From afcc14cbb3c0c3fc36a35c1f14b09686ac67b42d Mon Sep 17 00:00:00 2001 From: joshua Date: Mon, 19 Oct 2026 15:28:29 +0200 Subject: [PATCH] Add post-event feedback forms A Feedback step in the event wizard builds a survey that is sent at a chosen time to everyone who checked in (or everyone registered), with personal links and one reminder after 3 days. Answers can be anonymous. Adds rating (1-5 stars) and multiple-choice field types, also usable on attendee forms, and a Feedback summary report with response rate, average ratings, choice breakdowns, comments and event comparison. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 ++ FEATURES.md | 11 +- backend/API_DOCUMENTATION.md | 17 ++ .../migration.sql | 122 ++++++++++ backend/prisma/schema.prisma | 79 ++++++ backend/src/controllers/feedbackController.js | 179 ++++++++++++++ backend/src/index.js | 34 +++ backend/src/routes/feedbackRoutes.js | 14 ++ backend/src/utils/feedback.js | 230 ++++++++++++++++++ backend/tests/feedback.test.js | 68 ++++++ .../app/dashboard/supervisor/events/page.tsx | 134 ++++++++-- .../app/dashboard/supervisor/forms/page.tsx | 17 +- .../supervisor/manual-registration/page.tsx | 7 +- .../src/app/dashboard/user/forms/page.tsx | 7 +- frontend/src/app/feedback/[token]/page.tsx | 130 ++++++++++ frontend/src/app/forms/page.tsx | 7 +- .../src/app/registration/success/page.tsx | 7 +- frontend/src/app/self-service/page.tsx | 5 +- .../src/components/reports/ReportCatalog.ts | 2 + frontend/src/components/reports/ReportsV2.tsx | 112 ++++++++- .../src/components/shared/FormFieldExtras.tsx | 71 ++++++ frontend/src/content/help/reports.tsx | 2 +- .../src/content/help/supervisor-events.tsx | 5 +- info.md | 1 + 24 files changed, 1247 insertions(+), 33 deletions(-) create mode 100644 backend/prisma/migrations/20261019150657_add_feedback_forms/migration.sql create mode 100644 backend/src/controllers/feedbackController.js create mode 100644 backend/src/routes/feedbackRoutes.js create mode 100644 backend/src/utils/feedback.js create mode 100644 backend/tests/feedback.test.js create mode 100644 frontend/src/app/feedback/[token]/page.tsx create mode 100644 frontend/src/components/shared/FormFieldExtras.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3a77e..e08cf7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 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 diff --git a/FEATURES.md b/FEATURES.md index e37607d..77a3a92 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -171,6 +171,14 @@ 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. +- **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/`). 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. @@ -396,7 +404,7 @@ 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 | 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, plus the sponsorship pool) | | Cashup | Cashup reconciliation · Cashup audit trail | @@ -469,6 +477,7 @@ 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) | +| 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 | diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 691a49d..7a0d7a3 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -25,6 +25,7 @@ Tokens are issued by `POST /api/users/login`. - [Ticket transfer](#ticket-transfer) - [Rooms & groups](#rooms--groups) - [Sponsor a spot](#sponsor-a-spot) +- [Post-event feedback](#post-event-feedback) - [Payments](#payments) - [Tickets](#tickets) - [Broadcasts (Email)](#broadcasts-email) @@ -282,6 +283,22 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`: --- +## 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 | diff --git a/backend/prisma/migrations/20261019150657_add_feedback_forms/migration.sql b/backend/prisma/migrations/20261019150657_add_feedback_forms/migration.sql new file mode 100644 index 0000000..1701027 --- /dev/null +++ b/backend/prisma/migrations/20261019150657_add_feedback_forms/migration.sql @@ -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; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2100d58..ac4ff54 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -109,6 +109,8 @@ model User { transfersTo TicketTransfer[] @relation("TransferRecipient") transfersBy TicketTransfer[] @relation("TransferActor") ledGroups AllocationGroup[] @relation("AllocationGroupLeader") + feedbackInvites FeedbackInvite[] + feedbackGiven FeedbackResponse[] scanLocksForced ScanLock[] @relation("ScanLockForcedBy") syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer") @@ -150,6 +152,7 @@ model Event { allowSponsorships Boolean @default(false) sponsorshipGoalSpots Int? showSponsorshipProgress Boolean @default(true) + feedbackForm FeedbackForm? allocationSets AllocationSet[] waitlistOfferHours Int @default(24) waitlistPartialOffers Boolean @default(true) @@ -538,6 +541,8 @@ enum FormFieldType { numeric statement paragraph + rating + multiple_choice } model EventForm { @@ -846,6 +851,80 @@ 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. +/// 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()) diff --git a/backend/src/controllers/feedbackController.js b/backend/src/controllers/feedbackController.js new file mode 100644 index 0000000..bcf0107 --- /dev/null +++ b/backend/src/controllers/feedbackController.js @@ -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 }; diff --git a/backend/src/index.js b/backend/src/index.js index 78f2525..7d18f86 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -181,6 +181,7 @@ 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/backups', backupRoutes); // Pre-warm the settings cache so synchronous helpers have DB values from startup @@ -637,6 +638,16 @@ app.get('/docs', async (req, res) => { ]}, ]}, + { 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 }}]}, @@ -1388,6 +1399,29 @@ app.listen(PORT, () => { console.warn('[waitlist] 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 { diff --git a/backend/src/routes/feedbackRoutes.js b/backend/src/routes/feedbackRoutes.js new file mode 100644 index 0000000..773edff --- /dev/null +++ b/backend/src/routes/feedbackRoutes.js @@ -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; diff --git a/backend/src/utils/feedback.js b/backend/src/utils/feedback.js new file mode 100644 index 0000000..62f8831 --- /dev/null +++ b/backend/src/utils/feedback.js @@ -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(` +

${reminder ? 'A quick reminder — ' : ''}How was ${event.title}?

+

Hi ${user.name || 'there'},

+

Thank you for coming to ${event.title}. We'd love to hear what you thought — it only takes a minute.

+ ${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, +}; diff --git a/backend/tests/feedback.test.js b/backend/tests/feedback.test.js new file mode 100644 index 0000000..57dbecd --- /dev/null +++ b/backend/tests/feedback.test.js @@ -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' }]); + }); +}); diff --git a/frontend/src/app/dashboard/supervisor/events/page.tsx b/frontend/src/app/dashboard/supervisor/events/page.tsx index 11ff076..41518e4 100644 --- a/frontend/src/app/dashboard/supervisor/events/page.tsx +++ b/frontend/src/app/dashboard/supervisor/events/page.tsx @@ -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 (
-
Registration Form Fields
- +
{title}
+ {gateLabel && ( + + )}
{fields.length === 0 ?

No fields yet.

@@ -134,7 +145,8 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
{f.type !== "statement" && f.type !== "paragraph" && (
upd(idx, { label: e.target.value })} /> + {f.type === "multiple_choice" && upd(idx, { options: opts })} />} {f.type === "paragraph" ?