Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8ce5f86d | ||
|
|
4cd89b1a60 | ||
|
|
cc62f52ab3 | ||
|
|
238fcd6fd7 | ||
|
|
78b64329cc | ||
|
|
afcc14cbb3 | ||
|
|
1b92fae76c | ||
|
|
4c76c77005 | ||
|
|
93cff6099e | ||
|
|
7bcaec6284 | ||
|
|
2f4fd7c040 | ||
|
|
1516e104df | ||
|
|
e9588aeba8 |
@@ -7,6 +7,80 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.22.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Registration funnel stats (first-party, no third-party analytics), showing where people drop off between seeing an event and paying.
|
||||
- A new cookie notice asks visitors once. Only those who choose "Allow" are counted, using a random anonymous visit id.
|
||||
- We record the event page view and the start of registration. The submit and paid stages come from the registration itself.
|
||||
- Only the stage, the traffic source and the device type are stored. No name, IP address or User-Agent is kept.
|
||||
- New report, **Registration funnel**: counts and conversion % per stage over a date range, a daily trend, and breakdowns by traffic source, device and ticket option.
|
||||
- Selecting several events compares them.
|
||||
- Sources come from `utm_source` links and from short links, which now add `?src=<alias>` when redirecting.
|
||||
- A "Conversion (past month)" tile on the supervisor and admin dashboards covers live events.
|
||||
- Raw visit records are rolled up into daily totals after 90 days by a daily job (`ANALYTICS_ROLLUP_ENABLED=false` to turn it off).
|
||||
- The privacy policy's cookies section now describes these statistics.
|
||||
- New `/api/analytics` endpoints.
|
||||
- Requires the `add_funnel_analytics` database migration.
|
||||
|
||||
## [1.21.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Post-event feedback, set up in a new Feedback step in the event wizard.
|
||||
- Questions use the form builder.
|
||||
- You pick when it's sent (the default is the morning after the event) and who gets it: everyone who checked in, or everyone registered.
|
||||
- Each person gets a personal link by email (and WhatsApp where they use it).
|
||||
- Anyone who hasn't answered gets one reminder 3 days later.
|
||||
- Answers can be anonymous or named.
|
||||
- "Send now" is available, and the questions lock once people start answering.
|
||||
- New form field types: **Rating (1–5 stars)** and **Multiple choice**. They're used for feedback, and can also go on attendee forms (registration page, My Events, the kiosk, manual registration and Supervisor → Forms).
|
||||
- New report, **Feedback summary**:
|
||||
- response rate;
|
||||
- average rating per question, with star distribution;
|
||||
- multiple-choice and yes/no breakdowns as bar charts;
|
||||
- a list of text answers (with names unless the form is anonymous).
|
||||
- Select several events to compare their ratings (e.g. Camp 2025 vs Camp 2026). Exports to PDF and Excel like other reports.
|
||||
- New `/api/feedback` endpoints and a job every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to turn it off).
|
||||
- Requires the `add_feedback_forms` database migration.
|
||||
|
||||
## [1.20.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Sponsor a spot: a new event setting, "Allow sponsorships" (event wizard → Sponsor a spot).
|
||||
- It adds a "Sponsor a spot" button on the event page so people can fund a place for someone who can't afford it.
|
||||
- Sponsors choose one or more whole spots (at the main ticket price) or a custom amount, and pay through Yoco. Guests just give a name and email.
|
||||
- Sponsors can give anonymously, or add a name and a message for the organisers.
|
||||
- An optional goal ("12 of 20 spots sponsored") and public progress bar.
|
||||
- Each sponsorship is recorded as a donation tagged to the event, in that event's sponsorship pool.
|
||||
- Supervisors assign pool money to registrations with the existing donation assign/unassign flow, fully or partly.
|
||||
- Under Payments → Donations, sponsorships are labelled, there's an "Only the sponsorship pool" filter, and the sponsor's message is shown.
|
||||
- Who receives help is never shown to sponsors.
|
||||
- Sponsors get a "Thank you for sponsoring a spot" receipt.
|
||||
- Automations has a new "Thank Your Sponsors" email for events with sponsorships.
|
||||
- Email attendees can target an event's sponsors (`filter.audience: 'sponsors'`).
|
||||
- The Donations breakdown report has sponsorship pool columns per event: received, assigned and remaining.
|
||||
- New `/api/sponsorships` endpoints.
|
||||
- Requires the `add_sponsorships` database migration.
|
||||
|
||||
## [1.19.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Rooms & groups (Supervisor → Rooms & groups), for camps and lockdowns: put attendees into rooms, cabins, small groups, teams or buses.
|
||||
- Each event can have several allocation sets (e.g. "Rooms" and "Buses"). Each set has groups with an optional capacity, an optional gender restriction and an optional leader (a staff member).
|
||||
- A drag-and-drop board puts people not yet placed on the left and groups, with capacity counters, on the right. Full groups and groups for the other gender won't accept a person.
|
||||
- Auto-allocate fills groups evenly with everyone not yet placed, respecting capacity and gender. It can keep people on the same booking together or spread them out.
|
||||
- Printable lists per group (with leader) and a master list. On youth events they can include medical and emergency details from consent forms; viewing those is recorded in the audit log.
|
||||
- "Show on tickets" prints the group on each person's ticket and shows it in My Events.
|
||||
- Email and WhatsApp attendees can be sent to one group ("Bus 2 leaves at 07:30"). Messages go to whoever booked for the people in it.
|
||||
- Group leaders see their people under Staff → My groups. Medical details are shown to supervisors only.
|
||||
- New event setting "Ask each person's gender" (under Named attendees). It adds a gender choice for each named person on the registration page, at the door, on the kiosk, in manual registration and in My Events, and is needed for gender-split rooms.
|
||||
- New `/api/allocations` endpoints.
|
||||
- Requires the `add_room_allocation` database migration.
|
||||
|
||||
## [1.18.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
+45
-3
@@ -171,6 +171,35 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
Each payment records **who recorded it** as well as who it's for.
|
||||
- **Refunds** — negative payments against an original payment, netted against
|
||||
the original method.
|
||||
- **Registration funnel stats** — first-party and consent-based, with no third-party analytics.
|
||||
- A cookie notice asks once. Only visitors who allow it get a random visit id.
|
||||
- We record event page views and registration starts. Submits and payments come
|
||||
from the registration itself.
|
||||
- Each record holds only the stage, the traffic source (utm_source, or the short-link
|
||||
alias, which short links now pass as `?src=`) and the device type.
|
||||
- The Registration funnel report shows counts and conversion per stage over a date
|
||||
range, a daily trend, and breakdowns by source, device and ticket option.
|
||||
- A "Conversion (past month)" tile on the supervisor/admin dashboards covers live
|
||||
events.
|
||||
- Raw rows are rolled up into daily totals after 90 days.
|
||||
- **Post-event feedback** — a Feedback step in the event wizard.
|
||||
- Questions use the form builder, with new **star rating (1–5)** and **multiple
|
||||
choice** types (also available on attendee forms).
|
||||
- The form goes out at a chosen time to everyone who checked in, or to everyone
|
||||
registered, each with a personal link (`/feedback/<token>`). One reminder goes
|
||||
3 days later to anyone who hasn't answered.
|
||||
- Answers can be anonymous or named. "Send now" is available, and the questions
|
||||
lock once people have answered.
|
||||
- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page
|
||||
lets anyone, including guests, pay for one or more spots (at the main ticket price)
|
||||
or any amount through Yoco. They can give anonymously or add a name and a message.
|
||||
The money is a donation tagged to the event and goes into its sponsorship pool, which
|
||||
supervisors assign to registrations with the usual donation flow.
|
||||
- An optional public progress bar ("12 of 20 spots sponsored") with an optional goal.
|
||||
- Sponsors get a thank-you receipt, and an Automations thank-you can go to all of an
|
||||
event's sponsors.
|
||||
- The Donations breakdown report shows each pool: received, assigned and remaining.
|
||||
- Who is helped is never shown.
|
||||
- **Donations** — users can donate from their dashboard (`/dashboard/user/donate`).
|
||||
Supervisors can **assign** a donation (fully or partly) to someone's
|
||||
registration and **unassign** it later. Assignments are kept as separate
|
||||
@@ -315,6 +344,17 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
|
||||
discount codes, and see who used each one.
|
||||
- **Rooms & groups** (`/dashboard/supervisor/allocations`):
|
||||
- Per event, allocation sets such as "Rooms", "Small groups" or "Buses". Each set
|
||||
has groups with an optional capacity, gender restriction and leader (staff).
|
||||
- A drag-and-drop board, plus auto-allocate: fill evenly, respecting capacity and
|
||||
gender, keeping bookings together or splitting them.
|
||||
- Printable per-group and master lists. On youth events they can include medical
|
||||
details, and viewing those is audited.
|
||||
- The group can show on tickets and in My Events.
|
||||
- Email and WhatsApp attendees can be filtered to one group.
|
||||
- Leaders see their people under Staff → **My groups**.
|
||||
- Works on named attendees. Events can ask each person's gender.
|
||||
- **Offline scanning** (`/dashboard/supervisor/offline-scans`) — devices holding
|
||||
offline locks (with force release) and sync reports to review.
|
||||
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
|
||||
@@ -375,9 +415,9 @@ a viewer with its own filters, stat tiles and bar charts.
|
||||
| Category | Reports |
|
||||
|----------|---------|
|
||||
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
|
||||
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list |
|
||||
| Registration | Registration funnel (views → started → submitted → paid, daily trend, by source / device / ticket option) · Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event) |
|
||||
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
@@ -448,6 +488,8 @@ falling back to a general guide.
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
|
||||
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
|
||||
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
|
||||
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
|
||||
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
@@ -487,5 +529,5 @@ falling back to a general guide.
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -23,6 +23,10 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Waitlist](#waitlist)
|
||||
- [Offline scanning](#offline-scanning)
|
||||
- [Ticket transfer](#ticket-transfer)
|
||||
- [Rooms & groups](#rooms--groups)
|
||||
- [Sponsor a spot](#sponsor-a-spot)
|
||||
- [Post-event feedback](#post-event-feedback)
|
||||
- [Registration funnel](#registration-funnel)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -280,6 +284,75 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Registration funnel
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/analytics/track` | public (rate-limited) | `{ sessionId, eventId, stage: 'view'\|'start', source? }` → always `204`. Bots are ignored; device type comes from the User-Agent |
|
||||
| GET | `/api/analytics/funnel?eventIds=&from=&to=` | supervisor+ | `{ totals, conversion, trend, bySource, byDevice, byOption, perEvent }` |
|
||||
|
||||
How the stages are recorded:
|
||||
- `POST /api/registrations` accepts `analyticsSessionId`. It's stored on the registration and records the `submit` stage.
|
||||
- `paid` is read from that registration's status.
|
||||
- Counts are sessions that reached a stage or a later one.
|
||||
|
||||
Data handling:
|
||||
- Sessions inactive for 90 days are rolled up into `AnalyticsDaily` by a daily job (`ANALYTICS_ROLLUP_ENABLED`).
|
||||
- `/api/stats/overview` now includes `funnel: { views, paid, conversion }` for live events over the past 30 days.
|
||||
|
||||
## Post-event feedback
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/feedback/event/:eventId` | supervisor+ | The event's feedback form (with `invited` / `responses` counts), or null |
|
||||
| PUT | `/api/feedback/event/:eventId` | supervisor+ | `{ sendAt, audience: 'checked_in'\|'all', anonymous, intro, fields[] }` — fields can't change once answered (409) |
|
||||
| DELETE | `/api/feedback/event/:eventId` | supervisor+ | Remove the form |
|
||||
| POST | `/api/feedback/event/:eventId/send-now` | supervisor+ | Send now (also invites anyone not yet invited) |
|
||||
| GET / POST | `/api/feedback/public/:token` | link holder | Load the form / submit `{ answers: { fieldId: value } }` once |
|
||||
| GET | `/api/feedback/summary?eventIds=a,b` | supervisor+ | Per event: invited, responses, response rate, and per question: rating average + distribution, choice counts, text answers (names only when not anonymous) |
|
||||
|
||||
Notes:
|
||||
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too.
|
||||
- Only HMACs of link tokens are stored, and a reminder issues a new link.
|
||||
- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
|
||||
|
||||
## Sponsor a spot
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/sponsorships/event/:eventId` | public | `{ enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors }` — never recipients |
|
||||
| POST | `/api/sponsorships/checkout` | public (optional login) | `{ eventId, spots? \| amount?, anonymous?, sponsorName?, message?, name?, email? }` → Yoco `redirectUrl`. Guests give a name and email |
|
||||
| GET | `/api/sponsorships/event/:eventId/pool` | supervisor+ | `{ received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots }` |
|
||||
|
||||
How the money is handled:
|
||||
- A spot costs the cheapest paid main ticket.
|
||||
- Yoco checkout metadata carries `type: 'sponsorship'`. The webhook records the payment as `isDonation` + `isSponsorship` against the event, together with `sponsorName`, `sponsorMessage` and `sponsorAnonymous`.
|
||||
- Pool funds are assigned with `PUT /api/payments/assign-donation`.
|
||||
|
||||
Other changes:
|
||||
- Event fields: `allowSponsorships`, `sponsorshipGoalSpots`, `showSponsorshipProgress`.
|
||||
- `POST /api/events/:id/email-attendees` accepts `filter.audience: 'sponsors'`.
|
||||
- `POST /api/automations/schedule` jobs accept `audience: 'sponsors'`.
|
||||
|
||||
## Rooms & groups
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/allocations/event/:eventId` | supervisor+ | Board: sets (groups, assignments), named attendees, possible leaders, unnamed places |
|
||||
| POST | `/api/allocations/sets` | supervisor+ | `{ eventId, name, showOnTicket? }` |
|
||||
| PUT / DELETE | `/api/allocations/sets/:id` | supervisor+ | Rename / show on tickets; delete |
|
||||
| POST | `/api/allocations/sets/:id/groups` | supervisor+ | `{ name, capacity?, gender? ('male'\|'female'), leaderUserId? }` |
|
||||
| PUT / DELETE | `/api/allocations/groups/:id` | supervisor+ | Edit (can't drop below current members or break gender); delete (members become unassigned) |
|
||||
| PUT | `/api/allocations/sets/:id/assignments` | supervisor+ | `{ attendeeId, groupId }` (null unassigns); enforces capacity and gender |
|
||||
| DELETE | `/api/allocations/sets/:id/assignments` | supervisor+ | Clear the set |
|
||||
| POST | `/api/allocations/sets/:id/auto` | supervisor+ | `{ mode: 'together' \| 'split' }` — places everyone not yet placed |
|
||||
| GET | `/api/allocations/sets/:id/lists?medical=true` | supervisor+ | Groups with members for printing (medical view is audited) |
|
||||
| GET | `/api/allocations/my-groups` | staff+ | Groups the caller leads (medical details for supervisors only) |
|
||||
|
||||
Related changes:
|
||||
- `Event.collectGender` makes a gender (`male` / `female`) required for each named attendee.
|
||||
- Email and WhatsApp attendees accept `filter.allocationGroupId`.
|
||||
|
||||
## Ticket transfer
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "collectGender" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationSet" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"showOnTicket" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AllocationSet_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationGroup" (
|
||||
"id" TEXT NOT NULL,
|
||||
"setId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"capacity" INTEGER,
|
||||
"gender" TEXT,
|
||||
"leaderUserId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AllocationGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationAssignment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"setId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AllocationAssignment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationSet_eventId_idx" ON "AllocationSet"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationGroup_setId_idx" ON "AllocationGroup"("setId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationGroup_leaderUserId_idx" ON "AllocationGroup"("leaderUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationAssignment_groupId_idx" ON "AllocationAssignment"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationAssignment_attendeeId_idx" ON "AllocationAssignment"("attendeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AllocationAssignment_setId_attendeeId_key" ON "AllocationAssignment"("setId", "attendeeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationSet" ADD CONSTRAINT "AllocationSet_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_leaderUserId_fkey" FOREIGN KEY ("leaderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "AllocationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowSponsorships" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "showSponsorshipProgress" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "sponsorshipGoalSpots" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" ADD COLUMN "isSponsorship" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "sponsorAnonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "sponsorMessage" TEXT,
|
||||
ADD COLUMN "sponsorName" TEXT;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "FormFieldType" ADD VALUE 'rating';
|
||||
ALTER TYPE "FormFieldType" ADD VALUE 'multiple_choice';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackForm" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"sendAt" TIMESTAMP(3),
|
||||
"audience" TEXT NOT NULL DEFAULT 'checked_in',
|
||||
"anonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||
"intro" TEXT,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"reminderSentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackForm_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackField" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"type" "FormFieldType" NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"isRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"helpText" TEXT,
|
||||
"options" JSONB,
|
||||
|
||||
CONSTRAINT "FeedbackField_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackInvite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"remindedAt" TIMESTAMP(3),
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "FeedbackInvite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackResponse" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FeedbackResponse_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackAnswer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"responseId" TEXT NOT NULL,
|
||||
"fieldId" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackAnswer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackForm_eventId_key" ON "FeedbackForm"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackField_formId_idx" ON "FeedbackField"("formId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackInvite_tokenHash_key" ON "FeedbackInvite"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackInvite_userId_idx" ON "FeedbackInvite"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackInvite_formId_userId_key" ON "FeedbackInvite"("formId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackResponse_formId_idx" ON "FeedbackResponse"("formId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackAnswer_responseId_idx" ON "FeedbackAnswer"("responseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackAnswer_fieldId_idx" ON "FeedbackAnswer"("fieldId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackForm" ADD CONSTRAINT "FeedbackForm_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackField" ADD CONSTRAINT "FeedbackField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackInvite" ADD CONSTRAINT "FeedbackInvite_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackInvite" ADD CONSTRAINT "FeedbackInvite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackResponse" ADD CONSTRAINT "FeedbackResponse_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackResponse" ADD CONSTRAINT "FeedbackResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackAnswer" ADD CONSTRAINT "FeedbackAnswer_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "FeedbackResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackAnswer" ADD CONSTRAINT "FeedbackAnswer_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "FeedbackField"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "analyticsSessionId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnalyticsEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"stage" TEXT NOT NULL,
|
||||
"source" TEXT,
|
||||
"device" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnalyticsDaily" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"day" DATE NOT NULL,
|
||||
"stage" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL DEFAULT 'direct',
|
||||
"device" TEXT NOT NULL DEFAULT 'unknown',
|
||||
"sessions" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "AnalyticsDaily_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnalyticsEvent_eventId_createdAt_idx" ON "AnalyticsEvent"("eventId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AnalyticsEvent_sessionId_eventId_stage_key" ON "AnalyticsEvent"("sessionId", "eventId", "stage");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnalyticsDaily_eventId_day_idx" ON "AnalyticsDaily"("eventId", "day");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AnalyticsDaily_eventId_day_stage_source_device_key" ON "AnalyticsDaily"("eventId", "day", "stage", "source", "device");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AnalyticsEvent" ADD CONSTRAINT "AnalyticsEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AnalyticsDaily" ADD CONSTRAINT "AnalyticsDaily_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
+179
-161
@@ -108,6 +108,9 @@ model User {
|
||||
scanLocks ScanLock[] @relation("ScanLockOwner")
|
||||
transfersTo TicketTransfer[] @relation("TransferRecipient")
|
||||
transfersBy TicketTransfer[] @relation("TransferActor")
|
||||
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
|
||||
feedbackInvites FeedbackInvite[]
|
||||
feedbackGiven FeedbackResponse[]
|
||||
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
|
||||
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
|
||||
|
||||
@@ -144,6 +147,15 @@ model Event {
|
||||
// fewer places than asked for (otherwise skip to the next person who fits).
|
||||
waitlistEnabled Boolean @default(false)
|
||||
allowTicketTransfer Boolean @default(true)
|
||||
collectGender Boolean @default(false)
|
||||
// Sponsor a spot: public donations into a bursary pool for this event.
|
||||
allowSponsorships Boolean @default(false)
|
||||
sponsorshipGoalSpots Int?
|
||||
showSponsorshipProgress Boolean @default(true)
|
||||
feedbackForm FeedbackForm?
|
||||
analyticsEvents AnalyticsEvent[]
|
||||
analyticsDaily AnalyticsDaily[]
|
||||
allocationSets AllocationSet[]
|
||||
waitlistOfferHours Int @default(24)
|
||||
waitlistPartialOffers Boolean @default(true)
|
||||
contactName String?
|
||||
@@ -256,6 +268,7 @@ model Registration {
|
||||
discountTotal Float @default(0)
|
||||
// Where named attendees' tickets go: all to the payer, or each to the attendee.
|
||||
ticketDelivery TicketDelivery @default(payer)
|
||||
analyticsSessionId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
@@ -327,6 +340,11 @@ model Payment {
|
||||
registrationId String?
|
||||
eventId String?
|
||||
isDonation Boolean @default(false)
|
||||
// A "sponsor a spot" donation (also isDonation): goes into the event's sponsorship pool.
|
||||
isSponsorship Boolean @default(false)
|
||||
sponsorName String?
|
||||
sponsorMessage String?
|
||||
sponsorAnonymous Boolean @default(false)
|
||||
externalId String? @unique
|
||||
status String?
|
||||
originalPaymentId String?
|
||||
@@ -526,6 +544,8 @@ enum FormFieldType {
|
||||
numeric
|
||||
statement
|
||||
paragraph
|
||||
rating
|
||||
multiple_choice
|
||||
}
|
||||
|
||||
model EventForm {
|
||||
@@ -734,167 +754,6 @@ model EventCashupPersonCountDenomination {
|
||||
@@index([countId])
|
||||
}
|
||||
|
||||
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
|
||||
//
|
||||
// model Ministry {
|
||||
// id String @id @default(uuid())
|
||||
// slug String @unique
|
||||
// title String
|
||||
// description String?
|
||||
// image String?
|
||||
// isActive Boolean @default(true)
|
||||
// order Int @default(0)
|
||||
// leaderId String?
|
||||
// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull)
|
||||
// interests MinistryInterest[]
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// @@index([leaderId])
|
||||
// }
|
||||
//
|
||||
// model TeamMember {
|
||||
// id String @id @default(uuid())
|
||||
// name String
|
||||
// title String
|
||||
// bio String?
|
||||
// photo String?
|
||||
// order Int @default(0)
|
||||
// isActive Boolean @default(true)
|
||||
// email String?
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// ministries Ministry[]
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// }
|
||||
//
|
||||
// model ContentBlock {
|
||||
// id String @id @default(uuid())
|
||||
// page String
|
||||
// key String
|
||||
// value String
|
||||
// type String @default("text")
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@unique([page, key])
|
||||
// @@index([page])
|
||||
// }
|
||||
//
|
||||
// model ChurchDocument {
|
||||
// id String @id @default(uuid())
|
||||
// title String
|
||||
// description String?
|
||||
// fileUrl String
|
||||
// category String @default("other")
|
||||
// isPublic Boolean @default(true)
|
||||
// uploadedAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// }
|
||||
//
|
||||
// model FellowshipGroup {
|
||||
// id String @id @default(uuid())
|
||||
// name String
|
||||
// description String?
|
||||
// leader String?
|
||||
// isActive Boolean @default(true)
|
||||
// order Int @default(0)
|
||||
// members ChurchMember[]
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// }
|
||||
//
|
||||
// model ChurchMember {
|
||||
// id String @id @default(uuid())
|
||||
// status String @default("pending")
|
||||
// mainName String
|
||||
// mainEmail String
|
||||
// mainPhone String?
|
||||
// mainBirthday DateTime?
|
||||
// mainOccupation String?
|
||||
// mainEmploymentStatus String?
|
||||
// mainDateOfSalvation DateTime?
|
||||
// mainDateOfWaterBaptism DateTime?
|
||||
// mainHolySpiritBaptism Boolean @default(false)
|
||||
// spouseName String?
|
||||
// spouseEmail String?
|
||||
// spousePhone String?
|
||||
// spouseBirthday DateTime?
|
||||
// spouseOccupation String?
|
||||
// spouseEmploymentStatus String?
|
||||
// spouseDateOfSalvation DateTime?
|
||||
// spouseDateOfWaterBaptism DateTime?
|
||||
// spouseHolySpiritBaptism Boolean?
|
||||
// homeAddress String?
|
||||
// inFellowshipGroup Boolean @default(false)
|
||||
// fellowshipGroupId String?
|
||||
// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull)
|
||||
// membershipConfirmed Boolean @default(false)
|
||||
// notes String?
|
||||
// appliedAt DateTime @default(now())
|
||||
// approvedAt DateTime?
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// children ChurchChild[]
|
||||
// ministryInterests MinistryInterest[]
|
||||
// donations Donation[]
|
||||
//
|
||||
// @@index([status])
|
||||
// @@index([mainEmail])
|
||||
// @@index([fellowshipGroupId])
|
||||
// }
|
||||
//
|
||||
// model ChurchChild {
|
||||
// id String @id @default(uuid())
|
||||
// memberId String
|
||||
// name String
|
||||
// birthday DateTime?
|
||||
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
//
|
||||
// @@index([memberId])
|
||||
// }
|
||||
//
|
||||
// model MinistryInterest {
|
||||
// id String @id @default(uuid())
|
||||
// memberId String
|
||||
// ministryId String
|
||||
// forSpouse Boolean @default(false)
|
||||
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade)
|
||||
//
|
||||
// @@unique([memberId, ministryId, forSpouse])
|
||||
// @@index([memberId])
|
||||
// @@index([ministryId])
|
||||
// }
|
||||
//
|
||||
// model Donation {
|
||||
// id String @id @default(uuid())
|
||||
// donorName String?
|
||||
// donorEmail String?
|
||||
// donorPhone String?
|
||||
// amount Float
|
||||
// method String
|
||||
// reference String?
|
||||
// note String?
|
||||
// isAnonymous Boolean @default(false)
|
||||
// linkedMemberId String?
|
||||
// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull)
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([linkedMemberId])
|
||||
// @@index([createdAt])
|
||||
// @@index([method])
|
||||
// }
|
||||
|
||||
// ─── Coupon codes ─────────────────────────────────────────────────────────────
|
||||
// A code gives a percentage, fixed-amount or 100% (comp) discount on a registration.
|
||||
// The discount is locked into a CouponRedemption when applied, like early-bird
|
||||
// tranches: later pricing refreshes never re-price it (see utils/coupons.js).
|
||||
|
||||
enum CouponType {
|
||||
percent
|
||||
fixed
|
||||
@@ -995,6 +854,164 @@ enum TicketDelivery {
|
||||
|
||||
// One named person on a group registration, for one main-ticket place. Gets their
|
||||
// own quantity-1 ticket and their own attendee form response.
|
||||
/// First-party funnel tracking (only after cookie consent): one row per anonymous
|
||||
/// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the
|
||||
/// registration that carries the session id. Rows older than 90 days are rolled up
|
||||
/// into AnalyticsDaily and deleted.
|
||||
model AnalyticsEvent {
|
||||
id String @id @default(uuid())
|
||||
sessionId String
|
||||
eventId String
|
||||
stage String
|
||||
source String?
|
||||
device String?
|
||||
createdAt DateTime @default(now())
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, eventId, stage])
|
||||
@@index([eventId, createdAt])
|
||||
}
|
||||
|
||||
/// Rolled-up funnel counts: sessions that reached `stage`, per day, source and device.
|
||||
model AnalyticsDaily {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
day DateTime @db.Date
|
||||
stage String
|
||||
source String @default("direct")
|
||||
device String @default("unknown")
|
||||
sessions Int @default(0)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([eventId, day, stage, source, device])
|
||||
@@index([eventId, day])
|
||||
}
|
||||
|
||||
/// Post-event survey. Sent at sendAt to checked-in attendees (or everyone registered),
|
||||
/// with one reminder 3 days later. anonymous responses aren't linked to a person.
|
||||
model FeedbackForm {
|
||||
id String @id @default(uuid())
|
||||
eventId String @unique
|
||||
sendAt DateTime?
|
||||
audience String @default("checked_in") // 'checked_in' | 'all'
|
||||
anonymous Boolean @default(false)
|
||||
intro String?
|
||||
sentAt DateTime?
|
||||
reminderSentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
fields FeedbackField[]
|
||||
invites FeedbackInvite[]
|
||||
responses FeedbackResponse[]
|
||||
}
|
||||
|
||||
model FeedbackField {
|
||||
id String @id @default(uuid())
|
||||
formId String
|
||||
type FormFieldType
|
||||
label String
|
||||
isRequired Boolean @default(false)
|
||||
order Int @default(0)
|
||||
helpText String?
|
||||
options Json?
|
||||
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
answers FeedbackAnswer[]
|
||||
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
/// Who was asked. tokenHash is the HMAC of the link token (the token itself isn't stored).
|
||||
model FeedbackInvite {
|
||||
id String @id @default(uuid())
|
||||
formId String
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
sentAt DateTime @default(now())
|
||||
remindedAt DateTime?
|
||||
respondedAt DateTime?
|
||||
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([formId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model FeedbackResponse {
|
||||
id String @id @default(uuid())
|
||||
formId String
|
||||
userId String? // null when the form is anonymous
|
||||
createdAt DateTime @default(now())
|
||||
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
answers FeedbackAnswer[]
|
||||
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
model FeedbackAnswer {
|
||||
id String @id @default(uuid())
|
||||
responseId String
|
||||
fieldId String
|
||||
value String
|
||||
response FeedbackResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
|
||||
field FeedbackField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([responseId])
|
||||
@@index([fieldId])
|
||||
}
|
||||
|
||||
/// A way of splitting an event's attendees up: "Rooms", "Small groups", "Buses".
|
||||
model AllocationSet {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
name String
|
||||
showOnTicket Boolean @default(false)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
groups AllocationGroup[]
|
||||
assignments AllocationAssignment[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
/// One room / group / bus in a set. gender ('male' | 'female') restricts who can join.
|
||||
model AllocationGroup {
|
||||
id String @id @default(uuid())
|
||||
setId String
|
||||
name String
|
||||
capacity Int?
|
||||
gender String?
|
||||
leaderUserId String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
leader User? @relation("AllocationGroupLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
|
||||
assignments AllocationAssignment[]
|
||||
|
||||
@@index([setId])
|
||||
@@index([leaderUserId])
|
||||
}
|
||||
|
||||
/// An attendee placed in a group. One group per set.
|
||||
model AllocationAssignment {
|
||||
id String @id @default(uuid())
|
||||
setId String
|
||||
groupId String
|
||||
attendeeId String
|
||||
createdAt DateTime @default(now())
|
||||
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
group AllocationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([setId, attendeeId])
|
||||
@@index([groupId])
|
||||
@@index([attendeeId])
|
||||
}
|
||||
|
||||
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
|
||||
/// when one place was split off a shared group ticket, whose code stays valid).
|
||||
model TicketTransfer {
|
||||
@@ -1039,6 +1056,7 @@ model Attendee {
|
||||
ticket Ticket?
|
||||
formResponses FormResponse[]
|
||||
consentRequests ConsentRequest[]
|
||||
allocations AllocationAssignment[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([registrationOptionId])
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { allocationError, cleanGender, joinBlocker, autoAllocate } = require('../utils/allocation');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const cleanName = (v, what) => {
|
||||
const name = String(v || '').trim();
|
||||
if (!name) throw allocationError(`Enter a name for the ${what}.`);
|
||||
return name.slice(0, 100);
|
||||
};
|
||||
const cleanCapacity = (v) => {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.');
|
||||
return n;
|
||||
};
|
||||
|
||||
/** Named attendees on the event's live registrations. */
|
||||
async function eventAttendees(eventId) {
|
||||
return prisma.attendee.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } } },
|
||||
select: {
|
||||
id: true, name: true, gender: true, dateOfBirth: true, registrationId: true, email: true, phone: true,
|
||||
registration: { select: { user: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSet(id) {
|
||||
const set = await prisma.allocationSet.findUnique({ where: { id }, include: { groups: { orderBy: { order: 'asc' } } } });
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
return set;
|
||||
}
|
||||
|
||||
// @desc Everything the allocation board needs for an event
|
||||
// @route GET /api/allocations/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const getBoard = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: req.params.eventId },
|
||||
select: { id: true, title: true, startDate: true, collectGender: true, allowNamedAttendees: true, requiresParentalConsent: true },
|
||||
});
|
||||
if (!event) throw allocationError('Event not found', 404);
|
||||
const [sets, attendees, leaders, places] = await Promise.all([
|
||||
prisma.allocationSet.findMany({
|
||||
where: { eventId: event.id },
|
||||
include: { groups: { orderBy: { order: 'asc' }, include: { leader: { select: { id: true, name: true } } } }, assignments: { select: { attendeeId: true, groupId: true } } },
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
eventAttendees(event.id),
|
||||
prisma.user.findMany({ where: { role: { in: ['staff', 'supervisor', 'admin'] }, isActive: true }, select: { id: true, name: true }, orderBy: { name: 'asc' } }),
|
||||
prisma.registrationOption.aggregate({
|
||||
where: { registration: { eventId: event.id, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
|
||||
_sum: { quantity: true },
|
||||
}),
|
||||
]);
|
||||
res.json({
|
||||
event,
|
||||
sets,
|
||||
attendees: attendees.map(a => ({ id: a.id, name: a.name, gender: a.gender, dateOfBirth: a.dateOfBirth, registrationId: a.registrationId, bookedBy: a.registration.user?.name || '' })),
|
||||
leaders,
|
||||
unnamedPlaces: Math.max(0, (places._sum.quantity || 0) - attendees.length),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/allocations/sets { eventId, name, showOnTicket? }
|
||||
const createSet = async (req, res) => {
|
||||
try {
|
||||
const { eventId, name, showOnTicket } = req.body || {};
|
||||
if (!(await prisma.event.findUnique({ where: { id: String(eventId || '') }, select: { id: true } }))) throw allocationError('Event not found', 404);
|
||||
const order = await prisma.allocationSet.count({ where: { eventId } });
|
||||
res.status(201).json(await prisma.allocationSet.create({ data: { eventId, name: cleanName(name, 'set'), showOnTicket: showOnTicket === true, order } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/allocations/sets/:id { name?, showOnTicket? }
|
||||
const updateSet = async (req, res) => {
|
||||
try {
|
||||
await loadSet(req.params.id);
|
||||
const { name, showOnTicket } = req.body || {};
|
||||
res.json(await prisma.allocationSet.update({
|
||||
where: { id: req.params.id },
|
||||
data: { ...(name !== undefined ? { name: cleanName(name, 'set') } : {}), ...(showOnTicket !== undefined ? { showOnTicket: showOnTicket === true } : {}) },
|
||||
}));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/sets/:id
|
||||
const deleteSet = async (req, res) => {
|
||||
try {
|
||||
await loadSet(req.params.id);
|
||||
await prisma.allocationSet.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function groupData(body, setId, existing = null) {
|
||||
const { name, capacity, gender, leaderUserId } = body || {};
|
||||
const data = {};
|
||||
if (name !== undefined || !existing) data.name = cleanName(name, 'group');
|
||||
if (capacity !== undefined) data.capacity = cleanCapacity(capacity);
|
||||
if (gender !== undefined) data.gender = cleanGender(gender);
|
||||
if (leaderUserId !== undefined) {
|
||||
if (leaderUserId) {
|
||||
const leader = await prisma.user.findUnique({ where: { id: String(leaderUserId) }, select: { role: true } });
|
||||
if (!leader || !['staff', 'supervisor', 'admin'].includes(leader.role)) throw allocationError('A group leader must be a staff member.');
|
||||
}
|
||||
data.leaderUserId = leaderUserId || null;
|
||||
}
|
||||
// Changing capacity or gender mustn't leave the group over capacity or mixed.
|
||||
if (existing && (data.capacity !== undefined || data.gender !== undefined)) {
|
||||
const members = await prisma.allocationAssignment.findMany({ where: { groupId: existing.id }, include: { attendee: { select: { name: true, gender: true } } } });
|
||||
const next = { ...existing, ...data };
|
||||
const blocker = joinBlocker(next, 0, members.map(m => m.attendee));
|
||||
if (blocker) throw allocationError(`${blocker} Move people out first.`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? }
|
||||
const createGroup = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const data = await groupData(req.body, set.id);
|
||||
res.status(201).json(await prisma.allocationGroup.create({ data: { ...data, setId: set.id, order: set.groups.length } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/allocations/groups/:id
|
||||
const updateGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await prisma.allocationGroup.findUnique({ where: { id: req.params.id } });
|
||||
if (!group) throw allocationError('Group not found', 404);
|
||||
const data = await groupData(req.body, group.setId, group);
|
||||
res.json(await prisma.allocationGroup.update({ where: { id: group.id }, data }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/groups/:id (its people go back to unassigned)
|
||||
const deleteGroup = async (req, res) => {
|
||||
try {
|
||||
await prisma.allocationGroup.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
if (error.code === 'P2025') return res.status(404).json({ message: 'Group not found' });
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put an attendee in a group (groupId null = unassign)
|
||||
// @route PUT /api/allocations/sets/:id/assignments { attendeeId, groupId }
|
||||
const assign = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const { attendeeId, groupId } = req.body || {};
|
||||
const attendee = await prisma.attendee.findFirst({
|
||||
where: { id: String(attendeeId || ''), registration: { eventId: set.eventId, status: { not: 'cancelled' } } },
|
||||
select: { id: true, name: true, gender: true },
|
||||
});
|
||||
if (!attendee) throw allocationError('Attendee not found for this event', 404);
|
||||
if (!groupId) {
|
||||
await prisma.allocationAssignment.deleteMany({ where: { setId: set.id, attendeeId: attendee.id } });
|
||||
return res.json({ message: 'Unassigned' });
|
||||
}
|
||||
const group = set.groups.find(g => g.id === groupId);
|
||||
if (!group) throw allocationError('Group not found in this set', 404);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'alloc:' + group.id}))`;
|
||||
const count = await tx.allocationAssignment.count({ where: { groupId: group.id, NOT: { attendeeId: attendee.id } } });
|
||||
const blocker = joinBlocker(group, count, [attendee]);
|
||||
if (blocker) throw allocationError(blocker);
|
||||
await tx.allocationAssignment.upsert({
|
||||
where: { setId_attendeeId: { setId: set.id, attendeeId: attendee.id } },
|
||||
update: { groupId: group.id },
|
||||
create: { setId: set.id, groupId: group.id, attendeeId: attendee.id },
|
||||
});
|
||||
});
|
||||
res.json({ message: `${attendee.name} → ${group.name}` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Place everyone not yet in a group
|
||||
// @route POST /api/allocations/sets/:id/auto { mode: 'together' | 'split' }
|
||||
const autoAssign = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
if (set.groups.length === 0) throw allocationError('Add some groups first.');
|
||||
const mode = req.body?.mode === 'split' ? 'split' : 'together';
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'allocset:' + set.id}))`;
|
||||
const [attendees, existing] = await Promise.all([
|
||||
eventAttendees(set.eventId),
|
||||
tx.allocationAssignment.findMany({ where: { setId: set.id } }),
|
||||
]);
|
||||
const r = autoAllocate({ groups: set.groups, attendees, assigned: new Map(existing.map(a => [a.attendeeId, a.groupId])), mode });
|
||||
if (r.placements.length) {
|
||||
await tx.allocationAssignment.createMany({ data: r.placements.map(p => ({ setId: set.id, groupId: p.groupId, attendeeId: p.attendeeId })), skipDuplicates: true });
|
||||
}
|
||||
return r;
|
||||
});
|
||||
res.json({
|
||||
placed: result.placements.length,
|
||||
unplaced: result.unplaced.length,
|
||||
message: `${result.placements.length} placed${result.unplaced.length ? `; ${result.unplaced.length} couldn't be placed (groups full, or gender doesn't match)` : ''}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/sets/:id/assignments (clear the whole set)
|
||||
const clearAssignments = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const { count } = await prisma.allocationAssignment.deleteMany({ where: { setId: set.id } });
|
||||
res.json({ message: `${count} removed from groups.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
/** Members of groups, with contact and (for supervisors) medical details. */
|
||||
async function groupMembers(groupIds, { withMedical }) {
|
||||
const assignments = await prisma.allocationAssignment.findMany({
|
||||
where: { groupId: { in: groupIds } },
|
||||
include: {
|
||||
attendee: {
|
||||
select: {
|
||||
id: true, name: true, gender: true, dateOfBirth: true, phone: true, parentName: true, parentPhone: true,
|
||||
registration: { select: { user: { select: { name: true, phoneNumber: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { attendee: { name: 'asc' } },
|
||||
});
|
||||
let consent = new Map();
|
||||
if (withMedical && assignments.length) {
|
||||
const { latestRequestsFor, openJson } = require('../utils/consent');
|
||||
const latest = await latestRequestsFor(assignments.map(a => a.attendeeId));
|
||||
consent = new Map([...latest].map(([id, r]) => [id, r.status === 'given' ? { medical: openJson(r.medical), emergency: openJson(r.emergency) } : null]));
|
||||
}
|
||||
return assignments.map(a => ({
|
||||
groupId: a.groupId,
|
||||
id: a.attendee.id,
|
||||
name: a.attendee.name,
|
||||
gender: a.attendee.gender,
|
||||
dateOfBirth: a.attendee.dateOfBirth,
|
||||
phone: a.attendee.phone,
|
||||
parentName: a.attendee.parentName,
|
||||
parentPhone: a.attendee.parentPhone,
|
||||
bookedBy: a.attendee.registration.user?.name || '',
|
||||
bookedByPhone: a.attendee.registration.user?.phoneNumber || null,
|
||||
...(withMedical ? consent.get(a.attendee.id) || {} : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// @desc Lists for printing: every group of a set, with members
|
||||
// @route GET /api/allocations/sets/:id/lists?medical=true
|
||||
// @access Private/Supervisor
|
||||
const getSetLists = async (req, res) => {
|
||||
try {
|
||||
const set = await prisma.allocationSet.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { event: { select: { id: true, title: true, startDate: true } }, groups: { orderBy: { order: 'asc' }, include: { leader: { select: { name: true } } } } },
|
||||
});
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
const withMedical = req.query.medical === 'true';
|
||||
const members = await groupMembers(set.groups.map(g => g.id), { withMedical });
|
||||
if (withMedical) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
res.json({ set: { id: set.id, name: set.name }, event: set.event, groups: set.groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, leader: g.leader?.name || null, members: members.filter(m => m.groupId === g.id) })) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Groups the signed-in staff member leads, with their people
|
||||
// @route GET /api/allocations/my-groups
|
||||
// @access Private/Staff
|
||||
const getMyGroups = async (req, res) => {
|
||||
try {
|
||||
const groups = await prisma.allocationGroup.findMany({
|
||||
where: { leaderUserId: req.user.id, set: { event: { endDate: { gte: new Date(Date.now() - 7 * 864e5) } } } },
|
||||
include: { set: { include: { event: { select: { id: true, title: true, startDate: true } } } } },
|
||||
orderBy: [{ set: { event: { startDate: 'asc' } } }, { order: 'asc' }],
|
||||
});
|
||||
const withMedical = SUPERVISORS.includes(req.user.role);
|
||||
const members = await groupMembers(groups.map(g => g.id), { withMedical });
|
||||
if (withMedical && members.length) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationGroup', targetId: null, metadata: { groups: groups.length, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
res.json({
|
||||
medicalShown: withMedical,
|
||||
groups: groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, setName: g.set.name, event: g.set.event, members: members.filter(m => m.groupId === g.id) })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup, assign, autoAssign, clearAssignments, getSetLists, getMyGroups };
|
||||
@@ -0,0 +1,44 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { track, funnelFor } = require('../utils/analytics');
|
||||
|
||||
// @desc Record a funnel step (event page view, registration started)
|
||||
// @route POST /api/analytics/track { sessionId, eventId, stage: 'view'|'start', source? }
|
||||
// @access Public — only sent by browsers whose visitor accepted cookies
|
||||
const trackStage = async (req, res) => {
|
||||
try {
|
||||
const { sessionId, eventId, stage, source } = req.body || {};
|
||||
await track({ sessionId, eventId, stage, source, userAgent: req.headers['user-agent'] });
|
||||
} catch (e) {
|
||||
console.warn('[analytics] track failed:', e?.message || e);
|
||||
}
|
||||
// Always a quiet 204 — tracking must never get in a visitor's way.
|
||||
res.status(204).end();
|
||||
};
|
||||
|
||||
// @desc Registration funnel for one or more events
|
||||
// @route GET /api/analytics/funnel?eventIds=a,b&from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
// @access Private/Supervisor
|
||||
const getFunnel = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
|
||||
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
|
||||
// Per event too, so events can be compared.
|
||||
const perEvent = [];
|
||||
if (events.length > 1) {
|
||||
for (const ev of events) {
|
||||
const f = await funnelFor([ev.id], { from: req.query.from, to: req.query.to });
|
||||
perEvent.push({ eventId: ev.id, title: ev.title, totals: f.totals, conversion: f.conversion });
|
||||
}
|
||||
} else if (events[0]) {
|
||||
perEvent.push({ eventId: events[0].id, title: events[0].title, totals: total.totals, conversion: total.conversion });
|
||||
}
|
||||
res.json({ ...total, perEvent });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { trackStage, getFunnel };
|
||||
@@ -24,7 +24,7 @@ async function scheduleAutomations(req, res) {
|
||||
html: j.html ? String(j.html) : undefined,
|
||||
text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined),
|
||||
template: 'custom',
|
||||
filter: { status: undefined, attendeeIds: undefined },
|
||||
filter: { status: undefined, attendeeIds: undefined, ...(j.audience === 'sponsors' ? { audience: 'sponsors' } : {}) },
|
||||
};
|
||||
if (j.promoEventId && typeof j.promoEventId === 'string') {
|
||||
payload.promoEventId = j.promoEventId;
|
||||
|
||||
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -71,6 +71,10 @@ const createEvent = async (req, res) => {
|
||||
consentCollectPhoto: consentCollectPhoto === true,
|
||||
waitlistEnabled: waitlistEnabled === true,
|
||||
allowTicketTransfer: allowTicketTransfer !== false,
|
||||
collectGender: collectGender === true,
|
||||
allowSponsorships: allowSponsorships === true,
|
||||
sponsorshipGoalSpots: sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null,
|
||||
showSponsorshipProgress: showSponsorshipProgress !== false,
|
||||
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
|
||||
waitlistPartialOffers: waitlistPartialOffers !== false,
|
||||
contactName: contactName || null,
|
||||
@@ -537,7 +541,7 @@ const updateEvent = async (req, res) => {
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -561,6 +565,10 @@ const updateEvent = async (req, res) => {
|
||||
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
|
||||
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
|
||||
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
|
||||
collectGender: collectGender !== undefined ? collectGender === true : event.collectGender,
|
||||
allowSponsorships: allowSponsorships !== undefined ? allowSponsorships === true : event.allowSponsorships,
|
||||
sponsorshipGoalSpots: sponsorshipGoalSpots !== undefined ? (sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null) : event.sponsorshipGoalSpots,
|
||||
showSponsorshipProgress: showSponsorshipProgress !== undefined ? showSponsorshipProgress !== false : event.showSponsorshipProgress,
|
||||
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
|
||||
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
|
||||
// Editing the consent text starts a new version; parents who already agreed keep
|
||||
@@ -1331,6 +1339,11 @@ const emailEventAttendees = async (req, res) => {
|
||||
where.status = 'cancelled';
|
||||
}
|
||||
|
||||
// Only registrations with someone in this allocation group ("everyone on Bus 2").
|
||||
if (filter?.allocationGroupId) {
|
||||
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
|
||||
}
|
||||
|
||||
// Optional explicit attendee selection by userIds
|
||||
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
||||
if (attendeeIds.length > 0) {
|
||||
@@ -1381,6 +1394,24 @@ const emailEventAttendees = async (req, res) => {
|
||||
}
|
||||
const recipients = Array.from(uniqMap.values());
|
||||
|
||||
// Sponsors of this event instead of its attendees (e.g. the post-event thank-you).
|
||||
if (filter?.audience === 'sponsors') {
|
||||
recipients.length = 0;
|
||||
registrations.length = 0;
|
||||
const sponsorships = await prisma.payment.findMany({
|
||||
where: { eventId, isDonation: true, isSponsorship: true, amount: { gt: 0 } },
|
||||
include: { user: { select: { name: true, email: true, isActive: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const seen = new Set();
|
||||
for (const s of sponsorships) {
|
||||
const email = (s.user?.email || '').trim();
|
||||
if (!email || email.endsWith('@guest.local') || email.endsWith('@deleted.invalid') || seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
recipients.push({ email, name: s.sponsorName || s.user?.name || '' });
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
}
|
||||
@@ -1569,6 +1600,11 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
where.status = 'cancelled';
|
||||
}
|
||||
|
||||
// Only registrations with someone in this allocation group ("everyone on Bus 2").
|
||||
if (filter?.allocationGroupId) {
|
||||
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
|
||||
}
|
||||
|
||||
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
||||
if (attendeeIds.length > 0) {
|
||||
where.userId = { in: attendeeIds };
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
|
||||
|
||||
async function formWithStats(eventId) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { fields: { orderBy: { order: 'asc' } } } });
|
||||
if (!form) return null;
|
||||
const [invited, responses] = await Promise.all([
|
||||
prisma.feedbackInvite.count({ where: { formId: form.id } }),
|
||||
prisma.feedbackResponse.count({ where: { formId: form.id } }),
|
||||
]);
|
||||
return { ...form, invited, responses };
|
||||
}
|
||||
|
||||
// @desc An event's feedback form (with invite/response counts)
|
||||
// @route GET /api/feedback/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const getEventFeedback = async (req, res) => {
|
||||
try {
|
||||
res.json(await formWithStats(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create or update an event's feedback form
|
||||
// @route PUT /api/feedback/event/:eventId { sendAt, audience, anonymous, intro, fields[] }
|
||||
// @access Private/Supervisor
|
||||
const saveEventFeedback = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, select: { id: true, endDate: true } });
|
||||
if (!event) throw feedbackError('Event not found', 404);
|
||||
const { sendAt, audience, anonymous, intro, fields } = req.body || {};
|
||||
const clean = cleanFields(fields);
|
||||
let when = null;
|
||||
if (sendAt) {
|
||||
when = new Date(sendAt);
|
||||
if (isNaN(when.getTime())) throw feedbackError('Enter a valid send time.');
|
||||
}
|
||||
const data = {
|
||||
sendAt: when,
|
||||
audience: audience === 'all' ? 'all' : 'checked_in',
|
||||
anonymous: anonymous === true,
|
||||
intro: intro ? String(intro).slice(0, 2000) : null,
|
||||
};
|
||||
|
||||
const existing = await prisma.feedbackForm.findUnique({ where: { eventId: event.id }, include: { fields: { orderBy: { order: 'asc' } } } });
|
||||
if (!existing) {
|
||||
await prisma.feedbackForm.create({ data: { ...data, eventId: event.id, fields: { create: clean } } });
|
||||
} else {
|
||||
const answered = await prisma.feedbackResponse.count({ where: { formId: existing.id } });
|
||||
const changed = shape(existing.fields) !== shape(clean);
|
||||
if (answered > 0 && changed) throw feedbackError('People have already answered this feedback form, so its questions can’t be changed.', 409);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.feedbackForm.update({ where: { id: existing.id }, data: existing.sentAt ? { ...data, sendAt: existing.sendAt } : data });
|
||||
if (changed) {
|
||||
await tx.feedbackField.deleteMany({ where: { formId: existing.id } });
|
||||
await tx.feedbackField.createMany({ data: clean.map(f => ({ ...f, formId: existing.id, options: f.options ?? undefined })) });
|
||||
}
|
||||
});
|
||||
}
|
||||
res.json(await formWithStats(event.id));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/feedback/event/:eventId
|
||||
const deleteEventFeedback = async (req, res) => {
|
||||
try {
|
||||
await prisma.feedbackForm.deleteMany({ where: { eventId: req.params.eventId } });
|
||||
res.json({ message: 'Feedback form removed' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send the feedback form now (instead of waiting for its send time)
|
||||
// @route POST /api/feedback/event/:eventId/send-now
|
||||
// @access Private/Supervisor
|
||||
const sendNow = async (req, res) => {
|
||||
try {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
|
||||
if (!form) throw feedbackError('This event has no feedback form.', 404);
|
||||
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
|
||||
// Also invites anyone who checked in after the first send.
|
||||
const invited = await sendFeedbackInvites(form.id);
|
||||
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The form behind a personal feedback link
|
||||
// @route GET /api/feedback/public/:token
|
||||
// @access Public (link holder)
|
||||
const getPublicForm = async (req, res) => {
|
||||
try {
|
||||
const invite = await findInviteByToken(req.params.token);
|
||||
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
|
||||
const { form } = invite;
|
||||
res.json({
|
||||
event: form.event,
|
||||
name: invite.user?.name || '',
|
||||
intro: form.intro,
|
||||
anonymous: form.anonymous,
|
||||
responded: !!invite.respondedAt,
|
||||
fields: form.fields.map(f => ({ id: f.id, type: f.type, label: f.label, isRequired: f.isRequired, helpText: f.helpText, options: f.options })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Submit feedback (once per person)
|
||||
// @route POST /api/feedback/public/:token { answers: { fieldId: value } }
|
||||
// @access Public (link holder)
|
||||
const submitPublicForm = async (req, res) => {
|
||||
try {
|
||||
const invite = await findInviteByToken(req.params.token);
|
||||
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
|
||||
if (invite.respondedAt) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
|
||||
const answers = req.body?.answers || {};
|
||||
const rows = [];
|
||||
for (const f of invite.form.fields.filter(x => ANSWERABLE.includes(x.type))) {
|
||||
const value = cleanAnswer(f, answers[f.id]);
|
||||
if (value !== null) rows.push({ fieldId: f.id, value });
|
||||
}
|
||||
if (rows.length === 0) throw feedbackError('Please answer at least one question.');
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Claim the invite first, so a double-submit can't create two responses.
|
||||
const claimed = await tx.feedbackInvite.updateMany({ where: { id: invite.id, respondedAt: null }, data: { respondedAt: new Date() } });
|
||||
if (claimed.count !== 1) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
|
||||
await tx.feedbackResponse.create({
|
||||
data: { formId: invite.formId, userId: invite.form.anonymous ? null : invite.userId, answers: { create: rows } },
|
||||
});
|
||||
});
|
||||
res.status(201).json({ message: 'Thank you for your feedback!' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Feedback summary for one or more events (for the report, and comparisons)
|
||||
// @route GET /api/feedback/summary?eventIds=a,b
|
||||
// @access Private/Supervisor
|
||||
const getSummary = async (req, res) => {
|
||||
try {
|
||||
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
const forms = await prisma.feedbackForm.findMany({
|
||||
where: { eventId: { in: ids } },
|
||||
include: {
|
||||
event: { select: { id: true, title: true, startDate: true } },
|
||||
fields: { orderBy: { order: 'asc' } },
|
||||
responses: { include: { answers: true, user: { select: { name: true } } } },
|
||||
_count: { select: { invites: true } },
|
||||
},
|
||||
});
|
||||
const out = forms.map(form => {
|
||||
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value, ...(form.anonymous ? {} : { name: r.user?.name || '' }) })));
|
||||
const invited = form._count.invites;
|
||||
return {
|
||||
eventId: form.event.id, title: form.event.title, startDate: form.event.startDate,
|
||||
anonymous: form.anonymous, sentAt: form.sentAt,
|
||||
invited, responses: form.responses.length,
|
||||
responseRate: invited ? Math.round((form.responses.length / invited) * 100) : null,
|
||||
questions: summarise(form.fields, answers),
|
||||
};
|
||||
}).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary };
|
||||
@@ -110,7 +110,7 @@ async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
|
||||
// @access Private
|
||||
const createRegistration = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
|
||||
|
||||
// Check if event exists
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
@@ -503,6 +503,12 @@ const createRegistration = async (req, res) => {
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics');
|
||||
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
@@ -2220,7 +2226,7 @@ const updateAttendees = async (req, res) => {
|
||||
where: { id: a.id },
|
||||
data: {
|
||||
name: c.name, email: c.email, phone: c.phone,
|
||||
dateOfBirth: c.dateOfBirth, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
||||
dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
||||
...(contactChanged ? { userId: match?.user?.id || null } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
const axios = require('axios');
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || error.response?.status || 400).json({ message: safeErrorMessage(error) });
|
||||
|
||||
async function sponsorableEvent(eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: String(eventId || '') }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||||
if (!event || !event.isActive) throw sponsorshipError('Event not found', 404);
|
||||
if (!event.allowSponsorships) throw sponsorshipError('This event isn’t taking sponsorships.');
|
||||
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) throw sponsorshipError('This event has ended and is no longer taking sponsorships.');
|
||||
return event;
|
||||
}
|
||||
|
||||
async function poolFor(event) {
|
||||
const sponsorships = await prisma.payment.findMany({ where: { eventId: event.id, isDonation: true, isSponsorship: true, amount: { gt: 0 } }, select: { id: true, amount: true } });
|
||||
const legs = sponsorships.length
|
||||
? await prisma.payment.findMany({ where: { originalPaymentId: { in: sponsorships.map(s => s.id) }, isDonation: false }, select: { originalPaymentId: true, amount: true } })
|
||||
: [];
|
||||
return poolSummary(sponsorships, legs, spotPrice(event));
|
||||
}
|
||||
|
||||
// @desc Public sponsorship progress for an event (never names who was helped)
|
||||
// @route GET /api/sponsorships/event/:eventId
|
||||
// @access Public
|
||||
const getEventSponsorship = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||||
if (!event || !event.isActive || !event.allowSponsorships) return res.json({ enabled: false });
|
||||
const pool = await poolFor(event);
|
||||
const open = event.cashupStatus !== 'closed' && new Date(event.endDate) >= new Date();
|
||||
res.json({
|
||||
enabled: true,
|
||||
open,
|
||||
spotPrice: spotPrice(event),
|
||||
showProgress: event.showSponsorshipProgress,
|
||||
goalSpots: event.sponsorshipGoalSpots,
|
||||
spotsFunded: event.showSponsorshipProgress ? pool.spotsFunded : null,
|
||||
sponsors: event.showSponsorshipProgress ? pool.sponsors : null,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start a Yoco checkout to sponsor spots (or a custom amount) at an event
|
||||
// @route POST /api/sponsorships/checkout { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email?, phone? }
|
||||
// @access Public (guests give a name and email for the receipt)
|
||||
const createSponsorCheckout = async (req, res) => {
|
||||
try {
|
||||
const { eventId, spots, amount, anonymous, sponsorName, message, name, email, phone } = req.body || {};
|
||||
const event = await sponsorableEvent(eventId);
|
||||
const charge = sponsorshipCharge({ spots, amount }, spotPrice(event));
|
||||
|
||||
let payer = req.user || null;
|
||||
if (!payer) {
|
||||
const { hasValidEmail, findOrCreateAccount } = require('../utils/accountMatch');
|
||||
const cleanName = String(name || '').trim().slice(0, 100);
|
||||
if (!cleanName) throw sponsorshipError('Enter your name.');
|
||||
if (!hasValidEmail(email)) throw sponsorshipError('Enter your email address so we can send your receipt.');
|
||||
// An existing account is reused; otherwise a quiet inactive one holds the receipt —
|
||||
// no activation link is sent, since a sponsor doesn't need to log in.
|
||||
payer = (await findOrCreateAccount({ name: cleanName, email: String(email).trim(), phone })).user;
|
||||
}
|
||||
const isAnonymous = anonymous === true;
|
||||
const displayName = isAnonymous ? null : (String(sponsorName || '').trim() || payer.name || '').slice(0, 100) || null;
|
||||
const note = String(message || '').trim().slice(0, 300) || null;
|
||||
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const back = `${base}/events/${encodeURIComponent(event.id)}`;
|
||||
const amountInCents = Math.round(charge.amount * 100);
|
||||
const label = charge.spots ? `Sponsor ${charge.spots} spot${charge.spots > 1 ? 's' : ''} at ${event.title}` : `Sponsorship for ${event.title}`;
|
||||
const checkoutData = {
|
||||
amount: amountInCents,
|
||||
currency: 'ZAR',
|
||||
metadata: {
|
||||
type: 'sponsorship', isDonation: true, eventId: event.id, eventTitle: event.title, userId: payer.id,
|
||||
sponsorAnonymous: isAnonymous ? 'true' : 'false',
|
||||
...(displayName ? { sponsorName: displayName } : {}),
|
||||
...(note ? { sponsorMessage: note } : {}),
|
||||
...(charge.spots ? { spots: String(charge.spots) } : {}),
|
||||
},
|
||||
successUrl: `${back}?sponsored=1`,
|
||||
cancelUrl: back,
|
||||
failureUrl: `${back}?sponsored=failed`,
|
||||
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
|
||||
};
|
||||
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
|
||||
});
|
||||
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
|
||||
} catch (error) {
|
||||
if (error.response) console.error('Sponsor checkout error:', error.response.data || error.message);
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The sponsorship pool for an event (supervisors)
|
||||
// @route GET /api/sponsorships/event/:eventId/pool
|
||||
// @access Private/Supervisor
|
||||
const getPool = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||||
if (!event) throw sponsorshipError('Event not found', 404);
|
||||
res.json({ eventId: event.id, spotPrice: spotPrice(event), goalSpots: event.sponsorshipGoalSpots, ...(await poolFor(event)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getEventSponsorship, createSponsorCheckout, getPool, poolFor };
|
||||
@@ -267,7 +267,21 @@ const getOverviewStats = async (req, res) => {
|
||||
ticketsSold: ticketSumById[r.eventId] || 0,
|
||||
}));
|
||||
|
||||
// Funnel conversion (event page views → paid) across live events, last 30 days.
|
||||
let funnel = null;
|
||||
try {
|
||||
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
|
||||
if (live.length) {
|
||||
const { funnelFor } = require('../utils/analytics');
|
||||
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
|
||||
if (f.totals.view > 0) funnel = { views: f.totals.view, paid: f.totals.paid, conversion: f.conversion.paid };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[stats] funnel tile failed:', e?.message || e);
|
||||
}
|
||||
|
||||
res.json({
|
||||
funnel,
|
||||
activeEvents,
|
||||
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
|
||||
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
|
||||
|
||||
@@ -95,7 +95,12 @@ const getUserTickets = async (req, res) => {
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
attendee: { select: { id: true, name: true } },
|
||||
attendee: {
|
||||
select: {
|
||||
id: true, name: true,
|
||||
allocations: { where: { set: { showOnTicket: true } }, select: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } },
|
||||
},
|
||||
},
|
||||
user: { select: { id: true, name: true } },
|
||||
event: true,
|
||||
usages: {
|
||||
@@ -488,7 +493,7 @@ const getTicketsByEvent = async (req, res) => {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
attendee: { select: { id: true, name: true, email: true, phone: true } },
|
||||
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true } },
|
||||
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -704,8 +709,8 @@ const sendTicketsTo = async (req, res) => {
|
||||
const transferTicketHandler = async (req, res) => {
|
||||
try {
|
||||
const { transferTicket } = require('../utils/ticketTransfer');
|
||||
const { name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone } = req.body || {};
|
||||
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone });
|
||||
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
|
||||
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
|
||||
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
@@ -767,6 +772,14 @@ const getTicketTransfers = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** "Room: Cabin 3 · Bus: Bus 2" for the sets shown on tickets, or ''. */
|
||||
function allocationLabel(attendee) {
|
||||
return [...(attendee?.allocations || [])]
|
||||
.sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0))
|
||||
.map(a => `${a.set.name}: ${a.group.name}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// Internal helper used by both emailTickets and sendTicketsTo
|
||||
async function emailTicketsInternal(req, res, userOverride) {
|
||||
const { ticketIds, registrationId } = req.body;
|
||||
@@ -789,7 +802,7 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
const ticketInclude = {
|
||||
event: true,
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: true,
|
||||
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
|
||||
user: { select: { name: true } },
|
||||
usages: true,
|
||||
};
|
||||
@@ -890,7 +903,8 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
|
||||
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const groupLabel = allocationLabel(ticket.attendee);
|
||||
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}${groupLabel ? ` · ${groupLabel}` : ''}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const eventDate = new Date(ticket.event.startDate);
|
||||
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
|
||||
@@ -325,7 +325,13 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
userId: resolvedUserId,
|
||||
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
|
||||
eventId: registration?.eventId || metadata?.eventId || null,
|
||||
isDonation: !registration?.id
|
||||
isDonation: !registration?.id,
|
||||
...(!registration?.id && metadata?.type === 'sponsorship' ? {
|
||||
isSponsorship: true,
|
||||
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
|
||||
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
|
||||
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
|
||||
} : {})
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
|
||||
@@ -179,6 +179,10 @@ app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
|
||||
app.use('/api/allocations', require('./routes/allocationRoutes'));
|
||||
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
|
||||
app.use('/api/feedback', require('./routes/feedbackRoutes'));
|
||||
app.use('/api/analytics', require('./routes/analyticsRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
@@ -635,6 +639,43 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
|
||||
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
|
||||
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
|
||||
{ method:'GET', path:'/api/analytics/funnel', auth:'supervisor+', desc:'Funnel for ?eventIds=a,b&from=&to= — totals, conversion, daily trend, by source/device/option' },
|
||||
]},
|
||||
{ title: 'Post-event feedback', base: '/api/feedback', endpoints: [
|
||||
{ method:'GET', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'An event\'s feedback form, with invite and response counts' },
|
||||
{ method:'PUT', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Create/update the feedback form (questions lock once answered)',
|
||||
request:{ body:{ sendAt:'2025-07-13T09:00:00.000Z', audience:'checked_in', anonymous:false, intro:'Thanks for coming!', fields:[{ type:'rating', label:'Overall?', isRequired:true }, { type:'multiple_choice', label:'Best part', options:['Worship','Talks'] }] }}},
|
||||
{ method:'DELETE', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Remove the feedback form' },
|
||||
{ method:'POST', path:'/api/feedback/event/:eventId/send-now', auth:'supervisor+', desc:'Send the feedback form now' },
|
||||
{ method:'GET', path:'/api/feedback/public/:token', auth:'Link holder', desc:'The form behind a personal feedback link' },
|
||||
{ method:'POST', path:'/api/feedback/public/:token', auth:'Link holder', desc:'Submit feedback once', request:{ body:{ answers:{ 'field-uuid':'5' } }}},
|
||||
{ method:'GET', path:'/api/feedback/summary', auth:'supervisor+', desc:'Feedback summary for ?eventIds=a,b (response rate, averages, breakdowns, comments)' },
|
||||
]},
|
||||
{ title: 'Sponsor a spot', base: '/api/sponsorships', endpoints: [
|
||||
{ method:'GET', path:'/api/sponsorships/event/:eventId', auth:'Public', desc:'Sponsorship progress for an event (spots funded, goal) — never who was helped',
|
||||
responses:[{ status:200, desc:'Progress', body:{ enabled:true, open:true, spotPrice:450, showProgress:true, goalSpots:20, spotsFunded:12, sponsors:9 }}]},
|
||||
{ method:'POST', path:'/api/sponsorships/checkout', auth:'Public (optional login)', desc:'Start a Yoco checkout to sponsor spots or a custom amount; the money lands in the event sponsorship pool',
|
||||
request:{ body:{ eventId:'ev-uuid-...', spots:2, anonymous:false, sponsorName:'The Smith family', message:'Have a great camp!', name:'Sam Smith', email:'sam@example.com' }},
|
||||
responses:[{ status:200, desc:'Checkout created', body:{ redirectUrl:'https://pay.yoco.com/...', amount:900 }}]},
|
||||
{ method:'GET', path:'/api/sponsorships/event/:eventId/pool', auth:'supervisor+', desc:'Sponsorship pool totals: received, assigned, remaining' },
|
||||
]},
|
||||
{ title: 'Rooms & groups', base: '/api/allocations', endpoints: [
|
||||
{ method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' },
|
||||
{ method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}},
|
||||
{ method:'PUT', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Rename a set or show it on tickets' },
|
||||
{ method:'DELETE', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Delete a set and its groups' },
|
||||
{ method:'POST', path:'/api/allocations/sets/:id/groups', auth:'supervisor+', desc:'Add a group', request:{ body:{ name:'Cabin 1', capacity:8, gender:'female', leaderUserId:'user-uuid-...' }}},
|
||||
{ method:'PUT', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Edit a group' },
|
||||
{ method:'DELETE', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Delete a group (its people become unassigned)' },
|
||||
{ method:'PUT', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Place (or with groupId null, unplace) an attendee; enforces capacity and gender', request:{ body:{ attendeeId:'att-uuid-...', groupId:'grp-uuid-...' }}},
|
||||
{ method:'DELETE', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Clear every assignment in a set' },
|
||||
{ method:'POST', path:'/api/allocations/sets/:id/auto', auth:'supervisor+', desc:'Auto-allocate everyone not yet placed', request:{ body:{ mode:'together' }}, responses:[{ status:200, desc:'Done', body:{ placed:12, unplaced:1 }}]},
|
||||
{ method:'GET', path:'/api/allocations/sets/:id/lists', auth:'supervisor+', desc:'Per-group lists for printing (?medical=true adds medical details; audited)' },
|
||||
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
|
||||
]},
|
||||
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
|
||||
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
|
||||
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
|
||||
@@ -1364,6 +1405,52 @@ app.listen(PORT, () => {
|
||||
console.warn('[waitlist] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
|
||||
try {
|
||||
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { rollupOldEvents } = require('./utils/analytics');
|
||||
const run = async () => {
|
||||
try {
|
||||
const n = await rollupOldEvents();
|
||||
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Rollup failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
setTimeout(run, 5 * 60 * 1000);
|
||||
setInterval(run, 24 * 60 * 60 * 1000);
|
||||
console.log('[analytics] Rollup daily.');
|
||||
} else {
|
||||
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Feedback job — send post-event feedback forms at their send time, and the one
|
||||
// reminder 3 days later.
|
||||
try {
|
||||
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runFeedbackJob } = require('./utils/feedback');
|
||||
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runFeedbackJob();
|
||||
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily parental-consent job — reminders for unanswered requests, and purging
|
||||
// medical/emergency details a set number of days after each event.
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup,
|
||||
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
|
||||
} = require('../controllers/allocationController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/my-groups', protect, staff, getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, getBoard);
|
||||
router.post('/sets', protect, supervisor, createSet);
|
||||
router.put('/sets/:id', protect, supervisor, updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, deleteGroup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { trackStage, getFunnel } = require('../controllers/analyticsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
const trackLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (req, res) => res.status(204).end(), // silently drop floods
|
||||
});
|
||||
|
||||
router.post('/track', trackLimiter, trackStage);
|
||||
router.get('/funnel', protect, supervisor, getFunnel);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/public/:token', getPublicForm);
|
||||
router.post('/public/:token', submitPublicForm);
|
||||
router.get('/summary', protect, supervisor, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, sendNow);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/event/:eventId', getEventSponsorship);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, getPool);
|
||||
router.post('/checkout', optionalAuth, createSponsorCheckout);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Group / room allocation — placing named attendees into the groups of an allocation
|
||||
* set ("Rooms", "Buses"…). Groups can have a capacity and a gender restriction.
|
||||
* The placement rules and auto-allocate are pure, so they're unit-tested.
|
||||
*/
|
||||
|
||||
const GENDERS = ['male', 'female'];
|
||||
|
||||
function allocationError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Normalises a gender value ('male' | 'female' | null); throws on anything else. */
|
||||
function cleanGender(value) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const g = String(value).trim().toLowerCase();
|
||||
if (!GENDERS.includes(g)) throw allocationError('Gender must be male or female.');
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `attendees` can't all join `group` (which has `count` members already), or null. Pure.
|
||||
* @param {object} group - { name, capacity?, gender? }
|
||||
*/
|
||||
function joinBlocker(group, count, attendees) {
|
||||
if (group.capacity != null && count + attendees.length > group.capacity) {
|
||||
return `${group.name} is full (${group.capacity} places).`;
|
||||
}
|
||||
if (group.gender) {
|
||||
const wrong = attendees.find(a => a.gender !== group.gender);
|
||||
if (wrong) return `${group.name} is for ${group.gender === 'male' ? 'males' : 'females'} only${wrong.gender ? '' : ` — ${wrong.name} has no gender recorded`}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places every unassigned attendee it can. Pure.
|
||||
*
|
||||
* Groups fill evenly (the emptiest group that fits goes first). With mode 'together',
|
||||
* people on the same registration (siblings) stay in one group when a group can take
|
||||
* them all, otherwise they're placed one by one. With mode 'split', people on the same
|
||||
* registration (friends booking together) are spread across groups where possible.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {Array} p.groups - [{ id, name, capacity?, gender?, order? }]
|
||||
* @param {Array} p.attendees - [{ id, name, registrationId, gender? }]
|
||||
* @param {Map} p.assigned - attendeeId → groupId for attendees already placed
|
||||
* @param {'together'|'split'} [p.mode]
|
||||
* @returns {{ placements: {attendeeId, groupId}[], unplaced: string[] }}
|
||||
*/
|
||||
function autoAllocate({ groups, attendees, assigned = new Map(), mode = 'together' }) {
|
||||
const count = new Map(groups.map(g => [g.id, 0]));
|
||||
const regInGroup = new Map(groups.map(g => [g.id, new Map()]));
|
||||
const byId = new Map(attendees.map(a => [a.id, a]));
|
||||
const place = (groupId, a) => {
|
||||
count.set(groupId, count.get(groupId) + 1);
|
||||
const regs = regInGroup.get(groupId);
|
||||
regs.set(a.registrationId, (regs.get(a.registrationId) || 0) + 1);
|
||||
};
|
||||
for (const [attendeeId, groupId] of assigned) {
|
||||
const a = byId.get(attendeeId);
|
||||
if (a && count.has(groupId)) place(groupId, a);
|
||||
}
|
||||
|
||||
const todo = attendees.filter(a => !assigned.has(a.id));
|
||||
let units;
|
||||
if (mode === 'together') {
|
||||
const byReg = new Map();
|
||||
for (const a of todo) byReg.set(a.registrationId, [...(byReg.get(a.registrationId) || []), a]);
|
||||
units = [...byReg.values()].sort((x, y) => y.length - x.length);
|
||||
} else {
|
||||
units = todo.map(a => [a]);
|
||||
}
|
||||
|
||||
const orderOf = new Map(groups.map((g, i) => [g.id, g.order ?? i]));
|
||||
const best = (members) => {
|
||||
const reg = members[0].registrationId;
|
||||
const fitting = groups.filter(g => !joinBlocker(g, count.get(g.id), members));
|
||||
if (fitting.length === 0) return null;
|
||||
fitting.sort((x, y) =>
|
||||
(mode === 'split' ? (regInGroup.get(x.id).get(reg) || 0) - (regInGroup.get(y.id).get(reg) || 0) : 0)
|
||||
|| count.get(x.id) - count.get(y.id)
|
||||
|| orderOf.get(x.id) - orderOf.get(y.id));
|
||||
return fitting[0];
|
||||
};
|
||||
|
||||
const placements = [];
|
||||
const unplaced = [];
|
||||
const queue = [...units];
|
||||
while (queue.length) {
|
||||
const unit = queue.shift();
|
||||
const g = best(unit);
|
||||
if (g) {
|
||||
for (const a of unit) { place(g.id, a); placements.push({ attendeeId: a.id, groupId: g.id }); }
|
||||
} else if (unit.length > 1) {
|
||||
// Can't keep them together — place them one by one instead.
|
||||
queue.unshift(...unit.map(a => [a]));
|
||||
} else {
|
||||
unplaced.push(unit[0].id);
|
||||
}
|
||||
}
|
||||
return { placements, unplaced };
|
||||
}
|
||||
|
||||
module.exports = { GENDERS, allocationError, cleanGender, joinBlocker, autoAllocate };
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Registration funnel — first-party, consent-based tracking.
|
||||
*
|
||||
* The browser only tracks after the visitor accepts cookies: it keeps a random session
|
||||
* id and reports 'view' (event page) and 'start' (registration page). 'submit' is
|
||||
* recorded here when a registration arrives carrying that session id, and 'paid' is
|
||||
* read from that registration's status, so no personal data is stored with the events —
|
||||
* just a stage, a traffic source and a device type (worked out from the User-Agent,
|
||||
* which isn't kept).
|
||||
*
|
||||
* Counts are "reached": a session counts at a stage if it got that far or further, so a
|
||||
* visitor who went straight to the registration page still counts as a view. Raw rows
|
||||
* older than 90 days are rolled up into AnalyticsDaily and deleted.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const STAGES = ['view', 'start', 'submit', 'paid'];
|
||||
const TRACKED = ['view', 'start'];
|
||||
const RAW_DAYS = 90;
|
||||
const SESSION_RE = /^[a-zA-Z0-9-]{16,64}$/;
|
||||
|
||||
const dayOf = (d) => new Date(d).toISOString().slice(0, 10);
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const validSession = (sid) => typeof sid === 'string' && SESSION_RE.test(sid);
|
||||
|
||||
/** 'mobile' | 'tablet' | 'desktop', or null for bots. Pure. */
|
||||
function deviceFromUA(ua) {
|
||||
const s = String(ua || '');
|
||||
if (!s || /bot|crawl|spider|slurp|preview|headless|lighthouse/i.test(s)) return null;
|
||||
if (/iPad|Tablet|PlayBook|Silk|(Android(?!.*Mobile))/i.test(s)) return 'tablet';
|
||||
if (/Mobi|iPhone|iPod|Android|Windows Phone/i.test(s)) return 'mobile';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/** A traffic source label: utm_source / short-link alias, tidied; 'direct' when none. Pure. */
|
||||
function cleanSource(src) {
|
||||
const s = String(src || '').trim().toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 50);
|
||||
return s || 'direct';
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per session: the furthest stage reached, its source/device, and the day each
|
||||
* stage was reached. Pure.
|
||||
* @param {Array} events - [{ sessionId, stage, createdAt, source, device }]
|
||||
* @param {Set} paidSessions - session ids whose registration is paid
|
||||
*/
|
||||
function sessionsFrom(events, paidSessions = new Set()) {
|
||||
const map = new Map();
|
||||
for (const e of [...events].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))) {
|
||||
const s = map.get(e.sessionId) || { sessionId: e.sessionId, reach: -1, source: null, device: null, days: {} };
|
||||
const idx = STAGES.indexOf(e.stage);
|
||||
if (idx > s.reach) s.reach = idx;
|
||||
if (!s.days[e.stage]) s.days[e.stage] = dayOf(e.createdAt);
|
||||
if (!s.source && e.source) s.source = e.source;
|
||||
if (!s.device && e.device) s.device = e.device;
|
||||
map.set(e.sessionId, s);
|
||||
}
|
||||
for (const s of map.values()) {
|
||||
if (paidSessions.has(s.sessionId) && s.reach >= 2) { s.reach = 3; s.days.paid = s.days.submit; }
|
||||
// A stage reached without its own event (e.g. no 'view' for a direct link) takes the
|
||||
// day of the next stage that was recorded.
|
||||
for (let i = STAGES.length - 1; i >= 0; i--) {
|
||||
if (i <= s.reach && !s.days[STAGES[i]]) s.days[STAGES[i]] = s.days[STAGES[i + 1]];
|
||||
}
|
||||
s.source = s.source || 'direct';
|
||||
s.device = s.device || 'unknown';
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
/** Rows for AnalyticsDaily: sessions reaching each stage per day/source/device. Pure. */
|
||||
function rollupRows(sessions) {
|
||||
const by = new Map();
|
||||
for (const s of sessions) {
|
||||
for (let i = 0; i <= s.reach; i++) {
|
||||
const key = [s.days[STAGES[i]], STAGES[i], s.source, s.device].join('|');
|
||||
by.set(key, (by.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...by.entries()].map(([key, n]) => {
|
||||
const [day, stage, source, device] = key.split('|');
|
||||
return { day, stage, source, device, sessions: n };
|
||||
});
|
||||
}
|
||||
|
||||
const emptyCounts = () => ({ view: 0, start: 0, submit: 0, paid: 0 });
|
||||
|
||||
/**
|
||||
* The funnel report from raw sessions plus rolled-up rows. Pure.
|
||||
* @returns {{ totals, conversion, trend, bySource, byDevice }}
|
||||
*/
|
||||
function funnelReport(sessions, daily = []) {
|
||||
const rows = [...rollupRows(sessions), ...daily.map(d => ({ ...d, day: typeof d.day === 'string' ? d.day.slice(0, 10) : dayOf(d.day) }))];
|
||||
const totals = emptyCounts();
|
||||
const trend = new Map(), bySource = new Map(), byDevice = new Map();
|
||||
const add = (map, key, stage, n) => { const c = map.get(key) || emptyCounts(); c[stage] += n; map.set(key, c); };
|
||||
for (const r of rows) {
|
||||
totals[r.stage] += r.sessions;
|
||||
add(trend, r.day, r.stage, r.sessions);
|
||||
add(bySource, r.source, r.stage, r.sessions);
|
||||
add(byDevice, r.device, r.stage, r.sessions);
|
||||
}
|
||||
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
|
||||
const list = (map) => [...map.entries()].map(([key, c]) => ({ key, ...c, conversion: pct(c.paid, c.view) })).sort((a, b) => b.view - a.view);
|
||||
return {
|
||||
totals,
|
||||
conversion: {
|
||||
start: pct(totals.start, totals.view), submit: pct(totals.submit, totals.view), paid: pct(totals.paid, totals.view),
|
||||
startToSubmit: pct(totals.submit, totals.start), submitToPaid: pct(totals.paid, totals.submit),
|
||||
},
|
||||
trend: [...trend.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, c]) => ({ day, ...c })),
|
||||
bySource: list(bySource),
|
||||
byDevice: list(byDevice),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Database ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Records a 'view' or 'start'. Repeats from the same session are ignored. */
|
||||
async function track({ sessionId, eventId, stage, source, userAgent }) {
|
||||
if (!validSession(sessionId) || !TRACKED.includes(stage) || typeof eventId !== 'string') return false;
|
||||
const device = deviceFromUA(userAgent);
|
||||
if (!device) return false; // bots
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
||||
if (!event) return false;
|
||||
await prisma.analyticsEvent.createMany({ data: [{ sessionId, eventId, stage, source: cleanSource(source), device }], skipDuplicates: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 'submit' for a registration made on the public registration page. */
|
||||
async function recordSubmit(registration, sessionId) {
|
||||
if (!validSession(sessionId) || !registration?.id) return;
|
||||
const first = await prisma.analyticsEvent.findFirst({ where: { sessionId, eventId: registration.eventId }, orderBy: { createdAt: 'asc' } });
|
||||
await prisma.registration.updateMany({ where: { id: registration.id, analyticsSessionId: null }, data: { analyticsSessionId: sessionId } });
|
||||
await prisma.analyticsEvent.createMany({
|
||||
data: [{ sessionId, eventId: registration.eventId, stage: 'submit', source: first?.source || 'direct', device: first?.device || null }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function paidSessionsFor(eventIds, sessionIds) {
|
||||
if (!sessionIds.length) return new Set();
|
||||
const regs = await prisma.registration.findMany({
|
||||
where: { eventId: { in: eventIds }, status: 'paid', analyticsSessionId: { in: sessionIds } },
|
||||
select: { analyticsSessionId: true },
|
||||
});
|
||||
return new Set(regs.map(r => r.analyticsSessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The funnel for some events between two dates (by the day each stage was reached).
|
||||
* Adds a by-ticket-option breakdown from the tracked registrations.
|
||||
*/
|
||||
async function funnelFor(eventIds, { from, to } = {}) {
|
||||
const range = {};
|
||||
if (from) range.gte = new Date(from);
|
||||
if (to) { const t = new Date(to); t.setHours(23, 59, 59, 999); range.lte = t; }
|
||||
const where = { eventId: { in: eventIds }, ...(from || to ? { createdAt: range } : {}) };
|
||||
const events = await prisma.analyticsEvent.findMany({ where, select: { sessionId: true, stage: true, createdAt: true, source: true, device: true, eventId: true } });
|
||||
// A session is per event (the same browser can look at several events).
|
||||
const keyed = events.map(e => ({ ...e, sessionId: `${e.eventId}:${e.sessionId}` }));
|
||||
const paidRaw = await paidSessionsFor(eventIds, [...new Set(events.map(e => e.sessionId))]);
|
||||
const paid = new Set(events.filter(e => paidRaw.has(e.sessionId)).map(e => `${e.eventId}:${e.sessionId}`));
|
||||
const daily = await prisma.analyticsDaily.findMany({
|
||||
where: { eventId: { in: eventIds }, ...(from || to ? { day: range } : {}) },
|
||||
select: { day: true, stage: true, source: true, device: true, sessions: true },
|
||||
});
|
||||
const report = funnelReport(sessionsFrom(keyed, paid), daily);
|
||||
|
||||
const regs = await prisma.registration.findMany({
|
||||
where: { eventId: { in: eventIds }, analyticsSessionId: { not: null }, status: { not: 'cancelled' }, ...(from || to ? { createdAt: range } : {}) },
|
||||
select: { status: true, registrationOptions: { select: { quantity: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } } },
|
||||
});
|
||||
const byOption = new Map();
|
||||
for (const r of regs) {
|
||||
for (const ro of r.registrationOptions) {
|
||||
const key = ro.variant ? `${ro.eventOption.name} — ${ro.variant.name}` : ro.eventOption.name;
|
||||
const c = byOption.get(key) || { key, submitted: 0, paid: 0 };
|
||||
c.submitted += 1;
|
||||
if (r.status === 'paid') c.paid += 1;
|
||||
byOption.set(key, c);
|
||||
}
|
||||
}
|
||||
return { ...report, byOption: [...byOption.values()].sort((a, b) => b.submitted - a.submitted) };
|
||||
}
|
||||
|
||||
/** Rolls up sessions whose last activity is older than 90 days, then deletes their rows. */
|
||||
async function rollupOldEvents(now = new Date()) {
|
||||
const cutoff = new Date(now.getTime() - RAW_DAYS * 864e5);
|
||||
const groups = await prisma.analyticsEvent.groupBy({ by: ['eventId', 'sessionId'], _max: { createdAt: true }, having: { createdAt: { _max: { lt: cutoff } } } });
|
||||
if (groups.length === 0) return 0;
|
||||
const byEvent = new Map();
|
||||
for (const g of groups) byEvent.set(g.eventId, [...(byEvent.get(g.eventId) || []), g.sessionId]);
|
||||
let rolled = 0;
|
||||
for (const [eventId, sessionIds] of byEvent) {
|
||||
for (let i = 0; i < sessionIds.length; i += 500) {
|
||||
const batch = sessionIds.slice(i, i + 500);
|
||||
const events = await prisma.analyticsEvent.findMany({ where: { eventId, sessionId: { in: batch } } });
|
||||
const paid = await paidSessionsFor([eventId], batch);
|
||||
const rows = rollupRows(sessionsFrom(events, paid));
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const r of rows) {
|
||||
await tx.analyticsDaily.upsert({
|
||||
where: { eventId_day_stage_source_device: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device } },
|
||||
update: { sessions: { increment: r.sessions } },
|
||||
create: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device, sessions: r.sessions },
|
||||
});
|
||||
}
|
||||
await tx.analyticsEvent.deleteMany({ where: { eventId, sessionId: { in: batch } } });
|
||||
});
|
||||
rolled += batch.length;
|
||||
}
|
||||
}
|
||||
return rolled;
|
||||
}
|
||||
|
||||
module.exports = { STAGES, RAW_DAYS, validSession, deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport, track, recordSubmit, funnelFor, rollupOldEvents };
|
||||
@@ -69,6 +69,13 @@ function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
} else if (consent) {
|
||||
throw attendeeError(`${name}: enter a date of birth.`);
|
||||
}
|
||||
let gender = null;
|
||||
if (a.gender) {
|
||||
gender = String(a.gender).trim().toLowerCase();
|
||||
if (!['male', 'female'].includes(gender)) throw attendeeError(`${name}: gender must be male or female.`);
|
||||
} else if (event.collectGender) {
|
||||
throw attendeeError(`${name}: choose a gender.`);
|
||||
}
|
||||
const parentName = String(a.parentName || '').trim() || null;
|
||||
const parentEmail = String(a.parentEmail || '').trim() || null;
|
||||
const parentPhone = String(a.parentPhone || '').trim() || null;
|
||||
@@ -78,7 +85,7 @@ function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
}
|
||||
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
|
||||
}
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone };
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone };
|
||||
});
|
||||
|
||||
// Youth events need every main place named (with a date of birth).
|
||||
@@ -187,6 +194,7 @@ async function createAttendees(registrationId, attendees, db = prisma) {
|
||||
email: a.email,
|
||||
phone: a.phone,
|
||||
dateOfBirth: a.dateOfBirth || null,
|
||||
gender: a.gender || null,
|
||||
parentName: a.parentName || null,
|
||||
parentEmail: a.parentEmail || null,
|
||||
parentPhone: a.parentPhone || null,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Post-event feedback.
|
||||
*
|
||||
* A FeedbackForm is sent once, at its sendAt, to everyone who checked in (or everyone
|
||||
* registered), with one reminder 3 days later to those who haven't answered. Each person
|
||||
* gets a personal link; only an HMAC of its token is stored, and a reminder issues a new
|
||||
* link. Anonymous forms record who has answered (so reminders skip them) but never link
|
||||
* the answers to the person.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const ANSWERABLE = ['text', 'numeric', 'date', 'yes_no', 'rating', 'multiple_choice'];
|
||||
const ALL_TYPES = [...ANSWERABLE, 'statement', 'paragraph'];
|
||||
const REMINDER_AFTER_MS = 3 * 864e5;
|
||||
|
||||
function feedbackError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`feedback:${token}`).digest('hex');
|
||||
const newToken = () => crypto.randomBytes(24).toString('hex');
|
||||
function feedbackLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/feedback/${token}`;
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Checks and tidies the questions from the form builder. Pure. */
|
||||
function cleanFields(fields) {
|
||||
if (!Array.isArray(fields)) throw feedbackError('fields must be a list');
|
||||
const out = fields.filter(f => f && String(f.label || '').trim()).map((f, i) => {
|
||||
if (!ALL_TYPES.includes(f.type)) throw feedbackError(`Question ${i + 1}: unknown type "${f.type}".`);
|
||||
const label = String(f.label).trim().slice(0, 500);
|
||||
let options = null;
|
||||
if (f.type === 'multiple_choice') {
|
||||
options = [...new Set((Array.isArray(f.options) ? f.options : []).map(o => String(o || '').trim()).filter(Boolean))].slice(0, 20);
|
||||
if (options.length < 2) throw feedbackError(`"${label}": a multiple-choice question needs at least two choices.`);
|
||||
}
|
||||
return {
|
||||
type: f.type, label, order: i, options,
|
||||
isRequired: ANSWERABLE.includes(f.type) && !!f.isRequired,
|
||||
helpText: f.helpText ? String(f.helpText).slice(0, 1000) : null,
|
||||
};
|
||||
});
|
||||
if (out.length > 50) throw feedbackError('A feedback form can have at most 50 questions.');
|
||||
if (!out.some(f => ANSWERABLE.includes(f.type))) throw feedbackError('Add at least one question.');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The stored value for one answer (null when blank); throws when it's invalid. Pure. */
|
||||
function cleanAnswer(field, raw) {
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||
if (field.isRequired) throw feedbackError(`Please answer: ${field.label}`);
|
||||
return null;
|
||||
}
|
||||
const v = String(raw).trim();
|
||||
switch (field.type) {
|
||||
case 'rating': {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 5) throw feedbackError(`"${field.label}": choose 1 to 5 stars.`);
|
||||
return String(n);
|
||||
}
|
||||
case 'multiple_choice':
|
||||
if (!(field.options || []).includes(v)) throw feedbackError(`"${field.label}": choose one of the options.`);
|
||||
return v;
|
||||
case 'yes_no':
|
||||
if (!['yes', 'no'].includes(v.toLowerCase())) throw feedbackError(`"${field.label}": answer yes or no.`);
|
||||
return v.toLowerCase();
|
||||
case 'numeric':
|
||||
if (!Number.isFinite(Number(v))) throw feedbackError(`"${field.label}": enter a number.`);
|
||||
return v;
|
||||
case 'date':
|
||||
if (isNaN(new Date(v).getTime())) throw feedbackError(`"${field.label}": enter a valid date.`);
|
||||
return v.slice(0, 10);
|
||||
default:
|
||||
return v.slice(0, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-question summary. Pure.
|
||||
* @param {Array} fields - [{ id, type, label, options }]
|
||||
* @param {Array} answers - [{ fieldId, value, name? }] (name: respondent, for named forms)
|
||||
*/
|
||||
function summarise(fields, answers) {
|
||||
const by = new Map();
|
||||
for (const a of answers) by.set(a.fieldId, [...(by.get(a.fieldId) || []), a]);
|
||||
const round1 = (n) => Math.round(n * 10) / 10;
|
||||
return fields.filter(f => ANSWERABLE.includes(f.type)).map(f => {
|
||||
const list = by.get(f.id) || [];
|
||||
const base = { fieldId: f.id, type: f.type, label: f.label, answered: list.length };
|
||||
if (f.type === 'rating') {
|
||||
const nums = list.map(a => Number(a.value)).filter(n => n >= 1 && n <= 5);
|
||||
const distribution = [1, 2, 3, 4, 5].map(star => ({ label: `${star}★`, value: nums.filter(n => n === star).length }));
|
||||
return { ...base, average: nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null, distribution };
|
||||
}
|
||||
if (f.type === 'multiple_choice' || f.type === 'yes_no') {
|
||||
const choices = f.type === 'yes_no' ? ['yes', 'no'] : (f.options || []);
|
||||
return { ...base, counts: choices.map(c => ({ label: f.type === 'yes_no' ? (c === 'yes' ? 'Yes' : 'No') : c, value: list.filter(a => a.value === c).length })) };
|
||||
}
|
||||
if (f.type === 'numeric') {
|
||||
const nums = list.map(a => Number(a.value)).filter(Number.isFinite);
|
||||
return { ...base, average: nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null };
|
||||
}
|
||||
return { ...base, texts: list.map(a => ({ value: a.value, ...(a.name ? { name: a.name } : {}) })) };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sending ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** People to ask: those who checked in (a scan on their ticket), or everyone registered. */
|
||||
async function feedbackRecipients(form) {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
eventId: form.eventId,
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } },
|
||||
...(form.audience === 'all' ? {} : { usages: { some: {} } }),
|
||||
},
|
||||
select: { userId: true, holderUserId: true, attendee: { select: { userId: true } } },
|
||||
});
|
||||
const ids = new Set(tickets.map(t => t.holderUserId || t.attendee?.userId || t.userId));
|
||||
if (form.audience === 'all') {
|
||||
const regs = await prisma.registration.findMany({ where: { eventId: form.eventId, status: { not: 'cancelled' } }, select: { userId: true } });
|
||||
regs.forEach(r => ids.add(r.userId));
|
||||
}
|
||||
if (ids.size === 0) return [];
|
||||
return prisma.user.findMany({ where: { id: { in: [...ids] } }, select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } });
|
||||
}
|
||||
|
||||
async function sendFeedbackMessage(user, event, token, { reminder = false } = {}) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const link = feedbackLink(token);
|
||||
const subject = `${reminder ? 'Reminder: ' : ''}How was ${event.title}?`;
|
||||
const emailOk = !!(user.email && user.email.includes('@') && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'));
|
||||
const sent = [];
|
||||
if (emailOk) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A quick reminder — ' : ''}How was ${event.title}?</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Thank you for coming to <strong>${event.title}</strong>. We'd love to hear what you thought — it only takes a minute.</p>
|
||||
${ctaButton('Give feedback', link)}
|
||||
${fallbackLink(link)}
|
||||
`, { preheader: `Tell us about ${event.title}` });
|
||||
try {
|
||||
await sendMail({ to: user.email, subject, html, text: `Hi ${user.name || 'there'},\n\nThank you for coming to ${event.title}. We'd love to hear what you thought: ${link}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[feedback] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
const wantsWA = ['whatsapp', 'both'].includes(user.notificationPreference) || !emailOk;
|
||||
if (wantsWA && user.phoneNumber && isValidZAPhone(user.phoneNumber)) {
|
||||
await waTextAny(user, `${reminder ? '⏰ ' : ''}Hi ${user.name || 'there'}, thank you for coming to *${event.title}*! We'd love your feedback — it only takes a minute: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Sends the form to everyone not yet invited. Returns how many were invited. */
|
||||
async function sendFeedbackInvites(formId) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true, invites: { select: { userId: true } } } });
|
||||
if (!form) return 0;
|
||||
const already = new Set(form.invites.map(i => i.userId));
|
||||
const people = (await feedbackRecipients(form)).filter(u => !already.has(u.id));
|
||||
for (const user of people) {
|
||||
const token = newToken();
|
||||
try {
|
||||
await prisma.feedbackInvite.create({ data: { formId: form.id, userId: user.id, tokenHash: hashToken(token) } });
|
||||
} catch (e) {
|
||||
if (e.code === 'P2002') continue; // invited by a concurrent run
|
||||
throw e;
|
||||
}
|
||||
await sendFeedbackMessage(user, form.event, token).catch(e => console.warn('[feedback] send failed:', e?.message || e));
|
||||
}
|
||||
return people.length;
|
||||
}
|
||||
|
||||
/** The single reminder: a fresh link for everyone who hasn't answered. */
|
||||
async function sendFeedbackReminders(formId) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true } });
|
||||
const invites = await prisma.feedbackInvite.findMany({
|
||||
where: { formId, respondedAt: null, remindedAt: null },
|
||||
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } },
|
||||
});
|
||||
for (const inv of invites) {
|
||||
const token = newToken();
|
||||
await prisma.feedbackInvite.update({ where: { id: inv.id }, data: { tokenHash: hashToken(token), remindedAt: new Date() } });
|
||||
await sendFeedbackMessage(inv.user, form.event, token, { reminder: true }).catch(e => console.warn('[feedback] reminder failed:', e?.message || e));
|
||||
}
|
||||
return invites.length;
|
||||
}
|
||||
|
||||
/** Background job: send forms whose time has come, and reminders 3 days after. */
|
||||
async function runFeedbackJob(now = new Date()) {
|
||||
let sent = 0, reminded = 0;
|
||||
const due = await prisma.feedbackForm.findMany({ where: { sentAt: null, sendAt: { lte: now } }, select: { id: true } });
|
||||
for (const f of due) {
|
||||
// Claim it first so two servers (or overlapping runs) never both send.
|
||||
const claimed = await prisma.feedbackForm.updateMany({ where: { id: f.id, sentAt: null }, data: { sentAt: now } });
|
||||
if (claimed.count === 1) sent += await sendFeedbackInvites(f.id);
|
||||
}
|
||||
const remind = await prisma.feedbackForm.findMany({ where: { reminderSentAt: null, sentAt: { lte: new Date(now.getTime() - REMINDER_AFTER_MS) } }, select: { id: true } });
|
||||
for (const f of remind) {
|
||||
const claimed = await prisma.feedbackForm.updateMany({ where: { id: f.id, reminderSentAt: null }, data: { reminderSentAt: now } });
|
||||
if (claimed.count === 1) reminded += await sendFeedbackReminders(f.id);
|
||||
}
|
||||
return { sent, reminded };
|
||||
}
|
||||
|
||||
async function findInviteByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 200) return null;
|
||||
return prisma.feedbackInvite.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { user: { select: { id: true, name: true } }, form: { include: { event: { select: { id: true, title: true, startDate: true, endDate: true } }, fields: { orderBy: { order: 'asc' } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, feedbackError, hashToken, feedbackLink,
|
||||
cleanFields, cleanAnswer, summarise,
|
||||
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackJob, findInviteByToken,
|
||||
};
|
||||
@@ -533,7 +533,7 @@ function buildPaymentReceipt(payment) {
|
||||
}
|
||||
|
||||
// Donation receipt
|
||||
const subject = `Donation received – ${eventTitle}`;
|
||||
const subject = payment.isSponsorship ? `Thank you for sponsoring a spot – ${eventTitle}` : `Donation received – ${eventTitle}`;
|
||||
const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Thank you for your donation!</p>
|
||||
@@ -541,7 +541,7 @@ function buildPaymentReceipt(payment) {
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.
|
||||
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.${payment.isSponsorship ? ' It goes into the sponsorship pool and helps someone attend who otherwise couldn’t.' : ''}
|
||||
</p>
|
||||
|
||||
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} donated</strong><br/>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Sponsor a spot — public donations into an event's bursary pool.
|
||||
*
|
||||
* A sponsorship is an ordinary event donation (isDonation) also flagged isSponsorship,
|
||||
* so supervisors hand it out with the existing assign/unassign-donation flow. These
|
||||
* helpers are pure: what one spot costs, what a sponsor is charged, and the pool totals.
|
||||
*/
|
||||
|
||||
const { computeDonationRemaining } = require('./donationUtils');
|
||||
|
||||
const MIN_AMOUNT = 15; // Yoco's minimum charge, same as other donations
|
||||
const MAX_SPOTS = 100;
|
||||
const MAX_AMOUNT = 100000;
|
||||
|
||||
function sponsorshipError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** The price of one spot: the cheapest paid main ticket (or the event price), or null if free. */
|
||||
function spotPrice(event) {
|
||||
const prices = (event.eventOptions || [])
|
||||
.filter(o => o.isMainTicket && Number(o.price) > 0)
|
||||
.map(o => Number(o.price));
|
||||
if (prices.length) return Math.min(...prices);
|
||||
return Number(event.price) > 0 ? Number(event.price) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sponsor pays: `spots` whole spots, or a custom `amount`.
|
||||
* @returns {{ amount: number, spots: number|null }}
|
||||
*/
|
||||
function sponsorshipCharge({ spots, amount }, price) {
|
||||
if (spots !== undefined && spots !== null && spots !== '') {
|
||||
const n = Number(spots);
|
||||
if (!Number.isInteger(n) || n < 1 || n > MAX_SPOTS) throw sponsorshipError(`Choose between 1 and ${MAX_SPOTS} spots.`);
|
||||
if (!price) throw sponsorshipError('This event has no ticket price — enter an amount instead.');
|
||||
return { amount: Math.round(n * price * 100) / 100, spots: n };
|
||||
}
|
||||
const a = Math.round(Number(amount) * 100) / 100;
|
||||
if (!Number.isFinite(a) || a <= 0) throw sponsorshipError('Enter an amount, or choose a number of spots.');
|
||||
if (a < MIN_AMOUNT) throw sponsorshipError(`The minimum is R${MIN_AMOUNT}.`);
|
||||
if (a > MAX_AMOUNT) throw sponsorshipError(`For gifts over R${MAX_AMOUNT.toLocaleString('en-ZA')}, please contact us directly.`);
|
||||
return { amount: a, spots: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool totals for an event.
|
||||
* @param {Array} sponsorships - sponsorship donation payments [{ id, amount }]
|
||||
* @param {Array} legs - payments with originalPaymentId set (assignments, refunds)
|
||||
* @param {number|null} price - one spot
|
||||
*/
|
||||
function poolSummary(sponsorships, legs, price) {
|
||||
const legsBy = new Map();
|
||||
for (const l of legs || []) legsBy.set(l.originalPaymentId, [...(legsBy.get(l.originalPaymentId) || []), l]);
|
||||
let received = 0, remaining = 0;
|
||||
for (const s of sponsorships || []) {
|
||||
received += Number(s.amount) || 0;
|
||||
remaining += Math.max(0, computeDonationRemaining(s.amount, legsBy.get(s.id) || []));
|
||||
}
|
||||
const round = (n) => Math.round(n * 100) / 100;
|
||||
return {
|
||||
received: round(received),
|
||||
assigned: round(received - remaining),
|
||||
remaining: round(remaining),
|
||||
sponsors: (sponsorships || []).length,
|
||||
spotsFunded: price ? Math.floor((received + 0.001) / price) : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { MIN_AMOUNT, MAX_SPOTS, sponsorshipError, spotPrice, sponsorshipCharge, poolSummary };
|
||||
@@ -76,7 +76,7 @@ async function transferTicket({ ticketId, actor, ...holder }) {
|
||||
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
|
||||
const attendeeData = {
|
||||
name: clean.name, email: clean.email, phone: clean.phone, userId: toUser.id,
|
||||
dateOfBirth: clean.dateOfBirth, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone,
|
||||
dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone,
|
||||
};
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
const { joinBlocker, autoAllocate, cleanGender } = require('../src/utils/allocation');
|
||||
|
||||
const person = (id, registrationId, gender = null) => ({ id, name: id, registrationId, gender });
|
||||
const groupsOf = (result) => {
|
||||
const out = {};
|
||||
for (const p of result.placements) (out[p.groupId] = out[p.groupId] || []).push(p.attendeeId);
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('cleanGender', () => {
|
||||
test('accepts male/female in any case, blank as null', () => {
|
||||
expect(cleanGender('Male')).toBe('male');
|
||||
expect(cleanGender('')).toBeNull();
|
||||
expect(() => cleanGender('other')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinBlocker', () => {
|
||||
test('capacity', () => {
|
||||
expect(joinBlocker({ name: 'Cabin 1', capacity: 2 }, 1, [person('a', 'r')])).toBeNull();
|
||||
expect(joinBlocker({ name: 'Cabin 1', capacity: 2 }, 2, [person('a', 'r')])).toMatch(/full/);
|
||||
});
|
||||
|
||||
test('gender', () => {
|
||||
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r', 'male')])).toBeNull();
|
||||
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r', 'female')])).toMatch(/males only/);
|
||||
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r')])).toMatch(/no gender recorded/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoAllocate', () => {
|
||||
test('fills groups evenly', () => {
|
||||
const groups = [{ id: 'g1', name: 'A' }, { id: 'g2', name: 'B' }];
|
||||
const attendees = ['a', 'b', 'c', 'd'].map(id => person(id, id));
|
||||
const r = autoAllocate({ groups, attendees });
|
||||
const g = groupsOf(r);
|
||||
expect(g.g1.length).toBe(2);
|
||||
expect(g.g2.length).toBe(2);
|
||||
expect(r.unplaced).toEqual([]);
|
||||
});
|
||||
|
||||
test('respects capacity and reports who could not be placed', () => {
|
||||
const groups = [{ id: 'g1', name: 'A', capacity: 1 }];
|
||||
const r = autoAllocate({ groups, attendees: [person('a', 'r1'), person('b', 'r2')] });
|
||||
expect(r.placements.length).toBe(1);
|
||||
expect(r.unplaced.length).toBe(1);
|
||||
});
|
||||
|
||||
test('respects gender', () => {
|
||||
const groups = [{ id: 'boys', name: 'Boys', gender: 'male' }, { id: 'girls', name: 'Girls', gender: 'female' }];
|
||||
const r = autoAllocate({ groups, attendees: [person('a', 'r1', 'male'), person('b', 'r2', 'female'), person('c', 'r3')] });
|
||||
expect(groupsOf(r)).toEqual({ boys: ['a'], girls: ['b'] });
|
||||
expect(r.unplaced).toEqual(['c']);
|
||||
});
|
||||
|
||||
test('keeps a registration together when a group can take them all', () => {
|
||||
const groups = [{ id: 'g1', name: 'A', capacity: 3 }, { id: 'g2', name: 'B', capacity: 3 }];
|
||||
const attendees = [person('s1', 'fam'), person('s2', 'fam'), person('x', 'solo')];
|
||||
const g = groupsOf(autoAllocate({ groups, attendees, mode: 'together' }));
|
||||
const famGroup = Object.keys(g).find(k => g[k].includes('s1'));
|
||||
expect(g[famGroup]).toContain('s2');
|
||||
});
|
||||
|
||||
test('places siblings one by one when no group fits them all', () => {
|
||||
const groups = [{ id: 'boys', name: 'Boys', gender: 'male' }, { id: 'girls', name: 'Girls', gender: 'female' }];
|
||||
const r = autoAllocate({ groups, attendees: [person('bro', 'fam', 'male'), person('sis', 'fam', 'female')], mode: 'together' });
|
||||
expect(groupsOf(r)).toEqual({ boys: ['bro'], girls: ['sis'] });
|
||||
});
|
||||
|
||||
test('split mode spreads a registration across groups', () => {
|
||||
const groups = [{ id: 'g1', name: 'A' }, { id: 'g2', name: 'B' }];
|
||||
const attendees = [person('f1', 'friends'), person('f2', 'friends')];
|
||||
const g = groupsOf(autoAllocate({ groups, attendees, mode: 'split' }));
|
||||
expect(g.g1.length).toBe(1);
|
||||
expect(g.g2.length).toBe(1);
|
||||
});
|
||||
|
||||
test('counts people already placed and leaves them where they are', () => {
|
||||
const groups = [{ id: 'g1', name: 'A', capacity: 2 }, { id: 'g2', name: 'B', capacity: 2 }];
|
||||
const attendees = [person('a', 'r1'), person('b', 'r2'), person('c', 'r3')];
|
||||
const r = autoAllocate({ groups, attendees, assigned: new Map([['a', 'g1'], ['b', 'g1']]) });
|
||||
expect(r.placements).toEqual([{ attendeeId: 'c', groupId: 'g2' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const { deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport } = require('../src/utils/analytics');
|
||||
|
||||
const ev = (sessionId, stage, day, extra = {}) => ({ sessionId, stage, createdAt: `${day}T10:00:00Z`, ...extra });
|
||||
|
||||
describe('deviceFromUA', () => {
|
||||
test('mobile, tablet, desktop and bots', () => {
|
||||
expect(deviceFromUA('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile/15E148')).toBe('mobile');
|
||||
expect(deviceFromUA('Mozilla/5.0 (Linux; Android 14; SM-X200) Safari')).toBe('tablet');
|
||||
expect(deviceFromUA('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120')).toBe('desktop');
|
||||
expect(deviceFromUA('Googlebot/2.1')).toBeNull();
|
||||
expect(deviceFromUA('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSource', () => {
|
||||
test('tidies, defaulting to direct', () => {
|
||||
expect(cleanSource(' Facebook ')).toBe('facebook');
|
||||
expect(cleanSource('camp-2025<script>')).toBe('camp-2025script');
|
||||
expect(cleanSource('')).toBe('direct');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionsFrom', () => {
|
||||
test('a session counts as having reached every earlier stage', () => {
|
||||
const [s] = sessionsFrom([ev('a', 'start', '2026-01-02')]);
|
||||
expect(s.reach).toBe(1);
|
||||
expect(s.days.view).toBe('2026-01-02');
|
||||
});
|
||||
|
||||
test('paid only counts for sessions that submitted', () => {
|
||||
const out = sessionsFrom([ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'submit', '2026-01-03'), ev('b', 'view', '2026-01-01')], new Set(['a', 'b']));
|
||||
const a = out.find(s => s.sessionId === 'a');
|
||||
expect(a.reach).toBe(3);
|
||||
expect(a.days.paid).toBe('2026-01-03');
|
||||
expect(a.source).toBe('fb');
|
||||
expect(out.find(s => s.sessionId === 'b').reach).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('funnelReport', () => {
|
||||
const sessions = sessionsFrom([
|
||||
ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'start', '2026-01-01'), ev('a', 'submit', '2026-01-01'),
|
||||
ev('b', 'view', '2026-01-01', { source: 'fb', device: 'desktop' }), ev('b', 'start', '2026-01-02'),
|
||||
ev('c', 'view', '2026-01-02', { device: 'mobile' }),
|
||||
ev('d', 'view', '2026-01-02', { device: 'mobile' }),
|
||||
], new Set(['a']));
|
||||
|
||||
test('counts and conversion per stage', () => {
|
||||
const r = funnelReport(sessions);
|
||||
expect(r.totals).toEqual({ view: 4, start: 2, submit: 1, paid: 1 });
|
||||
expect(r.conversion.start).toBe(50);
|
||||
expect(r.conversion.paid).toBe(25);
|
||||
expect(r.conversion.startToSubmit).toBe(50);
|
||||
});
|
||||
|
||||
test('daily trend and breakdowns', () => {
|
||||
const r = funnelReport(sessions);
|
||||
expect(r.trend).toEqual([{ day: '2026-01-01', view: 2, start: 1, submit: 1, paid: 1 }, { day: '2026-01-02', view: 2, start: 1, submit: 0, paid: 0 }]);
|
||||
expect(r.bySource.find(s => s.key === 'fb')).toMatchObject({ view: 2, paid: 1, conversion: 50 });
|
||||
expect(r.bySource.find(s => s.key === 'direct').view).toBe(2);
|
||||
expect(r.byDevice.find(s => s.key === 'mobile').view).toBe(3);
|
||||
});
|
||||
|
||||
test('rolled-up rows add to the raw counts', () => {
|
||||
const daily = rollupRows(sessionsFrom([ev('x', 'view', '2025-10-01'), ev('x', 'submit', '2025-10-01')], new Set(['x'])));
|
||||
expect(daily.find(r => r.stage === 'paid').sessions).toBe(1);
|
||||
const r = funnelReport(sessions, daily);
|
||||
expect(r.totals).toEqual({ view: 5, start: 3, submit: 2, paid: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const { cleanFields, cleanAnswer, summarise } = require('../src/utils/feedback');
|
||||
|
||||
describe('cleanFields', () => {
|
||||
test('keeps labelled questions in order, tidying choices', () => {
|
||||
const out = cleanFields([
|
||||
{ type: 'rating', label: ' Overall ', isRequired: true },
|
||||
{ type: 'text', label: '' },
|
||||
{ type: 'multiple_choice', label: 'Best part', options: ['Worship', ' Talks ', 'Worship', ''] },
|
||||
{ type: 'statement', label: 'Thanks!', isRequired: true },
|
||||
]);
|
||||
expect(out.map(f => [f.type, f.label, f.order])).toEqual([['rating', 'Overall', 0], ['multiple_choice', 'Best part', 1], ['statement', 'Thanks!', 2]]);
|
||||
expect(out[1].options).toEqual(['Worship', 'Talks']);
|
||||
expect(out[2].isRequired).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects bad forms', () => {
|
||||
expect(() => cleanFields([{ type: 'stars', label: 'X' }])).toThrow(/unknown type/);
|
||||
expect(() => cleanFields([{ type: 'multiple_choice', label: 'X', options: ['only one'] }])).toThrow(/two choices/);
|
||||
expect(() => cleanFields([{ type: 'statement', label: 'Hello' }])).toThrow(/at least one question/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanAnswer', () => {
|
||||
test('ratings are whole stars from 1 to 5', () => {
|
||||
expect(cleanAnswer({ type: 'rating', label: 'R' }, '4')).toBe('4');
|
||||
expect(() => cleanAnswer({ type: 'rating', label: 'R' }, '6')).toThrow();
|
||||
expect(() => cleanAnswer({ type: 'rating', label: 'R' }, '3.5')).toThrow();
|
||||
});
|
||||
|
||||
test('multiple choice must be one of the options', () => {
|
||||
const f = { type: 'multiple_choice', label: 'M', options: ['A', 'B'] };
|
||||
expect(cleanAnswer(f, 'B')).toBe('B');
|
||||
expect(() => cleanAnswer(f, 'C')).toThrow();
|
||||
});
|
||||
|
||||
test('required questions need an answer; optional ones may be blank', () => {
|
||||
expect(() => cleanAnswer({ type: 'text', label: 'Why?', isRequired: true }, ' ')).toThrow(/Please answer/);
|
||||
expect(cleanAnswer({ type: 'text', label: 'Why?' }, '')).toBeNull();
|
||||
expect(cleanAnswer({ type: 'yes_no', label: 'Again?' }, 'YES')).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarise', () => {
|
||||
const fields = [
|
||||
{ id: 'r', type: 'rating', label: 'Overall' },
|
||||
{ id: 'm', type: 'multiple_choice', label: 'Best part', options: ['Worship', 'Talks'] },
|
||||
{ id: 't', type: 'text', label: 'Comments' },
|
||||
{ id: 's', type: 'statement', label: 'Thanks' },
|
||||
];
|
||||
const answers = [
|
||||
{ fieldId: 'r', value: '5' }, { fieldId: 'r', value: '4' }, { fieldId: 'r', value: '4' },
|
||||
{ fieldId: 'm', value: 'Talks' }, { fieldId: 'm', value: 'Talks' }, { fieldId: 'm', value: 'Worship' },
|
||||
{ fieldId: 't', value: 'Loved it', name: 'Ann' },
|
||||
];
|
||||
|
||||
test('averages ratings with a star distribution', () => {
|
||||
const [r] = summarise(fields, answers);
|
||||
expect(r.average).toBe(4.3);
|
||||
expect(r.distribution.map(d => d.value)).toEqual([0, 0, 0, 2, 1]);
|
||||
});
|
||||
|
||||
test('counts choices and lists text answers; skips statements', () => {
|
||||
const out = summarise(fields, answers);
|
||||
expect(out.length).toBe(3);
|
||||
expect(out[1].counts).toEqual([{ label: 'Worship', value: 1 }, { label: 'Talks', value: 2 }]);
|
||||
expect(out[2].texts).toEqual([{ value: 'Loved it', name: 'Ann' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
const { spotPrice, sponsorshipCharge, poolSummary } = require('../src/utils/sponsorship');
|
||||
|
||||
describe('spotPrice', () => {
|
||||
test('is the cheapest paid main ticket', () => {
|
||||
expect(spotPrice({ price: 0, eventOptions: [
|
||||
{ isMainTicket: true, price: 450 }, { isMainTicket: true, price: 300 }, { isMainTicket: false, price: 50 }, { isMainTicket: true, price: 0 },
|
||||
] })).toBe(300);
|
||||
});
|
||||
|
||||
test('falls back to the event price, or null for free events', () => {
|
||||
expect(spotPrice({ price: 200, eventOptions: [] })).toBe(200);
|
||||
expect(spotPrice({ price: 0, eventOptions: [{ isMainTicket: true, price: 0 }] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sponsorshipCharge', () => {
|
||||
test('whole spots cost the spot price each', () => {
|
||||
expect(sponsorshipCharge({ spots: 3 }, 450)).toEqual({ amount: 1350, spots: 3 });
|
||||
});
|
||||
|
||||
test('a custom amount is taken as given (2 decimals)', () => {
|
||||
expect(sponsorshipCharge({ amount: '120.555' }, 450)).toEqual({ amount: 120.56, spots: null });
|
||||
});
|
||||
|
||||
test('rejects bad input', () => {
|
||||
expect(() => sponsorshipCharge({ spots: 0 }, 450)).toThrow(/between 1/);
|
||||
expect(() => sponsorshipCharge({ spots: 1.5 }, 450)).toThrow();
|
||||
expect(() => sponsorshipCharge({ spots: 1 }, null)).toThrow(/no ticket price/);
|
||||
expect(() => sponsorshipCharge({ amount: 10 }, 450)).toThrow(/minimum/);
|
||||
expect(() => sponsorshipCharge({}, 450)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('poolSummary', () => {
|
||||
test('received, assigned and remaining, with spots funded', () => {
|
||||
const sponsorships = [{ id: 'a', amount: 900 }, { id: 'b', amount: 300 }];
|
||||
const legs = [{ originalPaymentId: 'a', amount: 450 }, { originalPaymentId: 'b', amount: -100 }];
|
||||
expect(poolSummary(sponsorships, legs, 450)).toEqual({ received: 1200, assigned: 550, remaining: 650, sponsors: 2, spotsFunded: 2 });
|
||||
});
|
||||
|
||||
test('no spot count for free events', () => {
|
||||
expect(poolSummary([{ id: 'a', amount: 50 }], [], null).spotsFunded).toBeNull();
|
||||
});
|
||||
});
|
||||
Generated
+42
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
@@ -343,6 +344,45 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
|
||||
@@ -40,7 +40,7 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
}
|
||||
|
||||
if (event.isLive || event.isActive) {
|
||||
redirect(`/events/${event.id}`);
|
||||
redirect(`/events/${event.id}?src=${encodeURIComponent(redirectUrl)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
@@ -42,6 +43,7 @@ const QUICK_ACTIONS = [
|
||||
|
||||
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
||||
type Overview = {
|
||||
funnel?: { views: number; paid: number; conversion: number | null } | null;
|
||||
activeEvents: number;
|
||||
registrations: OverviewMetric;
|
||||
ticketsSold: OverviewMetric;
|
||||
@@ -119,12 +121,15 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatCardRow>
|
||||
<StatCardRow cols={overview?.funnel ? 6 : 5}>
|
||||
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
|
||||
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
|
||||
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
|
||||
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href="/dashboard/admin/registrations" linkLabel="View registrations" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
|
||||
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
|
||||
{overview?.funnel && (
|
||||
<StatCard icon={Filter} label="Conversion (past month)" value={overview.funnel.conversion != null ? `${overview.funnel.conversion}%` : "—"} tone="violet" href={`${REPORTS_URL}?report=funnel&range=trailing_month`} linkLabel={`${overview.funnel.paid} of ${overview.funnel.views} visits`} />
|
||||
)}
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
type Member = {
|
||||
id: string; name: string; gender: string | null; dateOfBirth: string | null; phone: string | null;
|
||||
parentName: string | null; parentPhone: string | null; bookedBy: string; bookedByPhone: string | null;
|
||||
medical?: Record<string, string> | null; emergency?: Record<string, string> | null;
|
||||
};
|
||||
type MyGroup = { id: string; name: string; capacity: number | null; setName: string; event: { id: string; title: string; startDate: string }; members: Member[] };
|
||||
|
||||
const ageOn = (dob: string | null, on: string) => {
|
||||
if (!dob) return null;
|
||||
const d = new Date(dob), e = new Date(on);
|
||||
let age = e.getFullYear() - d.getFullYear();
|
||||
if (e.getMonth() < d.getMonth() || (e.getMonth() === d.getMonth() && e.getDate() < d.getDate())) age -= 1;
|
||||
return age;
|
||||
};
|
||||
|
||||
/** Groups the signed-in staff member leads (rooms, small groups, buses) and who's in them. */
|
||||
export default function MyGroupsPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<{ medicalShown: boolean; groups: MyGroup[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<{ medicalShown: boolean; groups: MyGroup[] }>("/api/allocations/my-groups", { authToken: token })
|
||||
.then(setData).catch(e => setError(e?.message || "Failed to load your groups"));
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Users className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">My groups</h1>
|
||||
<p className="text-sm text-gray-500">Rooms, small groups and buses you lead</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{data && data.groups.length === 0 && <p className="text-sm text-gray-600">You aren't leading any groups for upcoming events.</p>}
|
||||
<div className="space-y-4">
|
||||
{data?.groups.map(g => (
|
||||
<section key={g.id} className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||
<h2 className="font-semibold">{g.name} <span className="text-sm font-normal text-gray-500">· {g.setName}</span></h2>
|
||||
<span className="text-xs text-gray-500">{g.event.title} · {formatDate(new Date(g.event.startDate))} · {g.members.length}{g.capacity ? ` / ${g.capacity}` : ""} people</span>
|
||||
</div>
|
||||
{g.members.length === 0 ? <p className="text-sm text-gray-500">Nobody has been placed in this group yet.</p> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-xs text-gray-500 border-b">
|
||||
<th className="py-1 pr-3">Name</th><th className="py-1 pr-3">Age</th><th className="py-1 pr-3">Contact</th>
|
||||
{data.medicalShown && <th className="py-1 pr-3">Medical</th>}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{g.members.map(m => {
|
||||
const age = ageOn(m.dateOfBirth, g.event.startDate);
|
||||
return (
|
||||
<tr key={m.id} className="border-b last:border-0 align-top">
|
||||
<td className="py-1.5 pr-3 font-medium">{m.name}</td>
|
||||
<td className="py-1.5 pr-3">{age ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-xs">
|
||||
{m.phone && <div>{m.phone}</div>}
|
||||
{m.parentName ? <div>Parent: {m.parentName}{m.parentPhone ? ` · ${m.parentPhone}` : ""}</div> : m.bookedBy && <div>Booked by {m.bookedBy}{m.bookedByPhone ? ` · ${m.bookedByPhone}` : ""}</div>}
|
||||
</td>
|
||||
{data.medicalShown && (
|
||||
<td className="py-1.5 pr-3 text-xs">
|
||||
{m.medical ? Object.values(m.medical).filter(Boolean).join("; ") || "—" : "—"}
|
||||
{m.emergency && Object.values(m.emergency).some(Boolean) && <div className="text-gray-500">Emergency: {Object.values(m.emergency).filter(Boolean).join(", ")}</div>}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{data && !data.medicalShown && data.groups.length > 0 && <p className="text-xs text-gray-500 mt-3">Medical details are only shown to supervisors. Ask one if you need them.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock } from "lucide-react";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
|
||||
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
|
||||
] as const;
|
||||
|
||||
export default function StaffDashboardPage() {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
|
||||
|
||||
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
|
||||
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
|
||||
type Person = { id: string; name: string; gender: string | null; dateOfBirth: string | null; registrationId: string; bookedBy: string };
|
||||
type Board = { event: { id: string; title: string; startDate: string; collectGender: boolean; requiresParentalConsent: boolean }; sets: AllocSet[]; attendees: Person[]; leaders: { id: string; name: string }[]; unnamedPlaces: number };
|
||||
|
||||
const UNASSIGNED = "unassigned";
|
||||
const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!));
|
||||
const genderLabel = (g: string | null) => (g === "male" ? "Male" : g === "female" ? "Female" : "");
|
||||
const ageOn = (dob: string | null, on: string) => {
|
||||
if (!dob) return null;
|
||||
const d = new Date(dob), e = new Date(on);
|
||||
let age = e.getFullYear() - d.getFullYear();
|
||||
if (e.getMonth() < d.getMonth() || (e.getMonth() === d.getMonth() && e.getDate() < d.getDate())) age -= 1;
|
||||
return age;
|
||||
};
|
||||
|
||||
function PersonChip({ person, eventStart }: { person: Person; eventStart: string }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: person.id });
|
||||
const age = ageOn(person.dateOfBirth, eventStart);
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
style={transform ? { transform: `translate(${transform.x}px, ${transform.y}px)`, zIndex: 50 } : undefined}
|
||||
className={`px-2 py-1.5 rounded-lg border bg-white text-sm cursor-grab active:cursor-grabbing select-none touch-none ${isDragging ? "shadow-lg ring-2 ring-brand-300" : "shadow-sm"}`}
|
||||
title={person.bookedBy ? `Booked by ${person.bookedBy}` : undefined}
|
||||
>
|
||||
<span className="font-medium">{person.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{[genderLabel(person.gender), age != null ? `${age}` : "", person.bookedBy ? `· ${person.bookedBy}` : ""].filter(Boolean).map(s => ` ${s}`).join("")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Column({ id, title, subtitle, full, children, actions }: { id: string; title: string; subtitle?: string; full?: boolean; children: React.ReactNode; actions?: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
return (
|
||||
<div ref={setNodeRef} className={`rounded-xl border p-3 min-h-[8rem] transition-colors ${isOver ? "bg-brand-50 border-brand-300" : full ? "bg-gray-50" : "bg-white"}`}>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{title}</div>
|
||||
{subtitle && <div className={`text-xs ${full ? "text-amber-700" : "text-gray-500"}`}>{subtitle}</div>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AllocationsPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [setId, setSetId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [groupForm, setGroupForm] = useState<{ id?: string; name: string; capacity: string; gender: string; leaderUserId: string } | null>(null);
|
||||
const [newSetName, setNewSetName] = useState("");
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
setEvents(upcoming);
|
||||
if (upcoming.length) setEventId(id => id || upcoming[0].id);
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
}, [token, canView, setError]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
setBoard(b);
|
||||
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load allocations");
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const current = board?.sets.find(s => s.id === setId) || null;
|
||||
const groupOf = useMemo(() => new Map((current?.assignments || []).map(a => [a.attendeeId, a.groupId])), [current]);
|
||||
const membersOf = (groupId: string) => (board?.attendees || []).filter(p => groupOf.get(p.id) === groupId);
|
||||
const unassigned = (board?.attendees || []).filter(p => !groupOf.has(p.id));
|
||||
|
||||
const call = async <T,>(path: string, method: string, body?: unknown): Promise<T> =>
|
||||
apiFetch<T>(path, { method, authToken: token!, ...(body !== undefined ? { body } : {}) });
|
||||
|
||||
const onDragEnd = async (e: DragEndEvent) => {
|
||||
if (!current || !e.over) return;
|
||||
const attendeeId = String(e.active.id);
|
||||
const target = String(e.over.id);
|
||||
const groupId = target === UNASSIGNED ? null : target;
|
||||
if ((groupOf.get(attendeeId) || null) === groupId) return;
|
||||
// Optimistic move; reload on failure.
|
||||
setBoard(b => b && ({
|
||||
...b,
|
||||
sets: b.sets.map(s => s.id !== current.id ? s : {
|
||||
...s,
|
||||
assignments: [...s.assignments.filter(a => a.attendeeId !== attendeeId), ...(groupId ? [{ attendeeId, groupId }] : [])],
|
||||
}),
|
||||
}));
|
||||
try {
|
||||
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't move them");
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const run = async (fn: () => Promise<string | void>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const msg = await fn();
|
||||
if (msg) setInfo(msg);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addSet = () => run(async () => {
|
||||
const s = await call<{ id: string }>("/api/allocations/sets", "POST", { eventId, name: newSetName || "Rooms" });
|
||||
setNewSetName("");
|
||||
setSetId(s.id);
|
||||
});
|
||||
const saveGroup = () => groupForm && current && run(async () => {
|
||||
const body = { name: groupForm.name, capacity: groupForm.capacity || null, gender: groupForm.gender || null, leaderUserId: groupForm.leaderUserId || null };
|
||||
if (groupForm.id) await call(`/api/allocations/groups/${groupForm.id}`, "PUT", body);
|
||||
else await call(`/api/allocations/sets/${current.id}/groups`, "POST", body);
|
||||
setGroupForm(null);
|
||||
});
|
||||
|
||||
const printLists = async (master: boolean, medical: boolean) => {
|
||||
if (!current || !board) return;
|
||||
try {
|
||||
const data = await apiFetch<any>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const row = (m: any) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
+ `<td>${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}</td><td>${esc(m.parentName || m.bookedBy || "")}</td>`
|
||||
+ (medical ? `<td>${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}</td><td>${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}</td>` : "") + `</tr>`;
|
||||
const head = `<tr><th>Name</th><th>Gender</th><th>Age</th><th>Phone</th><th>Parent / booked by</th>${medical ? "<th>Medical</th><th>Emergency contact</th>" : ""}</tr>`;
|
||||
let body = "";
|
||||
if (master) {
|
||||
const all = data.groups.flatMap((g: any) => g.members.map((m: any) => ({ ...m, group: g.name }))).sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m: any) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
} else {
|
||||
body = data.groups.map((g: any) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
+ `<p>${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people</p>`
|
||||
+ `<table>${head}${g.members.map(row).join("")}</table></section>`).join("");
|
||||
}
|
||||
w.document.write(`<!doctype html><html><head><title>${esc(data.set.name)}</title><style>
|
||||
body{font-family:Arial,sans-serif;font-size:11pt;margin:12mm} h1{font-size:15pt;margin:0 0 2mm} p{margin:0 0 3mm;color:#555}
|
||||
table{border-collapse:collapse;width:100%;margin-bottom:6mm} th,td{border:1px solid #bbb;padding:3px 6px;text-align:left;vertical-align:top} th{background:#f1f1f1}
|
||||
section{page-break-after:always} section:last-child{page-break-after:auto}
|
||||
</style></head><body>${body}<script>window.onload=function(){window.print()}</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't load the lists");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<BedDouble className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Rooms & groups</h1>
|
||||
<p className="text-sm text-gray-500">Put attendees into rooms, small groups, teams or buses</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage allocations.</div>}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => { setEventId(e.target.value); setSetId(""); }}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{board && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{board.sets.map(s => (
|
||||
<button key={s.id} onClick={() => setSetId(s.id)} className={`px-3 py-1.5 text-sm rounded-lg border ${s.id === setId ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>{s.name}</button>
|
||||
))}
|
||||
<input className="border rounded-lg px-2 py-1.5 text-sm w-36" placeholder="New set, e.g. Buses" value={newSetName} onChange={e => setNewSetName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") addSet(); }} />
|
||||
<button onClick={addSet} disabled={busy} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Plus className="w-4 h-4" /> Add set</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{board && board.attendees.length === 0 && (
|
||||
<div className="p-3 border rounded bg-amber-50 text-amber-900 text-sm">
|
||||
No named attendees yet. Allocation works per person, so it needs names — turn on "Named attendees" for the event{board.event.collectGender ? "" : " (and \"Ask each person's gender\" if rooms are split by gender)"}.
|
||||
</div>
|
||||
)}
|
||||
{board && board.unnamedPlaces > 0 && (
|
||||
<p className="text-xs text-gray-500">{board.unnamedPlaces} place(s) have no name yet, so they can't be placed until the person booking adds names.</p>
|
||||
)}
|
||||
|
||||
{current && board && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 border rounded-xl p-3 bg-white">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={current.showOnTicket} onChange={e => run(async () => { await call(`/api/allocations/sets/${current.id}`, "PUT", { showOnTicket: e.target.checked }); })} />
|
||||
Show {current.name.toLowerCase()} on tickets and in My Events
|
||||
</label>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "together" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" title="Fill groups evenly, keeping people on the same booking (e.g. siblings) together where possible"><Wand2 className="w-4 h-4" /> Auto-allocate (keep bookings together)</button>
|
||||
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "split" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50" title="Fill groups evenly, spreading people on the same booking across groups">Auto-allocate (split bookings)</button>
|
||||
<button onClick={() => printLists(false, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Group lists</button>
|
||||
<button onClick={() => printLists(true, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Master list</button>
|
||||
{board.event.requiresParentalConsent && (
|
||||
<button onClick={() => printLists(false, true)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50" title="Includes medical details from consent forms. Viewing is recorded in the audit log."><Printer className="w-4 h-4" /> Lists with medical info</button>
|
||||
)}
|
||||
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/assignments`, "DELETE")).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50"><Eraser className="w-4 h-4" /> Clear</button>
|
||||
<button disabled={busy} onClick={() => run(async () => { await call(`/api/allocations/sets/${current.id}`, "DELETE"); setSetId(""); return `Deleted ${current.name}.`; })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border text-red-700 hover:bg-red-50 disabled:opacity-50"><Trash2 className="w-4 h-4" /> Delete set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupForm ? (
|
||||
<div className="border rounded-xl p-3 bg-white flex flex-wrap items-end gap-2">
|
||||
<div><label className="block text-xs text-gray-600 mb-1">Name</label><input className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.name} onChange={e => setGroupForm({ ...groupForm, name: e.target.value })} placeholder="Cabin 1" autoFocus /></div>
|
||||
<div><label className="block text-xs text-gray-600 mb-1">Capacity</label><input type="number" min="1" className="border rounded-lg px-2 py-1.5 text-sm w-24" value={groupForm.capacity} onChange={e => setGroupForm({ ...groupForm, capacity: e.target.value })} placeholder="No limit" /></div>
|
||||
<div><label className="block text-xs text-gray-600 mb-1">Gender</label>
|
||||
<select className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.gender} onChange={e => setGroupForm({ ...groupForm, gender: e.target.value })}>
|
||||
<option value="">Anyone</option><option value="male">Males only</option><option value="female">Females only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="block text-xs text-gray-600 mb-1">Leader</label>
|
||||
<select className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.leaderUserId} onChange={e => setGroupForm({ ...groupForm, leaderUserId: e.target.value })}>
|
||||
<option value="">None</option>
|
||||
{board.leaders.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button disabled={busy || !groupForm.name.trim()} onClick={saveGroup} className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white disabled:opacity-50">{groupForm.id ? "Save" : "Add group"}</button>
|
||||
<button onClick={() => setGroupForm(null)} className="px-3 py-1.5 text-sm rounded-lg bg-gray-100">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setGroupForm({ name: "", capacity: "", gender: "", leaderUserId: "" })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Plus className="w-4 h-4" /> Add a group to {current.name}</button>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[minmax(14rem,1fr)_3fr] gap-4 items-start">
|
||||
<div className="md:sticky md:top-4">
|
||||
<Column id={UNASSIGNED} title="Not placed yet" subtitle={`${unassigned.length} people · drag them into a group`}>
|
||||
{unassigned.map(p => <PersonChip key={p.id} person={p} eventStart={board.event.startDate} />)}
|
||||
</Column>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{current.groups.length === 0 && <p className="text-sm text-gray-500">Add groups (rooms, cabins, buses…) to start placing people.</p>}
|
||||
{current.groups.map(g => {
|
||||
const members = membersOf(g.id);
|
||||
const full = g.capacity != null && members.length >= g.capacity;
|
||||
const subtitle = [`${members.length}${g.capacity ? ` / ${g.capacity}` : ""}${full ? " · full" : ""}`, g.gender ? (g.gender === "male" ? "males" : "females") : "", g.leader ? `leader ${g.leader.name}` : ""].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<Column key={g.id} id={g.id} title={g.name} subtitle={subtitle} full={full} actions={
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button aria-label={`Edit ${g.name}`} onClick={() => setGroupForm({ id: g.id, name: g.name, capacity: g.capacity ? String(g.capacity) : "", gender: g.gender || "", leaderUserId: g.leaderUserId || "" })} className="p-1 rounded hover:bg-gray-100"><Pencil className="w-3.5 h-3.5" /></button>
|
||||
<button aria-label={`Delete ${g.name}`} onClick={() => run(async () => { await call(`/api/allocations/groups/${g.id}`, "DELETE"); return `${g.name} deleted; its people are back in "Not placed yet".`; })} className="p-1 rounded hover:bg-red-50 text-red-700"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
}>
|
||||
{members.map(p => <PersonChip key={p.id} person={p} eventStart={board.event.startDate} />)}
|
||||
</Column>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</>
|
||||
)}
|
||||
{board && board.sets.length === 0 && board.attendees.length > 0 && (
|
||||
<p className="text-sm text-gray-600">Start by adding a set, e.g. "Rooms", "Small groups" or "Buses". An event can have several.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export default function AtTheDoorPage() {
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||
const [doorCollectGender, setDoorCollectGender] = useState(false);
|
||||
const [doorNaming, setDoorNaming] = useState(false);
|
||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||
@@ -164,6 +165,7 @@ export default function AtTheDoorPage() {
|
||||
setEventOptions(ev.options || ev.eventOptions || []);
|
||||
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
||||
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
||||
setDoorCollectGender(!!ev.collectGender);
|
||||
} catch {
|
||||
setEventOptions([]);
|
||||
}
|
||||
@@ -514,6 +516,7 @@ export default function AtTheDoorPage() {
|
||||
entries: doorEntries, onEntriesChange: setDoorEntries,
|
||||
delivery: doorDelivery, onDeliveryChange: setDoorDelivery,
|
||||
consentEventStart: doorYouthStart || undefined,
|
||||
collectGender: doorCollectGender,
|
||||
} : null}
|
||||
/>
|
||||
<DonationModal
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Mail, Users, Zap, Clock } from "lucide-react";
|
||||
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
|
||||
@@ -336,6 +337,7 @@ function EmailAttendeesPageInner() {
|
||||
const [subjectDirty, setSubjectDirty] = useState(false);
|
||||
const [bodyDirty, setBodyDirty] = useState(false);
|
||||
const [status, setStatus] = useState<'any'|'paid'|'unpaid'|'partial_paid'|'cancelled'>("any");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
// Attendees list and selection
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
|
||||
@@ -420,7 +422,7 @@ function EmailAttendeesPageInner() {
|
||||
}
|
||||
const payload: any = {
|
||||
subject: subject || "(no subject)",
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
dryRun: true,
|
||||
template: templateKey,
|
||||
};
|
||||
@@ -460,7 +462,7 @@ function EmailAttendeesPageInner() {
|
||||
setSending(true);
|
||||
const payload: any = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
};
|
||||
if (templateKey !== 'tickets') {
|
||||
@@ -607,6 +609,7 @@ function EmailAttendeesPageInner() {
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<AllocationGroupFilter token={token} eventId={eventId} value={groupId} onChange={setGroupId} />
|
||||
</div>
|
||||
|
||||
{templateKey !== 'tickets' ? (
|
||||
@@ -659,7 +662,7 @@ function EmailAttendeesPageInner() {
|
||||
const whenIso = new Date(scheduledAtLocal).toISOString();
|
||||
const payload = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
scheduledAt: whenIso,
|
||||
} as any;
|
||||
@@ -986,6 +989,16 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
|
||||
const [enablePre, setEnablePre] = React.useState(true);
|
||||
const [enableFinal, setEnableFinal] = React.useState(true);
|
||||
const [enableThanks, setEnableThanks] = React.useState(true);
|
||||
// Sponsors of "sponsor a spot" events get their own thank-you.
|
||||
const [enableSponsors, setEnableSponsors] = React.useState(true);
|
||||
const [sponsorSubject, setSponsorSubject] = React.useState('Thank you for sponsoring a spot at {{event.title}}');
|
||||
const [sponsorBody, setSponsorBody] = React.useState(`Hi {{name}}
|
||||
|
||||
Thank you for sponsoring a spot at {{event.title}}. Because of you, someone was able to be there who otherwise couldn't.
|
||||
|
||||
[add a highlight or two]
|
||||
|
||||
With gratitude`);
|
||||
const [enablePromo, setEnablePromo] = React.useState(false);
|
||||
|
||||
// Subjects/Bodies
|
||||
@@ -1061,6 +1074,11 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
|
||||
if (thanksBody.trim().startsWith('<')) payload.html = thanksBody; else payload.text = thanksBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (currentEvent?.allowSponsorships && enableSponsors && sponsorSubject.trim() && sponsorBody.trim() && thanksWhen) {
|
||||
const payload: any = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
|
||||
if (sponsorBody.trim().startsWith('<')) payload.html = sponsorBody; else payload.text = sponsorBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (enablePromo && promoSubject.trim() && promoBody.trim() && promoWhen) {
|
||||
const payload: any = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
|
||||
if (promoBody.trim().startsWith('<')) payload.html = promoBody; else payload.text = promoBody;
|
||||
@@ -1157,6 +1175,20 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
|
||||
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={5} value={thanksBody} onChange={e => setThanksBody(e.target.value)} />
|
||||
</fieldset>
|
||||
|
||||
{currentEvent?.allowSponsorships && (
|
||||
<fieldset className="border rounded p-3">
|
||||
<legend className="text-sm font-medium">Thank Your Sponsors</legend>
|
||||
<label className="inline-flex items-center gap-2 text-sm mb-2">
|
||||
<input type="checkbox" checked={enableSponsors} onChange={e => setEnableSponsors(e.target.checked)} /> Enable
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Goes to everyone who sponsored a spot at this event, at the same time as the thank-you above.</p>
|
||||
<label className="block text-xs text-gray-600 mb-1">Subject</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={sponsorSubject} onChange={e => setSponsorSubject(e.target.value)} />
|
||||
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
|
||||
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={5} value={sponsorBody} onChange={e => setSponsorBody(e.target.value)} />
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<fieldset className="border rounded p-3">
|
||||
<legend className="text-sm font-medium">Next Event Promo</legend>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
|
||||
@@ -8,9 +8,12 @@ import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const cleanChoices = (opts: unknown): string[] => (Array.isArray(opts) ? opts.map(o => String(o).trim()).filter(Boolean) : []);
|
||||
|
||||
function toLocalDT(input: string | Date | null | undefined): string {
|
||||
if (!input) return "";
|
||||
const d = new Date(input);
|
||||
@@ -40,7 +43,7 @@ function DTInput({ label, value, onChange, hint, required }: { label: string; va
|
||||
|
||||
// ─── types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph";
|
||||
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph" | "rating" | "multiple_choice";
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: any }[] };
|
||||
|
||||
type Tier = { id?: string; deadline: string; price: string; stockLimit: string; order?: number };
|
||||
@@ -97,7 +100,13 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
|
||||
);
|
||||
}
|
||||
|
||||
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
|
||||
function FormBuilder({ value, onChange, title = "Registration Form Fields", gateLabel = "Required before tickets generate" }: {
|
||||
value: EventFormDef;
|
||||
onChange: (v: EventFormDef) => void;
|
||||
title?: string;
|
||||
/** Label of the "form must be filled in" switch; null hides it (feedback forms). */
|
||||
gateLabel?: string | null;
|
||||
}) {
|
||||
const fields = value.fields || [];
|
||||
const add = (type: FormFieldType) => onChange({ ...value, fields: [...fields, { type, label: "", isRequired: false, helpText: "" }] });
|
||||
const upd = (idx: number, patch: any) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
|
||||
@@ -107,15 +116,17 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
if (t < 0 || t >= c.length) return;
|
||||
[c[idx], c[t]] = [c[t], c[idx]]; onChange({ ...value, fields: c });
|
||||
};
|
||||
const typeLabel = (t: FormFieldType) => ({ text: "Text", numeric: "Number", date: "Date", yes_no: "Yes/No", statement: "Statement", paragraph: "Heading" }[t] || t);
|
||||
const typeLabel = (t: FormFieldType) => ({ text: "Text", numeric: "Number", date: "Date", yes_no: "Yes/No", statement: "Statement", paragraph: "Heading", rating: "Rating", multiple_choice: "Multiple choice" }[t] || t);
|
||||
return (
|
||||
<div className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm font-semibold">Registration Form Fields</div>
|
||||
<label className="text-xs flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={!!value.isRequired} onChange={e => onChange({ ...value, isRequired: e.target.checked })} />
|
||||
Required before tickets generate
|
||||
</label>
|
||||
<div className="text-sm font-semibold">{title}</div>
|
||||
{gateLabel && (
|
||||
<label className="text-xs flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={!!value.isRequired} onChange={e => onChange({ ...value, isRequired: e.target.checked })} />
|
||||
{gateLabel}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{fields.length === 0
|
||||
? <p className="text-xs text-gray-500 italic mb-3">No fields yet.</p>
|
||||
@@ -134,7 +145,8 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
<div className="flex flex-wrap gap-2 mb-1.5">
|
||||
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value })}>
|
||||
<option value="text">Text</option><option value="numeric">Number</option><option value="date">Date</option>
|
||||
<option value="yes_no">Yes/No</option><option value="statement">Statement</option><option value="paragraph">Heading+para</option>
|
||||
<option value="yes_no">Yes/No</option><option value="rating">Rating (1–5 stars)</option><option value="multiple_choice">Multiple choice</option>
|
||||
<option value="statement">Statement</option><option value="paragraph">Heading+para</option>
|
||||
</select>
|
||||
{f.type !== "statement" && f.type !== "paragraph" && (
|
||||
<label className="text-xs flex items-center gap-1 cursor-pointer">
|
||||
@@ -143,6 +155,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
)}
|
||||
</div>
|
||||
<input className="border rounded px-2 py-1 text-sm w-full mb-1" placeholder={f.type === "paragraph" ? "Heading text" : "Label"} value={f.label} onChange={e => upd(idx, { label: e.target.value })} />
|
||||
{f.type === "multiple_choice" && <ChoicesEditor options={f.options} onChange={opts => upd(idx, { options: opts })} />}
|
||||
{f.type === "paragraph"
|
||||
? <textarea className="border rounded px-2 py-1 text-xs w-full" rows={2} placeholder="Body text" value={f.helpText || ""} onChange={e => upd(idx, { helpText: e.target.value })} />
|
||||
: f.type !== "statement"
|
||||
@@ -154,6 +167,8 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => add("text")}>+ Question</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("rating")}>+ Rating</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("multiple_choice")}>+ Multiple choice</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("statement")}>+ Statement</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("paragraph")}>+ Heading</button>
|
||||
</div>
|
||||
@@ -759,8 +774,8 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
|
||||
|
||||
// ─── steps ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments"] as const;
|
||||
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback"] as const;
|
||||
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
const PRICING_SUBSTEPS = ["Options", "Variants", "Early Birds"] as const;
|
||||
type PricingSubstep = 0 | 1 | 2;
|
||||
|
||||
@@ -773,7 +788,8 @@ interface EventDraft {
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
allowTicketTransfer: boolean;
|
||||
allowTicketTransfer: boolean; collectGender: boolean;
|
||||
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -785,7 +801,8 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
requiresParentalConsent: false, consentText: "",
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
|
||||
allowTicketTransfer: true,
|
||||
allowTicketTransfer: true, collectGender: false,
|
||||
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -825,6 +842,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
|
||||
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
|
||||
allowTicketTransfer: ev.allowTicketTransfer !== false,
|
||||
collectGender: !!ev.collectGender,
|
||||
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
@@ -875,10 +894,57 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
|
||||
apiFetch<any>(`/api/events/${ev.id}`).then(full => {
|
||||
const f = full?.form;
|
||||
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "" })) });
|
||||
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
|
||||
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
|
||||
}, [mode, ev?.id]);
|
||||
|
||||
// ── post-event feedback ──
|
||||
const [fbEnabled, setFbEnabled] = useState(false);
|
||||
const [fbDef, setFbDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
|
||||
const [fbSettings, setFbSettings] = useState({ sendAt: "", audience: "checked_in", anonymous: false, intro: "" });
|
||||
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !ev?.id || !token) return;
|
||||
apiFetch<any>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
|
||||
if (!f) return;
|
||||
setFbEnabled(true);
|
||||
setFbDef({ isRequired: false, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
|
||||
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
|
||||
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
|
||||
}).catch(() => {});
|
||||
}, [mode, ev?.id, token]);
|
||||
const enableFeedback = (on: boolean) => {
|
||||
setFbEnabled(on);
|
||||
if (on && fbDef.fields.length === 0) {
|
||||
setFbDef({ isRequired: false, fields: [
|
||||
{ type: "rating", label: "Overall, how would you rate the event?", isRequired: true, helpText: "" },
|
||||
{ type: "rating", label: "How would you rate the organisation?", isRequired: false, helpText: "" },
|
||||
{ type: "text", label: "What did you enjoy most?", isRequired: false, helpText: "" },
|
||||
{ type: "text", label: "What could we do better?", isRequired: false, helpText: "" },
|
||||
] });
|
||||
}
|
||||
if (on && !fbSettings.sendAt && draft.endDate) {
|
||||
const next = new Date(draft.endDate);
|
||||
next.setDate(next.getDate() + 1);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
setFbSettings(s => ({ ...s, sendAt: toLocalDT(next) }));
|
||||
}
|
||||
};
|
||||
const saveFeedback = async (eventId: string) => {
|
||||
if (fbEnabled) {
|
||||
await apiFetch(`/api/feedback/event/${eventId}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: {
|
||||
sendAt: fbSettings.sendAt ? new Date(fbSettings.sendAt).toISOString() : null,
|
||||
audience: fbSettings.audience, anonymous: fbSettings.anonymous, intro: fbSettings.intro || null,
|
||||
fields: fbDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })),
|
||||
},
|
||||
});
|
||||
} else if (fbMeta) {
|
||||
await apiFetch(`/api/feedback/event/${eventId}`, { method: "DELETE", authToken: token || undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const upd = (patch: Partial<EventDraft>) => setDraft(d => ({ ...d, ...patch }));
|
||||
|
||||
// ── auto-fill first option's price from the event base price (create mode only, until the
|
||||
@@ -917,7 +983,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (mode === "edit") {
|
||||
await apiFetch(`/api/events/${eventId}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } }
|
||||
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) } }
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -968,7 +1034,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// Handle form save (edit only)
|
||||
await apiFetch(`/api/events/${eventId}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } }
|
||||
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) } }
|
||||
});
|
||||
} else {
|
||||
// New option added during edit
|
||||
@@ -1106,6 +1172,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
consentCollectPhoto: draft.consentCollectPhoto,
|
||||
waitlistEnabled: draft.waitlistEnabled,
|
||||
allowTicketTransfer: draft.allowTicketTransfer,
|
||||
collectGender: draft.collectGender,
|
||||
allowSponsorships: draft.allowSponsorships,
|
||||
sponsorshipGoalSpots: draft.allowSponsorships && draft.sponsorshipGoalSpots ? parseInt(draft.sponsorshipGoalSpots, 10) || null : null,
|
||||
showSponsorshipProgress: draft.showSponsorshipProgress,
|
||||
waitlistOfferHours: parseInt(draft.waitlistOfferHours, 10) || 24,
|
||||
waitlistPartialOffers: draft.waitlistPartialOffers,
|
||||
contactName: draft.contactName || undefined,
|
||||
@@ -1119,13 +1189,15 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
|
||||
await saveOptions(ev.id);
|
||||
// Form is saved inside saveOptions for edit mode; avoid double call
|
||||
await saveFeedback(ev.id);
|
||||
} else {
|
||||
if (formDef.isRequired || formDef.fields.length > 0) {
|
||||
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) };
|
||||
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
|
||||
}
|
||||
const created: any = await apiFetch("/api/events", { method: "POST", authToken: token || undefined, body });
|
||||
if (created?.id) {
|
||||
await saveOptions(created.id);
|
||||
await saveFeedback(created.id);
|
||||
// Fetch event to get final option IDs for sections
|
||||
const fullEvent: any = await apiFetch(`/api/events/${created.id}`).catch(() => null);
|
||||
const createdOptions = fullEvent?.eventOptions || [];
|
||||
@@ -1349,6 +1421,46 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 7 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-gray-500">Ask people what they thought, automatically after the event. Responses appear in Reports → Feedback summary.</p>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={fbEnabled} onChange={e => enableFeedback(e.target.checked)} />
|
||||
<span><strong>Send a feedback form after the event</strong></span>
|
||||
</label>
|
||||
{fbEnabled && (
|
||||
<>
|
||||
{fbMeta?.sentAt && (
|
||||
<div className="text-xs rounded bg-green-50 text-green-800 p-2">Sent {new Date(fbMeta.sentAt).toLocaleString()} to {fbMeta.invited} {fbMeta.invited === 1 ? "person" : "people"} · {fbMeta.responses} response(s).{fbMeta.responses > 0 ? " The questions can't be changed now that people have answered." : ""}</div>
|
||||
)}
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Send at</label>
|
||||
<input type="datetime-local" className="border rounded px-2 py-1.5 text-sm w-full" value={fbSettings.sendAt} disabled={!!fbMeta?.sentAt} onChange={e => setFbSettings(s => ({ ...s, sendAt: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Send to</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fbSettings.audience} onChange={e => setFbSettings(s => ({ ...s, audience: e.target.value }))}>
|
||||
<option value="checked_in">Everyone who checked in</option>
|
||||
<option value="all">Everyone registered</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={fbSettings.anonymous} onChange={e => setFbSettings(s => ({ ...s, anonymous: e.target.checked }))} />
|
||||
<span>Anonymous — answers aren't linked to people's names</span>
|
||||
</label>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Intro (optional)</label>
|
||||
<textarea className="border rounded px-2 py-1.5 text-sm w-full" rows={2} value={fbSettings.intro} onChange={e => setFbSettings(s => ({ ...s, intro: e.target.value }))} placeholder="Thanks for coming! Tell us how it went." />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Anyone who hasn't answered gets one reminder 3 days later.</p>
|
||||
<FormBuilder value={fbDef} onChange={setFbDef} title="Feedback questions" gateLabel={null} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Visibility ── */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-3">
|
||||
@@ -1366,6 +1478,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.allowNamedAttendees} onChange={e => upd({ allowNamedAttendees: e.target.checked })} />
|
||||
<span><strong>Named attendees (group tickets)</strong> — someone registering several people can give each a name, so each gets their own ticket and attendee form</span>
|
||||
</label>
|
||||
{(draft.allowNamedAttendees || draft.requiresParentalConsent) && (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer ml-6">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.collectGender} onChange={e => upd({ collectGender: e.target.checked })} />
|
||||
<span><strong>Ask each person's gender</strong> — needed to split rooms or cabins by gender (Rooms & groups)</span>
|
||||
</label>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.isActive} onChange={e => upd({ isActive: e.target.checked })} />
|
||||
@@ -1395,6 +1513,27 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Sponsor a spot</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.allowSponsorships} onChange={e => upd({ allowSponsorships: e.target.checked })} />
|
||||
<span><strong>Allow sponsorships</strong> — a "Sponsor a spot" button on the event page lets people pay for spots (or any amount) for those who can't afford to come. The money goes into a sponsorship pool you assign under Payments → Donations</span>
|
||||
</label>
|
||||
{draft.allowSponsorships && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Goal
|
||||
<input type="number" min="1" className="border rounded px-2 py-1 text-sm w-20" value={draft.sponsorshipGoalSpots} onChange={e => upd({ sponsorshipGoalSpots: e.target.value })} placeholder="—" />
|
||||
spots (optional)
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.showSponsorshipProgress} onChange={e => upd({ showSponsorshipProgress: e.target.checked })} />
|
||||
<span>Show progress on the event page ("12 of 20 spots sponsored")</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ticket transfer</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -7,7 +8,7 @@ import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
|
||||
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: any }[] };
|
||||
|
||||
@@ -36,7 +37,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
};
|
||||
|
||||
const typeLabel = (type: FormFieldType) => {
|
||||
const labels: Record<FormFieldType, string> = { text: 'Text', numeric: 'Number', date: 'Date', yes_no: 'Yes/No', statement: 'Statement', paragraph: 'Heading' };
|
||||
const labels: Record<FormFieldType, string> = { text: 'Text', numeric: 'Number', date: 'Date', yes_no: 'Yes/No', statement: 'Statement', paragraph: 'Heading', rating: 'Rating', multiple_choice: 'Multiple choice' };
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
@@ -73,6 +74,8 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
<option value="numeric">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="yes_no">Yes / No</option>
|
||||
<option value="rating">Rating (1–5 stars)</option>
|
||||
<option value="multiple_choice">Multiple choice</option>
|
||||
<option value="statement">Statement (display text)</option>
|
||||
<option value="paragraph">Heading + paragraph</option>
|
||||
</select>
|
||||
@@ -89,6 +92,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
value={f.label}
|
||||
onChange={e => updateField(idx, { label: e.target.value })}
|
||||
/>
|
||||
{f.type === 'multiple_choice' && <ChoicesEditor options={f.options} onChange={opts => updateField(idx, { options: opts })} />}
|
||||
{f.type === 'paragraph' ? (
|
||||
<textarea
|
||||
className="border rounded px-2 py-1 text-xs w-full"
|
||||
@@ -288,7 +292,7 @@ export default function FormsBrowserPage() {
|
||||
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
const f = evFull?.form;
|
||||
if (f && Array.isArray(f.fields)) {
|
||||
setFormDef({ isRequired: !!f.isRequired, fields: f.fields.map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '' })) });
|
||||
setFormDef({ isRequired: !!f.isRequired, fields: f.fields.map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) });
|
||||
} else {
|
||||
setFormDef({ isRequired: false, fields: [] });
|
||||
}
|
||||
@@ -310,7 +314,7 @@ export default function FormsBrowserPage() {
|
||||
if (!token || !selectedEventId) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null }));
|
||||
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o: any) => String(o).trim()).filter(Boolean) : undefined }));
|
||||
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
|
||||
setInfo('Form saved');
|
||||
} catch (e:any) {
|
||||
@@ -362,7 +366,7 @@ export default function FormsBrowserPage() {
|
||||
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.event.id)}`);
|
||||
const f = evFull?.form;
|
||||
if (f && Array.isArray(f.fields)) {
|
||||
setFillForm({ isRequired: !!f.isRequired, fields: f.fields.map((x:any)=>({ id:x.id, type:x.type, label:x.label, isRequired:!!x.isRequired, order:x.order, helpText:x.helpText||'' })) });
|
||||
setFillForm({ isRequired: !!f.isRequired, fields: f.fields.map((x:any)=>({ id:x.id, type:x.type, label:x.label, isRequired:!!x.isRequired, order:x.order, helpText:x.helpText||'', options: x.options || undefined })) });
|
||||
} else {
|
||||
setFillForm({ isRequired: false, fields: [] });
|
||||
}
|
||||
@@ -710,6 +714,9 @@ export default function FormsBrowserPage() {
|
||||
{f.type === 'date' && (
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
|
||||
)}
|
||||
{isExtraFieldType(f.type) && (
|
||||
<ExtraFieldInput field={f} idPrefix={`r${idx}`} value={resp.answers[f.id!]||''} onChange={v => setAnswer(idx, f.id!, v)} />
|
||||
)}
|
||||
{f.type === 'yes_no' && (
|
||||
<select className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -7,7 +8,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
export default function ManualRegistrationPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
@@ -178,7 +179,9 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||||
{f.type === 'yes_no' ? (
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => update(idx, f.id, v)} />
|
||||
) : f.type === 'yes_no' ? (
|
||||
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
|
||||
<option value="">Select</option>
|
||||
<option value="yes">Yes</option>
|
||||
|
||||
@@ -624,6 +624,7 @@ export default function ManualRegistrationPage() {
|
||||
onDeliveryChange={setDelivery}
|
||||
payerLabel="the person registering"
|
||||
consentEventStart={selectedIsYouth ? selectedEv.startDate : undefined}
|
||||
collectGender={!!selectedEv?.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -24,6 +24,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
@@ -38,6 +39,7 @@ const QUICK_ACTIONS = [
|
||||
|
||||
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
||||
type Overview = {
|
||||
funnel?: { views: number; paid: number; conversion: number | null } | null;
|
||||
activeEvents: number;
|
||||
registrations: OverviewMetric;
|
||||
ticketsSold: OverviewMetric;
|
||||
@@ -117,12 +119,15 @@ export default function SupervisorDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatCardRow>
|
||||
<StatCardRow cols={overview?.funnel ? 6 : 5}>
|
||||
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
|
||||
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
|
||||
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
|
||||
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href={`${REPORTS_URL}?report=regStatus&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
|
||||
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
|
||||
{overview?.funnel && (
|
||||
<StatCard icon={Filter} label="Conversion (past month)" value={overview.funnel.conversion != null ? `${overview.funnel.conversion}%` : "—"} tone="violet" href={`${REPORTS_URL}?report=funnel&range=trailing_month`} linkLabel={`${overview.funnel.paid} of ${overview.funnel.views} visits`} />
|
||||
)}
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
|
||||
@@ -832,7 +832,7 @@ function PaymentsContent() {
|
||||
return (
|
||||
<li key={p.id} className={`border rounded p-2 ${isRefund ? 'bg-red-50' : ''}`}>
|
||||
<div className="flex justify-between">
|
||||
<div className={`font-medium ${isRefund ? 'text-red-700' : ''}`}>{isRefund ? '-' : ''}R {Math.abs(amt).toFixed(2)} {p.isDonation ? <span className="text-xs text-emerald-700">(donation)</span> : null} {isRefund ? <span className="text-xs text-red-700">(refund)</span> : null}</div>
|
||||
<div className={`font-medium ${isRefund ? 'text-red-700' : ''}`}>{isRefund ? '-' : ''}R {Math.abs(amt).toFixed(2)} {p.isDonation ? <span className="text-xs text-emerald-700">({p.isSponsorship ? 'sponsorship' : 'donation'})</span> : null} {isRefund ? <span className="text-xs text-red-700">(refund)</span> : null}</div>
|
||||
<div className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Method: {p.method || 'payment'}</div>
|
||||
@@ -1171,6 +1171,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
const [paymentId, setPaymentId] = useState<string>("");
|
||||
const [amountStr, setAmountStr] = useState<string>("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [sponsorshipsOnly, setSponsorshipsOnly] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { setRegistrationId(""); setPaymentId(""); }, [userId]);
|
||||
@@ -1219,10 +1220,11 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
return payments.filter((p: any) => {
|
||||
if (!p.isDonation || p.registrationId) return false;
|
||||
if (String(p.eventId) !== String(eventId)) return false;
|
||||
if (sponsorshipsOnly && !p.isSponsorship) return false;
|
||||
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
|
||||
return remaining > 0.000001;
|
||||
});
|
||||
}, [payments, selectedRegistration, legsById]);
|
||||
}, [payments, selectedRegistration, legsById, sponsorshipsOnly]);
|
||||
|
||||
const selectedDonation = useMemo(
|
||||
() => payments.find((p: any) => String(p.id) === String(paymentId)),
|
||||
@@ -1306,12 +1308,19 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
<option value="">Select donation…</option>
|
||||
{donationsForEvent.map((p: any) => {
|
||||
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
|
||||
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
|
||||
const donor = p.isSponsorship ? `Sponsorship: ${p.sponsorAnonymous ? 'anonymous' : (p.sponsorName || p.user?.name || 'sponsor')}` : (p.user?.name || p.userId || 'Donor');
|
||||
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${donor} — #${String(p.id).slice(0,8)}`;
|
||||
return <option key={p.id} value={p.id} title={label}>{label}</option>;
|
||||
})}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={sponsorshipsOnly} onChange={e => { setSponsorshipsOnly(e.target.checked); setPaymentId(""); }} /> Only the sponsorship pool
|
||||
</label>
|
||||
{registrationId && donationsForEvent.length === 0 && (
|
||||
<div className="text-xs text-gray-500">No donations with a remaining balance for this event.</div>
|
||||
<div className="text-xs text-gray-500">No {sponsorshipsOnly ? "sponsorships" : "donations"} with a remaining balance for this event.</div>
|
||||
)}
|
||||
{selectedDonation?.isSponsorship && selectedDonation.sponsorMessage && (
|
||||
<div className="text-xs text-rose-800 bg-rose-50 rounded p-2">Sponsor's message: “{selectedDonation.sponsorMessage}”</div>
|
||||
)}
|
||||
|
||||
{selectedDonation && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
|
||||
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -402,6 +403,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageDirty, setMessageDirty] = useState(false);
|
||||
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
|
||||
const [loadingAttendees, setLoadingAttendees] = useState(false);
|
||||
@@ -449,7 +451,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
|
||||
const buildPayload = (dryRun?: boolean) => {
|
||||
const payload: any = {
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
dryRun: dryRun || undefined,
|
||||
};
|
||||
@@ -499,7 +501,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
const whenIso = new Date(scheduledAtLocal).toISOString();
|
||||
const payload: any = {
|
||||
message: message || undefined,
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey, scheduledAt: whenIso,
|
||||
};
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
|
||||
@@ -715,6 +717,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<AllocationGroupFilter token={token} eventId={eventId} value={groupId} onChange={setGroupId} />
|
||||
</div>
|
||||
|
||||
{templateKey !== "tickets" ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -7,7 +8,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
function FormsContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -198,7 +199,9 @@ function FormsContent() {
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||||
{f.type === 'yes_no' ? (
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
|
||||
) : f.type === 'yes_no' ? (
|
||||
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
|
||||
<option value="">Select</option>
|
||||
<option value="yes">Yes</option>
|
||||
|
||||
@@ -995,6 +995,11 @@ export default function UserDashboardPage() {
|
||||
<div className="text-xs font-medium text-blue-700 bg-blue-50 rounded px-2 py-0.5 inline-block">
|
||||
{t.attendee?.name ? <>For: {t.attendee.name}</> : <>Qty: {t.quantity || 1}</>}
|
||||
</div>
|
||||
{(t.attendee?.allocations || []).length > 0 && (
|
||||
<div className="text-xs text-violet-800 bg-violet-50 rounded px-2 py-0.5 inline-block ml-1">
|
||||
{[...t.attendee.allocations].sort((a: any, b: any) => (a.set?.order || 0) - (b.set?.order || 0)).map((a: any) => `${a.set.name}: ${a.group.name}`).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
|
||||
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,8 @@ import ClientActions from "@/app/events/[id]/ClientActions";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
import { LocationMap } from "@/components/events/LocationMap";
|
||||
import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton";
|
||||
import { SponsorSpot } from "@/components/events/SponsorSpot";
|
||||
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -42,6 +44,7 @@ type Event = {
|
||||
contactEmail?: string | null;
|
||||
location?: string | null;
|
||||
waitlistEnabled?: boolean;
|
||||
allowSponsorships?: boolean;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
@@ -164,6 +167,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
|
||||
<FunnelTracker eventId={event.id} stage="view" />
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{event.picture ? (
|
||||
@@ -222,6 +226,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
<div className="mt-4">
|
||||
<RegisterCta event={event} />
|
||||
</div>
|
||||
{event.allowSponsorships && <SponsorSpot eventId={event.id} eventTitle={event.title} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,6 +240,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
<div className="mt-4">
|
||||
<RegisterCta event={event} />
|
||||
</div>
|
||||
{event.allowSponsorships && <SponsorSpot eventId={event.id} eventTitle={event.title} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { MessageSquareHeart, CheckCircle2 } from "lucide-react";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
type Field = { id: string; type: string; label: string; isRequired: boolean; helpText: string | null; options: unknown };
|
||||
type FeedbackInfo = {
|
||||
event: { title: string; startDate: string };
|
||||
name: string;
|
||||
intro: string | null;
|
||||
anonymous: boolean;
|
||||
responded: boolean;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
/** Public page an attendee opens from their post-event feedback email or WhatsApp. */
|
||||
export default function FeedbackPage() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const token = params?.token;
|
||||
const [info, setInfo] = useState<FeedbackInfo | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<FeedbackInfo>(`/api/feedback/public/${encodeURIComponent(token)}`)
|
||||
.then(setInfo)
|
||||
.catch(e => setLoadError(e?.message || "This feedback link isn't valid."));
|
||||
}, [token]);
|
||||
|
||||
const set = (id: string, v: string) => setAnswers(prev => ({ ...prev, [id]: v }));
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/feedback/public/${encodeURIComponent(token!)}`, { method: "POST", body: { answers } });
|
||||
setDone(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't send your feedback");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||
<Navbar />
|
||||
<main className="flex-1 max-w-2xl w-full mx-auto p-4 sm:p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<MessageSquareHeart className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">{info ? `How was ${info.event.title}?` : "Feedback"}</h1>
|
||||
{info && <p className="text-sm text-gray-500">{formatDate(new Date(info.event.startDate))}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && <div className="p-4 border rounded-xl bg-white text-sm text-gray-700">{loadError}</div>}
|
||||
|
||||
{info && (done || info.responded) && (
|
||||
<div className="p-5 border rounded-xl bg-white shadow-sm flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600 shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold">Thank you{info.name ? `, ${info.name.split(" ")[0]}` : ""}!</div>
|
||||
<p className="text-sm text-gray-600">{done ? "Your feedback has been sent." : "You've already given feedback for this event."} It really helps us plan the next one.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info && !done && !info.responded && (
|
||||
<form onSubmit={submit} className="p-5 border rounded-xl bg-white shadow-sm space-y-5">
|
||||
{info.intro && <p className="text-sm text-gray-700 whitespace-pre-line">{info.intro}</p>}
|
||||
<p className="text-xs text-gray-500">{info.anonymous ? "Your answers are anonymous — they aren't linked to your name." : `Answering as ${info.name}.`}</p>
|
||||
{info.fields.map(f => (
|
||||
<div key={f.id}>
|
||||
{f.type === "statement" ? (
|
||||
<p className="text-sm text-gray-700 whitespace-pre-line">{f.label}</p>
|
||||
) : f.type === "paragraph" ? (
|
||||
<div className="text-sm text-gray-700">
|
||||
<div className="font-medium mb-1">{f.label}</div>
|
||||
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-medium text-gray-800 mb-1.5">{f.label}{f.isRequired && <span className="text-red-500"> *</span>}</label>
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} value={answers[f.id] || ""} onChange={v => set(f.id, v)} />
|
||||
) : f.type === "yes_no" ? (
|
||||
<div className="flex gap-4 text-sm">
|
||||
{["yes", "no"].map(v => (
|
||||
<label key={v} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" name={f.id} checked={answers[f.id] === v} onChange={() => set(f.id, v)} /> {v === "yes" ? "Yes" : "No"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : f.type === "numeric" ? (
|
||||
<input type="number" className={`${inputCls} sm:w-40`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
|
||||
) : f.type === "date" ? (
|
||||
<input type="date" className={`${inputCls} sm:w-48`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
|
||||
) : (
|
||||
<textarea className={inputCls} rows={3} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} maxLength={4000} />
|
||||
)}
|
||||
{f.helpText && <p className="text-xs text-gray-500 mt-1">{f.helpText}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<button type="submit" disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{busy ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -8,7 +9,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
function FormsContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -249,7 +250,9 @@ function FormsContent() {
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||||
{f.type === 'yes_no' ? (
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
|
||||
) : f.type === 'yes_no' ? (
|
||||
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
|
||||
<option value="">Select</option>
|
||||
<option value="yes">Yes</option>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ToastProvider } from "@/components/shared/ToastProvider";
|
||||
import { BannerBar } from "@/components/shared/BannerBar";
|
||||
import { SetupGuard } from "@/components/shared/SetupGuard";
|
||||
import HelpFab from "@/components/shared/HelpFab";
|
||||
import { CookieConsent } from "@/components/shared/CookieConsent";
|
||||
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
@@ -96,6 +97,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<BannerBar />
|
||||
{children}
|
||||
<HelpFab />
|
||||
<CookieConsent />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</SiteSettingsProvider>
|
||||
|
||||
@@ -192,6 +192,7 @@ function Section({
|
||||
],
|
||||
cookies: [
|
||||
"This website uses browser localStorage to store your login session token and user interface preferences (such as dismissed notifications). No third-party cookies, tracking pixels, or analytics tools are used.",
|
||||
"If you choose \"Allow\" on the cookie notice, we also keep a random, anonymous visit identifier so we can count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link) and the type of device — never your name, IP address or browsing history. Detailed records are combined into daily totals after 90 days. If you choose \"No thanks\", nothing is counted.",
|
||||
"You can clear your browser's local storage at any time through your browser settings, which will log you out of the site.",
|
||||
],
|
||||
security: [
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
import { analyticsSessionId } from "@/lib/analytics";
|
||||
|
||||
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
|
||||
function lowStockThreshold(stockLimit: number): number {
|
||||
@@ -44,6 +45,7 @@ type Event = {
|
||||
requiresAuth?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
waitlistEnabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -392,7 +394,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
setCreatingRegistration(true);
|
||||
const res = await apiFetch<{ id: string; totalDueComputed?: number }>("/api/registrations", {
|
||||
method: "POST",
|
||||
body: { eventId, options: items, waitlistToken, ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
|
||||
body: { eventId, options: items, waitlistToken, analyticsSessionId: analyticsSessionId(), ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
|
||||
});
|
||||
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
|
||||
} catch (e: any) {
|
||||
@@ -418,6 +420,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
eventId,
|
||||
options: items,
|
||||
...(coupon ? { couponCode: coupon.code } : {}),
|
||||
analyticsSessionId: analyticsSessionId(),
|
||||
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
|
||||
...(waitlistToken ? { waitlistToken } : {}),
|
||||
},
|
||||
@@ -462,6 +465,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
method: "POST",
|
||||
body: {
|
||||
eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined,
|
||||
analyticsSessionId: analyticsSessionId(),
|
||||
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
|
||||
},
|
||||
});
|
||||
@@ -603,6 +607,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
consentEventStart={isYouthEvent ? event.startDate : undefined}
|
||||
collectGender={!!event.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import RegisterForm from "./RegisterForm";
|
||||
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -28,6 +29,7 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
|
||||
<FunnelTracker eventId={event.id} stage="start" />
|
||||
<RegisterForm event={event} />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import React, { Suspense } from "react";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
@@ -6,7 +7,7 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
function RegistrationSuccessContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -242,7 +243,9 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||||
{f.type === 'yes_no' ? (
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => update(idx, f.id, v)} />
|
||||
) : f.type === 'yes_no' ? (
|
||||
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
|
||||
<option value="">Select</option>
|
||||
<option value="yes">Yes</option>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -42,6 +43,7 @@ type KioskEvent = {
|
||||
form?: EventForm | null;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
};
|
||||
|
||||
type Screen = "setup" | "form" | "eventform" | "confirmation";
|
||||
@@ -1025,6 +1027,7 @@ export default function SelfServicePage() {
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
consentEventStart={selectedEvent.requiresParentalConsent ? selectedEvent.startDate : undefined}
|
||||
collectGender={!!selectedEvent.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1190,7 +1193,9 @@ export default function SelfServicePage() {
|
||||
{field.helpText && (
|
||||
<p className="text-xs text-gray-500 mb-1">{field.helpText}</p>
|
||||
)}
|
||||
{field.type === "yes_no" ? (
|
||||
{isExtraFieldType(field.type) ? (
|
||||
<ExtraFieldInput field={field} value={currentTicketAnswers[field.id] || ""} onChange={v => setCurrentTicketAnswers(a => ({ ...a, [field.id]: v }))} />
|
||||
) : field.type === "yes_no" ? (
|
||||
<div className="flex gap-4">
|
||||
{["Yes", "No"].map((opt) => (
|
||||
<label key={opt} className="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { CONSENT_EVENT, trackFunnel } from "@/lib/analytics";
|
||||
|
||||
/** Records a funnel step for this page (once consent is given, even if given later). */
|
||||
export function FunnelTracker({ eventId, stage }: { eventId: string; stage: "view" | "start" }) {
|
||||
useEffect(() => {
|
||||
trackFunnel(eventId, stage);
|
||||
const onConsent = () => trackFunnel(eventId, stage);
|
||||
window.addEventListener(CONSENT_EVENT, onConsent);
|
||||
return () => window.removeEventListener(CONSENT_EVENT, onConsent);
|
||||
}, [eventId, stage]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { HeartHandshake, X } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
const rand = (n: number) => `R${n.toLocaleString("en-ZA", { minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 })}`;
|
||||
|
||||
/**
|
||||
* "Sponsor a spot": progress towards the event's sponsorship goal, and a form to fund
|
||||
* one or more spots (or any amount) for people who can't afford to come. Payment is
|
||||
* through Yoco; the money lands in the event's sponsorship pool.
|
||||
*/
|
||||
export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTitle: string }) {
|
||||
const { token, user } = useAuth();
|
||||
const [data, setData] = useState<Sponsorship | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"spots" | "amount">("spots");
|
||||
const [spots, setSpots] = useState(1);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [anonymous, setAnonymous] = useState(false);
|
||||
const [sponsorName, setSponsorName] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [guest, setGuest] = useState({ name: "", email: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [returned, setReturned] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
|
||||
try {
|
||||
const s = new URLSearchParams(window.location.search).get("sponsored");
|
||||
if (s) setReturned(s);
|
||||
} catch { /* no window */ }
|
||||
}, [eventId]);
|
||||
|
||||
if (!data?.enabled) return null;
|
||||
const price = data.spotPrice || null;
|
||||
const loggedIn = !!(token && user);
|
||||
const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
|
||||
const goal = data.goalSpots || 0;
|
||||
const funded = data.spotsFunded ?? 0;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/sponsorships/checkout", {
|
||||
method: "POST",
|
||||
...(loggedIn ? { authToken: token! } : {}),
|
||||
body: {
|
||||
eventId,
|
||||
...(mode === "spots" && price ? { spots } : { amount: Number(amount) }),
|
||||
anonymous,
|
||||
...(!anonymous && sponsorName.trim() ? { sponsorName: sponsorName.trim() } : {}),
|
||||
...(message.trim() ? { message: message.trim() } : {}),
|
||||
...(!loggedIn ? { name: guest.name, email: guest.email } : {}),
|
||||
},
|
||||
});
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't start the payment");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t pt-4 space-y-2">
|
||||
{returned === "1" && <div className="text-sm rounded-lg bg-green-50 text-green-800 p-2">Thank you for sponsoring! Your receipt is on its way.</div>}
|
||||
{returned === "failed" && <div className="text-sm rounded-lg bg-red-50 text-red-800 p-2">The payment didn't go through. You can try again.</div>}
|
||||
{data.showProgress && price && (goal > 0 || funded > 0) && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span>{goal > 0 ? `${Math.min(funded, goal)} of ${goal} spots sponsored` : `${funded} spot${funded === 1 ? "" : "s"} sponsored`}</span>
|
||||
{data.sponsors ? <span>{data.sponsors} sponsor{data.sponsors === 1 ? "" : "s"}</span> : null}
|
||||
</div>
|
||||
{goal > 0 && (
|
||||
<div className="h-2 rounded-full bg-gray-100 overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={goal} aria-valuenow={Math.min(funded, goal)}>
|
||||
<div className="h-full bg-rose-500" style={{ width: `${Math.min(100, (funded / goal) * 100)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data.open && (
|
||||
<button onClick={() => setOpen(true)} className="w-full inline-flex items-center justify-center gap-2 border border-rose-300 text-rose-700 px-4 py-2 rounded-lg hover:bg-rose-50 font-medium text-sm">
|
||||
<HeartHandshake className="w-4 h-4" /> Sponsor a spot
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2"><HeartHandshake className="w-5 h-5 text-rose-600" /> Sponsor a spot</h3>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-1 rounded hover:bg-gray-100"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Help someone come to {eventTitle} who couldn't otherwise afford it. Your gift goes into a sponsorship pool that the organisers use to cover places.</p>
|
||||
|
||||
{price ? (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<button type="button" onClick={() => setMode("spots")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "spots" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Whole spots</button>
|
||||
<button type="button" onClick={() => setMode("amount")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "amount" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Any amount</button>
|
||||
</div>
|
||||
) : null}
|
||||
{mode === "spots" && price ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<input type="number" min={1} max={100} className={`${inputCls} w-24`} value={spots} onChange={e => setSpots(Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1)))} aria-label="Number of spots" />
|
||||
<span>spot{spots === 1 ? "" : "s"} × {rand(price)} = <strong>{rand(spots * price)}</strong></span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Amount (R)</label>
|
||||
<input type="number" min={15} step="0.01" className={inputCls} value={amount} onChange={e => setAmount(e.target.value)} placeholder="e.g. 200" required />
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum R15.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loggedIn && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input className={inputCls} placeholder="Your name" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required />
|
||||
<input type="email" className={inputCls} placeholder="Email (for your receipt)" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required />
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={anonymous} onChange={e => setAnonymous(e.target.checked)} /> Give anonymously
|
||||
</label>
|
||||
{!anonymous && (
|
||||
<input className={inputCls} placeholder={`Name to show the organisers (e.g. "The Smith family")`} value={sponsorName} onChange={e => setSponsorName(e.target.value)} maxLength={100} />
|
||||
)}
|
||||
<textarea className={inputCls} rows={2} placeholder="A message for the organisers (optional)" value={message} onChange={e => setMessage(e.target.value)} maxLength={300} />
|
||||
<p className="text-xs text-gray-500">You'll get a receipt by email. We never share who is helped by sponsorships.</p>
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<button type="submit" disabled={busy || !(total >= 15)} className="w-full px-4 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 font-medium">
|
||||
{busy ? "Opening payment…" : `Pay ${total > 0 ? rand(total) : ""} securely`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
TicketPercent,
|
||||
ShieldCheck,
|
||||
HeartPulse,
|
||||
MessageSquareHeart,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
|
||||
@@ -37,6 +39,8 @@ export const REPORTS = [
|
||||
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
|
||||
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
|
||||
{ key: "consentStatus", label: "Parental consent status", description: "Youth events: consent per attendee", category: "Registration", icon: ShieldCheck, fields: 5 },
|
||||
{ key: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
|
||||
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
|
||||
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
|
||||
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
|
||||
@@ -5,12 +5,13 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck } from "lucide-react";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck } from "lucide-react";
|
||||
import { REPORTS, type ReportKey } from "./ReportCatalog";
|
||||
import ReportsShell from "./ReportsShell";
|
||||
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
|
||||
import { StatTile, StatTileRow } from "./StatTile";
|
||||
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
|
||||
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
|
||||
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
@@ -177,6 +178,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
// Youth events (one row per attendee), each tagged with its event title.
|
||||
const [consentRows, setConsentRows] = useState<any[]>([]);
|
||||
const [medicalRows, setMedicalRows] = useState<any[]>([]);
|
||||
// Post-event feedback, one entry per event that had a feedback form.
|
||||
const [feedbackRows, setFeedbackRows] = useState<any[]>([]);
|
||||
// Registration funnel for the selected events (null until viewed).
|
||||
const [funnel, setFunnel] = useState<any | null>(null);
|
||||
|
||||
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
|
||||
// straight into a specific report, pre-filtered to every currently-visible event and the
|
||||
@@ -286,6 +291,27 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
return byEv;
|
||||
};
|
||||
|
||||
const loadFunnel = async (eventIds: string[]) => {
|
||||
if (!token || !eventIds.length) return null;
|
||||
const query = new URLSearchParams({ eventIds: eventIds.join(",") });
|
||||
if (dateFrom) query.set("from", dateFrom);
|
||||
if (dateTo) query.set("to", dateTo);
|
||||
try {
|
||||
return await apiFetch<any>(`/api/analytics/funnel?${query.toString()}`, { authToken: token });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadFeedback = async (eventIds: string[]) => {
|
||||
if (!token || !eventIds.length) return [] as any[];
|
||||
try {
|
||||
return await apiFetch<any[]>(`/api/feedback/summary?eventIds=${encodeURIComponent(eventIds.join(","))}`, { authToken: token });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Coupon uses (active redemptions on non-cancelled registrations) for the Discounts report.
|
||||
const loadDiscounts = async (eventIds: string[]) => {
|
||||
if (!token || !eventIds.length) return [] as any[];
|
||||
@@ -352,6 +378,29 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
};
|
||||
}, [discountRows]);
|
||||
|
||||
// Feedback summary: overall numbers, and the average star rating per event (for comparing events).
|
||||
const feedbackSummary = useMemo(() => {
|
||||
const invited = feedbackRows.reduce((s, f) => s + (f.invited || 0), 0);
|
||||
const responses = feedbackRows.reduce((s, f) => s + (f.responses || 0), 0);
|
||||
const ratingOf = (f: any) => {
|
||||
const avgs = (f.questions || []).filter((q: any) => q.type === "rating" && q.average != null).map((q: any) => q.average);
|
||||
return avgs.length ? Math.round((avgs.reduce((s: number, n: number) => s + n, 0) / avgs.length) * 10) / 10 : null;
|
||||
};
|
||||
const perEvent = feedbackRows.map(f => ({ ...f, rating: ratingOf(f) }));
|
||||
const rated = perEvent.filter(f => f.rating != null);
|
||||
return {
|
||||
perEvent, invited, responses,
|
||||
rate: invited ? Math.round((responses / invited) * 100) : null,
|
||||
rating: rated.length ? Math.round((rated.reduce((s, f) => s + f.rating, 0) / rated.length) * 10) / 10 : null,
|
||||
chart: rated.map(f => ({ label: f.title, value: f.rating })),
|
||||
};
|
||||
}, [feedbackRows]);
|
||||
const feedbackResult = (q: any) =>
|
||||
q.type === "rating" ? (q.average != null ? `${q.average} / 5` : "—")
|
||||
: q.type === "numeric" ? (q.average != null ? `average ${q.average}` : "—")
|
||||
: q.counts ? q.counts.map((c: any) => `${c.label}: ${c.value}`).join(" · ")
|
||||
: `${(q.texts || []).length} answer(s)`;
|
||||
|
||||
// View handler according to selected report
|
||||
const onView = async () => {
|
||||
setBusy(true);
|
||||
@@ -409,6 +458,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
setDiscountRows(await loadDiscounts(selectedEventIds));
|
||||
} else if (report === "consentStatus") {
|
||||
setConsentRows(await loadConsent(selectedEventIds, "status"));
|
||||
} else if (report === "funnel") {
|
||||
setFunnel(await loadFunnel(selectedEventIds));
|
||||
} else if (report === "feedbackSummary") {
|
||||
setFeedbackRows(await loadFeedback(selectedEventIds));
|
||||
} else if (report === "medicalList") {
|
||||
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
|
||||
}
|
||||
@@ -970,7 +1023,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
|
||||
const unused = Math.max(total - used, 0);
|
||||
grandTotal += total; grandUsed += used;
|
||||
rows.push([ev?.title || evId, Number(total.toFixed(2)), Number(used.toFixed(2)), Number(unused.toFixed(2)), dons.length]);
|
||||
const pool = dons.filter((p: any) => p.isSponsorship);
|
||||
const poolIn = pool.reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const poolUsed = pool.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
|
||||
rows.push([ev?.title || evId, Number(total.toFixed(2)), Number(used.toFixed(2)), Number(unused.toFixed(2)), dons.length, Number(poolIn.toFixed(2)), Number(poolUsed.toFixed(2)), Number(Math.max(poolIn - poolUsed, 0).toFixed(2))]);
|
||||
});
|
||||
const grandUnused = Math.max(grandTotal - grandUsed, 0);
|
||||
return {
|
||||
@@ -981,7 +1037,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
{ label: 'Unused', value: `R ${grandUnused.toFixed(2)}`, tone: 'amber' },
|
||||
],
|
||||
chart: { title: 'Used vs. unused, across all selected events', data: [{ label: "Used", value: grandUsed, displayValue: `R ${grandUsed.toFixed(2)}` }, { label: "Unused", value: grandUnused, displayValue: `R ${grandUnused.toFixed(2)}` }] },
|
||||
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
|
||||
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count", "Sponsorship pool (R)", "Pool assigned (R)", "Pool remaining (R)"], rows }
|
||||
};
|
||||
}
|
||||
if (report === "consentStatus") {
|
||||
@@ -1001,6 +1057,50 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (report === "funnel" && funnel) {
|
||||
const pct = (v: number | null) => (v != null ? `${v}%` : "—");
|
||||
const line = (label: string, c: any) => [label, c.view, c.start, c.submit, c.paid, pct(c.conversion ?? (c.view ? Math.round((c.paid / c.view) * 1000) / 10 : null))];
|
||||
const rows: (string | number)[][] = [
|
||||
line("All selected events", funnel.totals),
|
||||
...(funnel.perEvent || []).length > 1 ? funnel.perEvent.map((p: any) => line(p.title, p.totals)) : [],
|
||||
...funnel.bySource.map((s: any) => line(`Source: ${s.key}`, s)),
|
||||
...funnel.byDevice.map((d: any) => line(`Device: ${d.key}`, d)),
|
||||
...funnel.byOption.map((o: any) => [`Ticket: ${o.key}`, "", "", o.submitted, o.paid, ""]),
|
||||
];
|
||||
return {
|
||||
title: 'Registration funnel', subtitle, kind: 'table', orientation: 'landscape',
|
||||
stats: [
|
||||
{ label: 'Event page visits', value: String(funnel.totals.view), tone: 'blue' },
|
||||
{ label: 'Paid', value: String(funnel.totals.paid), tone: 'green' },
|
||||
{ label: 'Conversion', value: pct(funnel.conversion.paid), tone: 'violet' },
|
||||
],
|
||||
chart: { title: 'Visitors reaching each stage', data: [
|
||||
{ label: 'Viewed event', value: funnel.totals.view }, { label: 'Started', value: funnel.totals.start },
|
||||
{ label: 'Submitted', value: funnel.totals.submit }, { label: 'Paid', value: funnel.totals.paid },
|
||||
] },
|
||||
note: 'Counts only visitors who allowed cookies. A visitor counts at a stage if they got that far or further.',
|
||||
table: { columns: ["Breakdown", "Viewed", "Started", "Submitted", "Paid", "Conversion"], rows },
|
||||
};
|
||||
}
|
||||
if (report === "feedbackSummary") {
|
||||
const rows: (string | number)[][] = [];
|
||||
for (const f of feedbackSummary.perEvent) {
|
||||
for (const q of f.questions || []) {
|
||||
rows.push([f.title, q.label, q.answered, feedbackResult(q)]);
|
||||
for (const t of q.texts || []) rows.push(["", ` “${t.value}”`, "", t.name || ""]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Feedback summary', subtitle, kind: 'table', orientation: 'landscape',
|
||||
stats: [
|
||||
{ label: 'Responses', value: String(feedbackSummary.responses), tone: 'violet' },
|
||||
{ label: 'Response rate', value: feedbackSummary.rate != null ? `${feedbackSummary.rate}%` : '—', tone: 'blue' },
|
||||
{ label: 'Average rating', value: feedbackSummary.rating != null ? `${feedbackSummary.rating} / 5` : '—', tone: 'amber' },
|
||||
],
|
||||
...(feedbackSummary.chart.length > 1 ? { chart: { title: 'Average rating, by event', data: feedbackSummary.chart.map(c => ({ ...c, displayValue: `${c.value} / 5` })) } } : {}),
|
||||
table: { columns: ["Event", "Question", "Answered", "Result"], rows },
|
||||
};
|
||||
}
|
||||
if (report === "medicalList") {
|
||||
const fmt = (m: any) => m ? [m.allergies && `Allergies: ${m.allergies}`, m.medication && `Medication: ${m.medication}`, m.conditions && `Conditions: ${m.conditions}`, m.medicalAid && `Medical aid: ${m.medicalAid}`].filter(Boolean).join('; ') : '';
|
||||
return {
|
||||
@@ -1669,6 +1769,132 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{report === "funnel" && (
|
||||
<div>
|
||||
{!funnel || funnel.totals.view === 0 ? (
|
||||
<div className="text-sm text-gray-500">No tracked visits for the selected events and dates yet. Only visitors who allow cookies are counted.</div>
|
||||
) : (() => {
|
||||
const pct = (v: number | null) => (v != null ? `${v}%` : "—");
|
||||
const stages = [
|
||||
{ label: "Viewed event", value: funnel.totals.view },
|
||||
{ label: `Started (${pct(funnel.conversion.start)})`, value: funnel.totals.start },
|
||||
{ label: `Submitted (${pct(funnel.conversion.submit)})`, value: funnel.totals.submit },
|
||||
{ label: `Paid (${pct(funnel.conversion.paid)})`, value: funnel.totals.paid },
|
||||
];
|
||||
const table = (title: string, rows: any[]) => rows.length === 0 ? null : (
|
||||
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
|
||||
<table className="min-w-[560px] w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
|
||||
<th className="py-2 px-3">{title}</th><th className="py-2 px-3 text-right">Viewed</th><th className="py-2 px-3 text-right">Started</th>
|
||||
<th className="py-2 px-3 text-right">Submitted</th><th className="py-2 px-3 text-right">Paid</th><th className="py-2 px-3 text-right">Conversion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r: any) => (
|
||||
<tr key={r.key} className="border-t border-gray-100">
|
||||
<td className="py-2 px-3">{r.key}</td><td className="py-2 px-3 text-right">{r.view}</td><td className="py-2 px-3 text-right">{r.start}</td>
|
||||
<td className="py-2 px-3 text-right">{r.submit}</td><td className="py-2 px-3 text-right">{r.paid}</td><td className="py-2 px-3 text-right">{pct(r.conversion)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<StatTileRow>
|
||||
<StatTile icon={Eye} label="Event page visits" value={String(funnel.totals.view)} tone="blue" />
|
||||
<StatTile icon={MousePointerClick} label="Started registering" value={String(funnel.totals.start)} tone="amber" />
|
||||
<StatTile icon={BadgeCheck} label="Paid" value={`${funnel.totals.paid} (${pct(funnel.conversion.paid)})`} tone="green" />
|
||||
</StatTileRow>
|
||||
<div className="mt-4 border border-gray-100 rounded-xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-3">Visitors reaching each stage (% of event page visits)</div>
|
||||
<HorizontalBarChart data={stages} valueFormatter={v => String(v)} />
|
||||
<div className="text-xs text-gray-500 mt-2">Started → submitted: {pct(funnel.conversion.startToSubmit)} · Submitted → paid: {pct(funnel.conversion.submitToPaid)}</div>
|
||||
</div>
|
||||
{funnel.trend.length > 1 && (
|
||||
<div className="mt-4 border border-gray-100 rounded-xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-3">Event page visits per day</div>
|
||||
<AreaTrendChart data={funnel.trend.map((t: any) => ({ label: t.day.slice(5), value: t.view }))} />
|
||||
</div>
|
||||
)}
|
||||
{(funnel.perEvent || []).length > 1 && table("Event", funnel.perEvent.map((p: any) => ({ key: p.title, ...p.totals, conversion: p.conversion.paid })))}
|
||||
{table("Source", funnel.bySource)}
|
||||
{table("Device", funnel.byDevice)}
|
||||
{funnel.byOption.length > 0 && (
|
||||
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
|
||||
<table className="min-w-[400px] w-full">
|
||||
<thead><tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50"><th className="py-2 px-3">Ticket option</th><th className="py-2 px-3 text-right">Registrations</th><th className="py-2 px-3 text-right">Paid</th></tr></thead>
|
||||
<tbody>
|
||||
{funnel.byOption.map((o: any) => (
|
||||
<tr key={o.key} className="border-t border-gray-100"><td className="py-2 px-3">{o.key}</td><td className="py-2 px-3 text-right">{o.submitted}</td><td className="py-2 px-3 text-right">{o.paid}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-3">Only visitors who allowed cookies are counted. A visitor counts at a stage if they got that far or further. Sources come from utm_source links and short links.</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{report === "feedbackSummary" && (
|
||||
<div>
|
||||
{feedbackSummary.perEvent.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">None of the selected events has a feedback form. Add one in the event wizard's Feedback step.</div>
|
||||
) : (
|
||||
<>
|
||||
<StatTileRow>
|
||||
<StatTile icon={MessageSquareHeart} label="Responses" value={String(feedbackSummary.responses)} tone="violet" />
|
||||
<StatTile icon={Send} label="Response rate" value={feedbackSummary.rate != null ? `${feedbackSummary.rate}%` : "—"} tone="blue" />
|
||||
<StatTile icon={Star} label="Average rating" value={feedbackSummary.rating != null ? `${feedbackSummary.rating} / 5` : "—"} tone="amber" />
|
||||
</StatTileRow>
|
||||
{feedbackSummary.chart.length > 1 && (
|
||||
<div className="mt-4 border border-gray-100 rounded-xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-3">Average rating, by event</div>
|
||||
<HorizontalBarChart data={feedbackSummary.chart} valueFormatter={v => `${v} / 5`} />
|
||||
</div>
|
||||
)}
|
||||
{feedbackSummary.perEvent.map((f: any) => (
|
||||
<div key={f.eventId} className="mt-4 border border-gray-100 rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-3">
|
||||
<div className="font-semibold">{f.title}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{f.sentAt ? `${f.responses} of ${f.invited} responded${f.responseRate != null ? ` (${f.responseRate}%)` : ""}` : "Not sent yet"}{f.anonymous ? " · anonymous" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{(f.questions || []).map((q: any) => (
|
||||
<div key={q.fieldId}>
|
||||
<div className="text-sm font-medium text-gray-800">{q.label} <span className="text-xs font-normal text-gray-500">· {q.answered} answer(s)</span></div>
|
||||
{q.type === "rating" && (
|
||||
<div className="mt-1">
|
||||
<div className="text-sm"><span className="text-lg font-semibold">{q.average ?? "—"}</span> / 5</div>
|
||||
<HorizontalBarChart data={q.distribution} valueFormatter={v => String(v)} />
|
||||
</div>
|
||||
)}
|
||||
{q.counts && <div className="mt-1"><HorizontalBarChart data={q.counts} valueFormatter={v => String(v)} /></div>}
|
||||
{q.type === "numeric" && <div className="text-sm mt-1">Average: {q.average ?? "—"}</div>}
|
||||
{q.texts && (
|
||||
q.texts.length === 0 ? <div className="text-xs text-gray-500 mt-1">No answers.</div> : (
|
||||
<ul className="mt-1 space-y-1 max-h-64 overflow-auto">
|
||||
{q.texts.map((t: any, i: number) => (
|
||||
<li key={i} className="text-sm text-gray-700 border-l-2 border-gray-200 pl-2">{t.value}{t.name && <span className="text-xs text-gray-500"> — {t.name}</span>}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(report === "consentStatus" || report === "medicalList") && (() => {
|
||||
const payload: any = buildReportPayload();
|
||||
const rows: any[] = payload?.table?.rows || [];
|
||||
@@ -1775,7 +2001,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
|
||||
const unused = Math.max(total - used, 0);
|
||||
grandTotal += total; grandUsed += used; grandCount += dons.length;
|
||||
return { evId, title: ev?.title || evId, total, used, unused, count: dons.length };
|
||||
const pool = dons.filter((p: any) => p.isSponsorship);
|
||||
const poolIn = pool.reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const poolUsed = pool.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
|
||||
return { evId, title: ev?.title || evId, total, used, unused, count: dons.length, poolIn, poolUsed, poolLeft: Math.max(poolIn - poolUsed, 0) };
|
||||
});
|
||||
const grandUnused = Math.max(grandTotal - grandUsed, 0);
|
||||
return (
|
||||
@@ -1799,6 +2028,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
<th className="py-2 px-3 text-right">Unused (R)</th>
|
||||
<th className="py-2 px-3 text-right">Count</th>
|
||||
<th className="py-2 px-3 text-right">Avg (R)</th>
|
||||
<th className="py-2 px-3 text-right">Sponsorship pool (R)</th>
|
||||
<th className="py-2 px-3 text-right">Pool assigned (R)</th>
|
||||
<th className="py-2 px-3 text-right">Pool remaining (R)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1810,6 +2042,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
<td className="py-2 px-3 text-right">R {row.unused.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">{row.count}</td>
|
||||
<td className="py-2 px-3 text-right">{row.count ? `R ${(row.total / row.count).toFixed(2)}` : '-'}</td>
|
||||
<td className="py-2 px-3 text-right">R {row.poolIn.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {row.poolUsed.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {row.poolLeft.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
|
||||
@@ -1819,6 +2054,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
<td className="py-2 px-3 text-right">R {grandUnused.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">{grandCount}</td>
|
||||
<td className="py-2 px-3"></td>
|
||||
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolIn, 0).toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolUsed, 0).toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolLeft, 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type SetWithGroups = { id: string; name: string; groups: { id: string; name: string }[] };
|
||||
|
||||
/**
|
||||
* "Only people in…" a room / group / bus, for messaging attendees. Renders nothing when
|
||||
* the event has no allocation sets.
|
||||
*/
|
||||
export function AllocationGroupFilter({ token, eventId, value, onChange }: {
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
value: string;
|
||||
onChange: (groupId: string) => void;
|
||||
}) {
|
||||
const [sets, setSets] = useState<SetWithGroups[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange("");
|
||||
if (!token || !eventId) { setSets([]); return; }
|
||||
apiFetch<{ sets: SetWithGroups[] }>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token })
|
||||
.then(b => setSets((b.sets || []).filter(s => s.groups.length > 0)))
|
||||
.catch(() => setSets([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, eventId]);
|
||||
|
||||
if (sets.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Room / group</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">Everyone</option>
|
||||
{sets.map(s => (
|
||||
<optgroup key={s.id} label={s.name}>
|
||||
{s.groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{value && <p className="text-xs text-gray-500 mt-1">Sent to whoever booked for the people in this group.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,14 +11,14 @@ export type AttendeeOption = {
|
||||
};
|
||||
|
||||
export type AttendeeEntry = {
|
||||
name: string; email: string; phone: string;
|
||||
name: string; email: string; phone: string; gender?: string;
|
||||
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
||||
};
|
||||
export type AttendeeEntries = Record<string, AttendeeEntry>;
|
||||
export type TicketDelivery = "payer" | "each";
|
||||
|
||||
export type AttendeePayload = {
|
||||
eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string;
|
||||
eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string; gender?: string;
|
||||
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
||||
};
|
||||
|
||||
@@ -67,6 +67,7 @@ export function attendeesPayload(options: AttendeeOption[], qtyFor: (optionId: s
|
||||
...(e.email.trim() ? { email: e.email.trim() } : {}),
|
||||
...(e.phone.trim() ? { phone: e.phone.trim() } : {}),
|
||||
...(e.dateOfBirth ? { dateOfBirth: e.dateOfBirth } : {}),
|
||||
...(e.gender ? { gender: e.gender } : {}),
|
||||
...(e.parentName?.trim() ? { parentName: e.parentName.trim() } : {}),
|
||||
...(e.parentEmail?.trim() ? { parentEmail: e.parentEmail.trim() } : {}),
|
||||
...(e.parentPhone?.trim() ? { parentPhone: e.parentPhone.trim() } : {}),
|
||||
@@ -105,6 +106,7 @@ export function AttendeeNamesEditor({
|
||||
onDeliveryChange,
|
||||
payerLabel = "me",
|
||||
consentEventStart,
|
||||
collectGender = false,
|
||||
}: {
|
||||
options: AttendeeOption[];
|
||||
qtyFor: (optionId: string, variantId: string | null) => number;
|
||||
@@ -117,6 +119,8 @@ export function AttendeeNamesEditor({
|
||||
payerLabel?: string;
|
||||
/** Set for youth events: names are required, with a date of birth, and parent details for minors. */
|
||||
consentEventStart?: string;
|
||||
/** Ask each person's gender (for gender-split rooms). */
|
||||
collectGender?: boolean;
|
||||
}) {
|
||||
const consent = !!consentEventStart;
|
||||
const slots = slotsFor(options, qtyFor);
|
||||
@@ -159,6 +163,13 @@ export function AttendeeNamesEditor({
|
||||
<input type="email" className={inputCls} placeholder="Email (optional)" value={e.email} onChange={ev => set(s.key, "email", ev.target.value)} aria-label={`${s.label} email`} />
|
||||
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={e.phone} onChange={ev => set(s.key, "phone", ev.target.value)} aria-label={`${s.label} phone`} />
|
||||
</div>
|
||||
{collectGender && (
|
||||
<select className={`${inputCls} mt-2 sm:w-48`} value={e.gender || ""} onChange={ev => set(s.key, "gender", ev.target.value)} aria-label={`${s.label} gender`}>
|
||||
<option value="">Gender…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{consent && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
|
||||
@@ -13,13 +13,13 @@ const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
|
||||
type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
dateOfBirth?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
type Row = {
|
||||
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
||||
dateOfBirth: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -37,6 +37,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
}) {
|
||||
const attendees: Attendee[] = registration.attendees || [];
|
||||
const youth = !!registration.event?.requiresParentalConsent;
|
||||
const collectGender = !!registration.event?.collectGender;
|
||||
const eventStart: string | undefined = registration.event?.startDate;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
@@ -57,6 +58,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
id: a?.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, label: `${label} ${i + 1}`,
|
||||
name: a?.name || "", email: a?.email || "", phone: a?.phone || "",
|
||||
dateOfBirth: a?.dateOfBirth ? String(a.dateOfBirth).slice(0, 10) : "",
|
||||
gender: a?.gender || "",
|
||||
parentName: a?.parentName || "", parentEmail: a?.parentEmail || "", parentPhone: a?.parentPhone || "",
|
||||
locked: !!a?.ticket?.isUsed,
|
||||
});
|
||||
@@ -68,7 +70,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
if (!registration.event?.allowNamedAttendees && !youth && attendees.length === 0) return null;
|
||||
|
||||
const startEdit = () => { setRows(buildRows()); setDelivery(registration.ticketDelivery || "payer"); setError(null); setEditing(true); };
|
||||
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
|
||||
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "gender" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
|
||||
setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
|
||||
|
||||
const save = async () => {
|
||||
@@ -88,6 +90,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
email: r.email.trim() || undefined,
|
||||
phone: r.phone.trim() || undefined,
|
||||
...(r.dateOfBirth ? { dateOfBirth: r.dateOfBirth } : {}),
|
||||
...(r.gender ? { gender: r.gender } : {}),
|
||||
...(r.parentName.trim() ? { parentName: r.parentName.trim() } : {}),
|
||||
...(r.parentEmail.trim() ? { parentEmail: r.parentEmail.trim() } : {}),
|
||||
...(r.parentPhone.trim() ? { parentPhone: r.parentPhone.trim() } : {}),
|
||||
@@ -171,6 +174,13 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Email (optional)" value={r.email} disabled={r.locked} onChange={e => update(i, "email", e.target.value)} aria-label={`${r.label} email`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Phone (optional)" value={r.phone} disabled={r.locked} onChange={e => update(i, "phone", e.target.value)} aria-label={`${r.label} phone`} />
|
||||
</div>
|
||||
{collectGender && (
|
||||
<select className="mt-1 border rounded px-2 py-1 text-sm" value={r.gender} disabled={r.locked} onChange={e => update(i, "gender", e.target.value)} aria-label={`${r.label} gender`}>
|
||||
<option value="">Gender…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{youth && (
|
||||
<div className="mt-1 space-y-1">
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm" value={r.dateOfBirth} disabled={r.locked} onChange={e => update(i, "dateOfBirth", e.target.value)} aria-label={`${r.label} date of birth`} />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Cookie } from "lucide-react";
|
||||
import { getConsent, setConsent } from "@/lib/analytics";
|
||||
|
||||
/**
|
||||
* Asks once whether we may use a cookie-like identifier to count visits (our own
|
||||
* registration-funnel stats). Declining changes nothing else about the site.
|
||||
*/
|
||||
export function CookieConsent() {
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => { setShow(getConsent() === null); }, []);
|
||||
if (!show) return null;
|
||||
const choose = (v: "accepted" | "declined") => { setConsent(v); setShow(false); };
|
||||
return (
|
||||
<div className="fixed bottom-0 inset-x-0 z-40 p-3 sm:p-4 pointer-events-none" role="region" aria-label="Cookie consent">
|
||||
<div className="pointer-events-auto max-w-3xl mx-auto bg-white border shadow-lg rounded-xl p-4 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<Cookie className="w-6 h-6 text-brand-600 shrink-0 hidden sm:block" />
|
||||
<p className="text-sm text-gray-700 flex-1">
|
||||
May we count your visit? We use our own anonymous statistics (no third-party analytics) to see how people find and register for events.{" "}
|
||||
<Link href="/legal/privacy#cookies" className="text-brand-700 underline">Privacy policy</Link>
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button onClick={() => choose("declined")} className="px-3 py-1.5 text-sm rounded-lg border hover:bg-gray-50">No thanks</button>
|
||||
<button onClick={() => choose("accepted")} className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700">Allow</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Star } from "lucide-react";
|
||||
|
||||
/** Field types added for feedback (also usable on attendee forms). */
|
||||
export const EXTRA_FIELD_TYPES = ["rating", "multiple_choice"] as const;
|
||||
export const isExtraFieldType = (t: string) => (EXTRA_FIELD_TYPES as readonly string[]).includes(t);
|
||||
|
||||
/** The choices of a multiple-choice field (stored as a JSON array of strings). */
|
||||
export const fieldChoices = (field: { options?: unknown }): string[] =>
|
||||
Array.isArray(field.options) ? field.options.map(String).filter(Boolean) : [];
|
||||
|
||||
/**
|
||||
* Input for a rating (1–5 stars) or multiple-choice field. Values are strings, like every
|
||||
* other form answer: "1".."5" for a rating, the chosen option for multiple choice.
|
||||
*/
|
||||
export function ExtraFieldInput({ field, value, onChange, idPrefix = "f" }: {
|
||||
field: { id?: string; type: string; label: string; options?: unknown };
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
idPrefix?: string;
|
||||
}) {
|
||||
if (field.type === "rating") {
|
||||
const n = Number(value) || 0;
|
||||
return (
|
||||
<div className="flex items-center gap-1" role="radiogroup" aria-label={field.label}>
|
||||
{[1, 2, 3, 4, 5].map(star => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={n === star}
|
||||
aria-label={`${star} star${star === 1 ? "" : "s"}`}
|
||||
onClick={() => onChange(n === star ? "" : String(star))}
|
||||
className="p-0.5 rounded focus:outline-none focus:ring-2 focus:ring-amber-300"
|
||||
>
|
||||
<Star className={`w-7 h-7 ${star <= n ? "fill-amber-400 text-amber-400" : "text-gray-300"}`} />
|
||||
</button>
|
||||
))}
|
||||
{n > 0 && <span className="text-xs text-gray-500 ml-1">{n} of 5</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const choices = fieldChoices(field);
|
||||
return (
|
||||
<div className="space-y-1" role="radiogroup" aria-label={field.label}>
|
||||
{choices.map(c => (
|
||||
<label key={c} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" name={`${idPrefix}-${field.id || field.label}`} checked={value === c} onChange={() => onChange(c)} />
|
||||
{c}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Builder controls for a multiple-choice field's options (one per line). */
|
||||
export function ChoicesEditor({ options, onChange }: { options: unknown; onChange: (opts: string[]) => void }) {
|
||||
const text = Array.isArray(options) ? options.join("\n") : "";
|
||||
return (
|
||||
<textarea
|
||||
className="border rounded px-2 py-1 text-xs w-full mt-1"
|
||||
rows={3}
|
||||
placeholder={"One choice per line, e.g.\nWorship\nTalks\nFood"}
|
||||
value={text}
|
||||
onChange={e => onChange(e.target.value.split("\n"))}
|
||||
onBlur={e => onChange(e.target.value.split("\n").map(s => s.trim()).filter(Boolean))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,6 @@ export function StatCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCardRow({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5 gap-4">{children}</div>;
|
||||
export function StatCardRow({ children, cols = 5 }: { children: React.ReactNode; cols?: 5 | 6 }) {
|
||||
return <div className={`grid grid-cols-1 sm:grid-cols-3 ${cols === 6 ? "lg:grid-cols-6" : "lg:grid-cols-5"} gap-4`}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -25,19 +25,19 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const youth = !!event?.requiresParentalConsent;
|
||||
const [f, setF] = useState({ name: "", email: "", phone: "", dateOfBirth: "", parentName: "", parentEmail: "", parentPhone: "" });
|
||||
const [f, setF] = useState({ name: "", email: "", phone: "", gender: "", dateOfBirth: "", parentName: "", parentEmail: "", parentPhone: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const minor = youth && isMinorOn(f.dateOfBirth, event?.startDate);
|
||||
const places = ticket.quantity || 1;
|
||||
const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement>) => setF(prev => ({ ...prev, [k]: e.target.value }));
|
||||
const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setF(prev => ({ ...prev, [k]: e.target.value }));
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: Record<string, string> = { name: f.name, email: f.email, phone: f.phone };
|
||||
const body: Record<string, string> = { name: f.name, email: f.email, phone: f.phone, ...(f.gender ? { gender: f.gender } : {}) };
|
||||
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
|
||||
const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body });
|
||||
onDone(res.message);
|
||||
@@ -76,6 +76,16 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
<input type="tel" className={input} value={f.phone} onChange={set("phone")} />
|
||||
</div>
|
||||
</div>
|
||||
{event?.collectGender && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Gender</label>
|
||||
<select className={input} value={f.gender} onChange={set("gender")} required>
|
||||
<option value="">Choose…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">An email or phone number is needed to send them the ticket. If they don't have an account yet, one is created and they get a link to activate it.</p>
|
||||
{youth && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Ticket, Tag, Share2, QrCode, Calendar, Users } from "lucide-react";
|
||||
import { Ticket, Tag, Share2, QrCode, Calendar, Users, HeartHandshake } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -25,6 +25,9 @@ export const eventDetailHelpContent: HelpContent = {
|
||||
<GuideItem icon={Ticket} title="Availability badges" tone="brand">
|
||||
"N remaining" appears once stock runs low, and "Sold out" once an option is fully booked. Some events have a waitlist: tap "Join waitlist" and you'll be emailed or WhatsApped a link to claim a place if one opens up (it's held for you for a limited time).
|
||||
</GuideItem>
|
||||
<GuideItem icon={HeartHandshake} title="Sponsor a spot" tone="rose">
|
||||
Some events let you pay for a place for someone who can't afford it. Tap "Sponsor a spot", choose one or more spots (or any amount), and pay securely with Yoco. You can give anonymously or add a name and a message for the organisers. You'll get a receipt, and who is helped is never shared.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
|
||||
Some events let you name each person you're booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own. On youth events, you'll give each person's date of birth; for anyone under 18, their parent or guardian is asked to give consent by email and WhatsApp before they can be checked in.
|
||||
</GuideItem>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { supervisorCouponsHelpContent } from "./supervisor-coupons";
|
||||
import { supervisorConsentHelpContent } from "./supervisor-consent";
|
||||
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
|
||||
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
|
||||
import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations";
|
||||
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
|
||||
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
|
||||
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
|
||||
@@ -151,6 +152,16 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/payments",
|
||||
content: supervisorPaymentsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-allocations",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/allocations",
|
||||
content: supervisorAllocationsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "staff-my-groups",
|
||||
matches: pathname => pathname === "/dashboard/staff/my-groups",
|
||||
content: staffMyGroupsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-offline-scans",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/offline-scans",
|
||||
|
||||
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
|
||||
Preview your report, adjust filters, and choose how you want the data to appear.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Download} title="Export or email" tone="amber">
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp.
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source (short links and utm_source links) and device. It counts only visitors who allowed cookies.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { BedDouble, Layers, Move, Wand2, Printer, Ticket, Users } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/allocations. */
|
||||
export const supervisorAllocationsHelpContent: HelpContent = {
|
||||
title: "Rooms & groups",
|
||||
subtitle: "Put attendees into rooms, cabins, small groups, teams or buses.",
|
||||
tabs: [
|
||||
{
|
||||
key: "page",
|
||||
label: "This page",
|
||||
icon: BedDouble,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Layers} title="Sets and groups" tone="brand">
|
||||
A set is one way of splitting people up, such as "Rooms", "Small groups" or "Buses". An event can have several sets, and each person can be in one group per set. Groups can have a capacity, be for males or females only, and have a leader (a staff member).
|
||||
</GuideItem>
|
||||
<GuideItem icon={Move} title="Drag and drop" tone="green">
|
||||
Drag people from "Not placed yet" into a group, between groups, or back again. A full group, or one for the other gender, won't take them. Allocation works per named person, so the event needs named attendees. Turn on "Ask each person's gender" if rooms are split by gender.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Wand2} title="Auto-allocate" tone="violet">
|
||||
Fills groups evenly with everyone not placed yet, respecting capacity and gender. "Keep bookings together" keeps people on the same booking (siblings) in one group where possible; "Split bookings" spreads them out (friends). People already placed stay where they are.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Printer} title="Lists" tone="amber">
|
||||
Print a list per group (with its leader) or one master list. On youth events you can include medical and emergency details from consent forms. Viewing those is recorded in the audit log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="On tickets" tone="rose">
|
||||
Tick "Show on tickets" to print the group on each person's ticket and show it in My Events. To message one group ("Bus 2 leaves at 07:30"), pick it under Room / group on Email or WhatsApp attendees.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Leaders" tone="gray">
|
||||
Group leaders see their people (with ages and parent contacts) under Staff → My groups. Medical details are only shown to supervisors.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Help content for /dashboard/staff/my-groups. */
|
||||
export const staffMyGroupsHelpContent: HelpContent = {
|
||||
title: "My groups",
|
||||
subtitle: "Rooms, small groups and buses you lead.",
|
||||
tabs: [
|
||||
{
|
||||
key: "page",
|
||||
label: "This page",
|
||||
icon: Users,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Users} title="Your people" tone="brand">
|
||||
Everyone a supervisor has placed in a group you lead, with their age and a contact number (theirs, a parent's, or the person who booked). Supervisors also see medical details here.
|
||||
</GuideItem>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock } from "lucide-react";
|
||||
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -52,6 +52,9 @@ export const supervisorEventsHelpContent: HelpContent = {
|
||||
<GuideItem icon={Paperclip} title="Attachments" tone="rose">
|
||||
Upload downloadable files that appear on the public event page.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MessageSquareHeart} title="Feedback" tone="violet">
|
||||
Send a feedback form automatically after the event: pick when (e.g. the morning after) and whether it goes to everyone who checked in or everyone registered. Questions can be star ratings, multiple choice, yes/no or text. Answers can be anonymous. Anyone who hasn't answered gets one reminder 3 days later. Results are in Reports → Feedback summary. Rating and multiple-choice questions can also be used on the attendee form.
|
||||
</GuideItem>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">When creating a new event, later steps unlock as you complete the ones before them; editing an existing event lets you jump to any step freely.</p>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const supervisorPaymentsHelpContent: HelpContent = {
|
||||
Refund a specific payment or a registration — pick the user, target, amount and method, and a reason.
|
||||
</GuideItem>
|
||||
<GuideItem icon={HandHeart} title="Donations" tone="rose">
|
||||
Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration.
|
||||
Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration. Money from "Sponsor a spot" is marked as a sponsorship. Tick "Only the sponsorship pool" to assign just those, and you'll see the sponsor's message. The Donations breakdown report shows each event's pool: received, assigned and remaining.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ScanLine} title="Reconcile" tone="green">
|
||||
Match unreconciled Yoco card transactions to a registration, log them as a donation, or ignore them.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* First-party registration-funnel tracking. Nothing is tracked until the visitor accepts
|
||||
* cookies (CookieConsent); then a random session id is kept in localStorage and the
|
||||
* event page ("view") and registration page ("start") report to our own API. No
|
||||
* third-party analytics, and no personal data — the server records only the stage, the
|
||||
* traffic source and a device type.
|
||||
*/
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
const CONSENT_KEY = "hope_events_cookie_consent";
|
||||
const SESSION_KEY = "hope_events_analytics_sid";
|
||||
export const CONSENT_EVENT = "hope-events-consent";
|
||||
|
||||
export type Consent = "accepted" | "declined" | null;
|
||||
|
||||
export function getConsent(): Consent {
|
||||
try {
|
||||
const v = localStorage.getItem(CONSENT_KEY);
|
||||
return v === "accepted" || v === "declined" ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setConsent(v: "accepted" | "declined") {
|
||||
try {
|
||||
localStorage.setItem(CONSENT_KEY, v);
|
||||
if (v === "declined") localStorage.removeItem(SESSION_KEY);
|
||||
} catch { /* storage unavailable */ }
|
||||
try { window.dispatchEvent(new Event(CONSENT_EVENT)); } catch { /* no window */ }
|
||||
}
|
||||
|
||||
/** The anonymous session id — only when cookies were accepted. */
|
||||
export function analyticsSessionId(): string | undefined {
|
||||
if (getConsent() !== "accepted") return undefined;
|
||||
try {
|
||||
let id = localStorage.getItem(SESSION_KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(SESSION_KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the visitor came from: ?utm_source=… or ?src=<short-link alias>. */
|
||||
function sourceFromUrl(): string | undefined {
|
||||
try {
|
||||
const p = new URLSearchParams(window.location.search);
|
||||
return p.get("utm_source") || p.get("src") || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reports a funnel step. Silently does nothing without consent or on any error. */
|
||||
export function trackFunnel(eventId: string, stage: "view" | "start") {
|
||||
const sessionId = analyticsSessionId();
|
||||
if (!sessionId || !eventId) return;
|
||||
const body = JSON.stringify({ sessionId, eventId, stage, source: sourceFromUrl() });
|
||||
try {
|
||||
fetch(`${API_BASE}/api/analytics/track`, { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true }).catch(() => {});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -30,6 +30,10 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
|
||||
### For staff on the day
|
||||
|
||||
- **QR code scanner** — check people in by scanning their ticket with any phone camera. A single ticket can cover a whole family and be checked in a few people at a time.
|
||||
- **See where people drop off** — a privacy-friendly funnel (no Google Analytics) shows how many people viewed an event, started registering and paid, and which links and short URLs brought them.
|
||||
- **Feedback after every event** — a short survey with star ratings goes out automatically the morning after, with one reminder. See response rates and compare this year's camp with last year's.
|
||||
- **Sponsor a spot** — let your congregation fund places for people who can't afford to come, with an optional "12 of 20 spots sponsored" progress bar. You decide who receives the help; sponsors never see who.
|
||||
- **Rooms & groups for camps** — drag campers into cabins, small groups or buses (or auto-fill by capacity and gender), print lists for leaders, and show the cabin on each ticket.
|
||||
- **Ticket transfer** — can't make it? Pass your ticket on to a friend; they get a fresh QR code and the old one stops working.
|
||||
- **Works without Wi-Fi** — lock a check-in point to a phone and keep scanning with no signal; scans sync automatically when the connection is back.
|
||||
- **Scan points** — set up separate check-in points (e.g. main hall, dinner) that only accept the right tickets.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user