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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:28:29 +02:00
co-authored by Claude Opus 5.5
parent 1b92fae76c
commit afcc14cbb3
24 changed files with 1247 additions and 33 deletions
+19
View File
@@ -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
+10 -1
View File
@@ -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/<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.
@@ -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 |
+17
View File
@@ -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 |
@@ -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;
+79
View File
@@ -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())
@@ -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 };
+34
View File
@@ -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 {
+14
View File
@@ -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;
+230
View File
@@ -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,
};
+68
View File
@@ -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' }]);
});
});
@@ -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;
@@ -879,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
@@ -921,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;
@@ -972,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
@@ -1127,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 || [];
@@ -1357,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&apos;t linked to people&apos;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&apos;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">
@@ -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>
@@ -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>
+130
View File
@@ -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>
);
}
+5 -2
View File
@@ -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>
@@ -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>
+4 -1
View File
@@ -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";
@@ -1192,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">
@@ -16,6 +16,7 @@ import {
TicketPercent,
ShieldCheck,
HeartPulse,
MessageSquareHeart,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
@@ -37,6 +38,7 @@ 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: "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<{
+111 -1
View File
@@ -5,7 +5,7 @@ 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 } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
@@ -177,6 +177,8 @@ 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[]>([]);
// 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 +288,15 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
return byEv;
};
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 +363,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 +443,8 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
setDiscountRows(await loadDiscounts(selectedEventIds));
} else if (report === "consentStatus") {
setConsentRows(await loadConsent(selectedEventIds, "status"));
} else if (report === "feedbackSummary") {
setFeedbackRows(await loadFeedback(selectedEventIds));
} else if (report === "medicalList") {
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
}
@@ -1004,6 +1040,25 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
},
};
}
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 {
@@ -1672,6 +1727,61 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
)}
</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&apos;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 || [];
@@ -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))}
/>
);
}
+1 -1
View File
@@ -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).
</GuideItem>
</div>
</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&apos;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>
+1
View File
@@ -30,6 +30,7 @@ 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.
- **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.