Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 b3bfbe486c Bump to 1.24.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:46:25 +02:00
joshuaandClaude Opus 5.5 686616cc18 Require acceptance for ticket transfers; privacy and legal updates
Transfers now send the new holder a link (to the email/phone typed) and
only complete once they accept; they can decline and the sender can
cancel. No response reveals whether contact details belong to an
account, and attendee account links are no longer sent to payers.
Terms of Use and Privacy Policy are updated for all features since
1.12, with a full cookie policy and a "Cookie settings" control. The
/docs route list now covers every mounted route, and account
activation auto-login is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:46:15 +02:00
30 changed files with 703 additions and 97 deletions
+34
View File
@@ -7,6 +7,40 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.24.0] - 2026-10-19
### Changed
- Ticket transfers now need the new holder to accept.
- "Transfer" (in My Events, or At the door → Tickets) sends the person a link by email and/or WhatsApp, to the details typed.
- Nothing changes on the ticket until they accept on the new `/transfer/<link>` page. They can also decline.
- On accepting, the transfer completes as before: new QR code, ticket delivery, account activation link, parental consent on youth events.
- My Events shows "Waiting for … to accept" with a Cancel button.
- Offers expire after 7 days or when the event starts, and a new offer replaces an open one.
- Privacy for group tickets and transfers: there is no user search, and nothing reveals whether an email or phone number belongs to an account.
- Registration responses no longer include named attendees' account links.
- If an email and phone point at two different accounts, this is no longer reported to the person typing them. The email's account is used.
- Terms of Use and Privacy Policy updated for everything added since 1.12:
- booking for other people, ticket transfers, and youth-event consent with health information and how long it is kept;
- who can see what (door staff, group leaders, supervisors), and read-only admin support access;
- sponsorships, waitlists, discount codes and feedback;
- two-factor authentication and offline scanning.
- The Privacy Policy has a full **cookie policy**: every item the site stores in the browser (necessary, staff-only and optional), the optional visit statistics, and the third-party cookies from the Google Maps embed and Yoco checkout.
- A new "Cookie settings" link in the footer (and a button in the policy) lets visitors change their choice.
- The API docs page (`/docs`) now lists every mounted route. 45 were missing, including dashboard stats, backups, attachments, the audit log, ticket transfer links and WhatsApp admin.
### Fixed
- After activating an account, the visitor is now really signed in. The token was saved under a key the app never read, so the "auto-login" didn't work.
### Notes
- `npm install` succeeds in the root, `backend` and `frontend` with no missing packages.
- The remaining `npm audit` findings are all in development/build tools (Jest, nodemon, ESLint, Tailwind's build), and none affect the running site.
- `braces` and `sprintf-js` have no patched release.
- `postcss-selector-parser` 7 would break Tailwind 3.
- Requires the `add_transfer_requests` database migration.
## [1.23.0] - 2026-10-19
### Added
+6 -3
View File
@@ -306,9 +306,12 @@ logout controls. Disconnected sessions are recovered automatically.
- Registration detail: itemised bill (early-bird lines grouped), payments,
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
- **Ticket transfer** — hand a ticket to someone else before the event (name plus
email/phone). They get a new ticket and QR code, and an inactive account with an
activation link if they're new. The old code stops working, and scanning it says it
- **Ticket transfer** — offer a ticket to someone else before the event by typing
their name and email/phone (no user search, and nothing reveals whether they have an
account). They get a link to accept or decline; the ticket only moves once they
accept, and the sender can cancel until then (offers expire after 7 days or at the
event start). On accepting they get a new ticket and QR code, and an inactive account
with an activation link if they're new. The old code stops working, and scanning it says it
was transferred, both online and in offline-mode sync reports. One place can be split
off a ticket covering several people. Billing stays with the payer. It's not allowed
once a ticket has been scanned, and it can be turned off per event (supervisors can
+10 -1
View File
@@ -373,9 +373,18 @@ Related changes:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }` (date of birth, plus parent details for a minor, on youth events) |
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one |
| DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer |
| GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` |
| POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) |
| POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline |
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
Privacy:
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
- Offers expire after 7 days or when the event starts.
- Only an HMAC of the link token is stored.
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
- `Event.allowTicketTransfer` turns it off for non-supervisors.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.23.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.23.0",
"version": "1.24.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.23.0",
"version": "1.24.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "TicketTransferRequest" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"toName" TEXT NOT NULL,
"toEmail" TEXT,
"toPhone" TEXT,
"details" JSONB,
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" TIMESTAMP(3) NOT NULL,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransferRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TicketTransferRequest_tokenHash_key" ON "TicketTransferRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "TicketTransferRequest_ticketId_status_idx" ON "TicketTransferRequest"("ticketId", "status");
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+24
View File
@@ -112,6 +112,7 @@ model User {
feedbackInvites FeedbackInvite[]
feedbackGiven FeedbackResponse[]
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
transferRequests TicketTransferRequest[] @relation("TransferRequester")
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
@@ -389,6 +390,7 @@ model Ticket {
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
transfers TicketTransfer[]
transferRequests TicketTransferRequest[]
@@index([holderUserId])
@@index([eventId])
@@ -1047,6 +1049,28 @@ model AllocationAssignment {
@@index([attendeeId])
}
/// A transfer waiting for the new holder to accept it from the link sent to the email /
/// phone the sender typed. Nothing changes on the ticket until it's accepted. `details`
/// holds what the sender entered (date of birth, gender, parent details on youth events).
model TicketTransferRequest {
id String @id @default(uuid())
ticketId String
requestedById String
toName String
toEmail String?
toPhone String?
details Json?
tokenHash String @unique
status String @default("pending") // pending | accepted | declined | cancelled | expired
expiresAt DateTime
respondedAt DateTime?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
requestedBy User @relation("TransferRequester", fields: [requestedById], references: [id], onDelete: Cascade)
@@index([ticketId, status])
}
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
/// when one place was split off a shared group ticket, whose code stays valid).
model TicketTransfer {
@@ -2254,7 +2254,7 @@ const updateAttendees = async (req, res) => {
where: { id: registration.id },
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
});
res.json(updated);
res.json({ ...updated, attendees: updated.attendees.map(({ userId, ...a }) => a) });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
+76 -7
View File
@@ -103,6 +103,7 @@ const getUserTickets = async (req, res) => {
},
user: { select: { id: true, name: true } },
event: true,
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
scannedBy: {
@@ -703,20 +704,84 @@ const sendTicketsTo = async (req, res) => {
}
};
// @desc Transfer a ticket to someone else (new QR code; billing stays with the payer)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { transferTicket } = require('../utils/ticketTransfer');
const { requestTransfer } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
res.status(201).json({
message: sent.length
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
requestId: request.id,
expiresAt: request.expiresAt,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Withdraw a transfer that hasn't been accepted yet
// @route DELETE /api/tickets/:id/transfer
// @access Private (payer or holder; supervisors)
const cancelTransferHandler = async (req, res) => {
try {
const { cancelTransfer } = require('../utils/ticketTransfer');
await cancelTransfer(req.params.id, req.user);
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc What's on offer behind a transfer link
// @route GET /api/tickets/transfer-offer/:token
// @access Public (link holder)
const getTransferOffer = async (req, res) => {
try {
const { findRequestByToken } = require('../utils/ticketTransfer');
const r = await findRequestByToken(req.params.token);
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
const t = r.ticket;
res.json({
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
event: t.event,
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
});
} catch (error) {
res.status(400).json({ message: safeErrorMessage(error) });
}
};
// @route POST /api/tickets/transfer-offer/:token/decline
const declineTransferOffer = async (req, res) => {
try {
const { declineTransfer } = require('../utils/ticketTransfer');
await declineTransfer(req.params.token);
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc Accept a transferred ticket — the transfer happens now
// @route POST /api/tickets/transfer-offer/:token/accept
// @access Public (link holder)
const acceptTransferOffer = async (req, res) => {
try {
const { acceptTransfer } = require('../utils/ticketTransfer');
const r = await acceptTransfer(req.params.token);
const actor = r.request.requestedBy;
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode },
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
ip: getClientIp(req),
});
@@ -744,7 +809,7 @@ const transferTicketHandler = async (req, res) => {
}
res.json({
message: `Ticket transferred to ${a.name}. We've sent them the new ticket${r.needsConsent ? ' and a parental consent request' : ''}. The old QR code no longer works.`,
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
ticketId: r.target.id,
});
} catch (error) {
@@ -980,5 +1045,9 @@ module.exports = {
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
};
+49
View File
@@ -392,6 +392,7 @@ app.get('/docs', async (req, res) => {
// ── Endpoint data ────────────────────────────────────────────────────────
const GROUPS = [
{ title: 'Users', base: '/api/users', endpoints: [
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
responses:[
@@ -515,6 +516,14 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Events', base: '/api/events', endpoints: [
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
queryParams:{ limit:'Max results (default 20)' },
responses:[
@@ -592,6 +601,12 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Registrations', base: '/api/registrations', endpoints: [
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
@@ -644,6 +659,7 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
@@ -688,6 +704,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
@@ -748,6 +768,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
responses:[
@@ -787,6 +811,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Tickets', base: '/api/tickets', endpoints: [
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
@@ -866,6 +894,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Reports', base: '/api/reports', endpoints: [
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
@@ -890,6 +920,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Cashups', base: '/api/cashups', endpoints: [
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
@@ -911,6 +943,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Uploads', base: '/api/uploads', endpoints: [
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
responses:[
@@ -967,6 +1001,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
@@ -987,6 +1025,17 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
]},
{ title: 'Dashboard stats', base: '/api/stats', endpoints: [
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
]},
{ title: 'Backups', base: '/api/backups', endpoints: [
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
+9
View File
@@ -15,6 +15,10 @@ const {
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
@@ -24,6 +28,11 @@ router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.delete('/:id/transfer', protect, cancelTransferHandler);
// Transfer links (public: the person the ticket is offered to)
router.get('/transfer-offer/:token', getTransferOffer);
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
router.post('/transfer-offer/:token/decline', declineTransferOffer);
router.get('/:id/transfers', protect, getTicketTransfers);
// Staff routes
+3 -5
View File
@@ -40,11 +40,9 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
*/
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
const match = await findAccountByContact({ email, phone }, db);
if (match.conflict) {
const err = new Error('That email address and phone number belong to two different accounts. Use just one of them.');
err.statusCode = 400;
throw err;
}
// Email and phone pointing at two different accounts: go with the email's account,
// without saying so — the person typing the details mustn't learn who has an account.
if (match.conflict) return { user: match.emailUser, created: false };
if (match.user) return { user: match.user, created: false };
const { v4: uuidv4 } = require('uuid');
const { hashPassword } = require('../config/auth');
+5
View File
@@ -346,6 +346,11 @@ function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
*/
function attachComputedTotals(registration) {
if (!registration) return registration;
// Never send which named attendees have an account: the person who typed their email or
// phone mustn't be able to tell (the link is used server-side only).
if (Array.isArray(registration.attendees)) {
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
}
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
+153 -2
View File
@@ -1,6 +1,12 @@
/**
* Ticket transfer — handing a ticket to someone else before the event.
*
* Privacy first: the sender only types the new holder's name and email/phone — there is
* no user search, and nothing tells the sender whether those details belong to an
* account. A TicketTransferRequest is created and a link is sent to the details typed;
* nothing changes on the ticket until the new holder accepts (requestTransfer →
* acceptTransfer). Accepting runs the transfer itself:
*
* - The new holder becomes the ticket's attendee (name, contact details, account). The
* payer stays responsible for the registration; billing doesn't move.
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
@@ -17,6 +23,13 @@ const { validateAttendees } = require('./attendees');
const { isMinor } = require('./consent');
const SUPERVISORS = ['admin', 'supervisor'];
const OFFER_DAYS = 7;
const crypto = require('crypto');
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`transfer:${token}`).digest('hex');
function transferLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/transfer/${token}`;
}
function transferError(message, statusCode = 400) {
const err = new Error(message);
@@ -71,7 +84,7 @@ async function transferTicket({ ticketId, actor, ...holder }) {
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
if (currentHolderId && currentHolderId === toUser.id) throw transferError(`${clean.name} already holds this ticket.`);
if (currentHolderId && currentHolderId === toUser.id) throw transferError('This ticket is already yours.');
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
const attendeeData = {
@@ -126,6 +139,141 @@ async function transferTicket({ ticketId, actor, ...holder }) {
};
}
/** Checks a transfer the way transferTicket will, without touching anything. */
async function checkTransfer({ ticketId, actor, ...holder }) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true, holder: { select: { name: true } }, user: { select: { name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { eventOption: { select: { name: true } }, registration: { select: { id: true, status: true } } } },
},
});
if (!ticket) throw transferError('Ticket not found', 404);
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
if (blocker) throw transferError(blocker.message, blocker.status);
const ro = ticket.registrationOption;
const [clean] = validateAttendees({ ...ticket.event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
}]);
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
return { ticket, clean };
}
async function sendTransferOffer(request, ticket, token, fromName) {
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 = transferLink(token);
const event = ticket.event;
const when = new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
const subject = `${fromName} wants to give you a ticket for ${event.title}`;
const sent = [];
if (request.toEmail) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">You've been offered a ticket</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.toName}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${fromName} would like to give you their ticket for <strong>${event.title}</strong> on <strong>${when}</strong>. Accept it to get your own ticket and QR code.</p>
${ctaButton('View and accept the ticket', link)}
${fallbackLink(link)}
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">If you weren't expecting this, you can ignore this email or decline from the link.</p>
`, { preheader: subject });
try {
await sendMail({ to: request.toEmail, subject, html, text: `Hi ${request.toName},\n\n${fromName} would like to give you their ticket for ${event.title} on ${when}. View and accept it here: ${link}` });
sent.push('email');
} catch (e) {
console.warn('[transfer] offer email failed:', e?.message || e);
}
}
if (request.toPhone && isValidZAPhone(request.toPhone)) {
await waTextAny({ name: request.toName, phoneNumber: request.toPhone }, `Hi ${request.toName}, ${fromName} would like to give you their ticket for *${event.title}* on ${when}. View and accept it here: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/**
* Starts a transfer: checks it, then sends the new holder a link to accept. Nothing on
* the ticket changes yet, and no account is looked up.
*/
async function requestTransfer({ ticketId, actor, ...holder }) {
const { ticket, clean } = await checkTransfer({ ticketId, actor, ...holder });
const token = crypto.randomBytes(24).toString('hex');
const expiresAt = new Date(Math.min(new Date(ticket.event.startDate).getTime(), Date.now() + OFFER_DAYS * 864e5));
const request = await prisma.$transaction(async (tx) => {
// One open offer per ticket: a new one replaces the old.
await tx.ticketTransferRequest.updateMany({ where: { ticketId: ticket.id, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
return tx.ticketTransferRequest.create({
data: {
ticketId: ticket.id, requestedById: actor.id, toName: clean.name, toEmail: clean.email, toPhone: clean.phone,
details: { dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone },
tokenHash: hashToken(token), expiresAt,
},
});
});
const fromName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || actor.name || 'Someone';
const sent = await sendTransferOffer(request, ticket, token, fromName);
return { request, sent };
}
async function findRequestByToken(token) {
if (!token || typeof token !== 'string' || token.length > 200) return null;
const request = await prisma.ticketTransferRequest.findUnique({
where: { tokenHash: hashToken(token) },
include: {
requestedBy: { select: { id: true, name: true, role: true } },
ticket: { include: { event: { select: { id: true, title: true, startDate: true, location: true } }, registrationOption: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } }, attendee: { select: { name: true } }, user: { select: { name: true } } } },
},
});
if (request && request.status === 'pending' && request.expiresAt <= new Date()) {
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'expired' } });
request.status = 'expired';
}
return request;
}
/** The new holder accepts: the transfer happens now, with every check run again. */
async function acceptTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
// Claim it first so a double-click can't run the transfer twice.
const claimed = await prisma.ticketTransferRequest.updateMany({ where: { id: request.id, status: 'pending' }, data: { status: 'accepted', respondedAt: new Date() } });
if (claimed.count !== 1) throw transferError('This transfer has already been answered.', 409);
try {
const d = request.details || {};
const result = await transferTicket({
ticketId: request.ticketId, actor: request.requestedBy,
name: request.toName, email: request.toEmail, phone: request.toPhone,
dateOfBirth: d.dateOfBirth, gender: d.gender, parentName: d.parentName, parentEmail: d.parentEmail, parentPhone: d.parentPhone,
});
return { ...result, request };
} catch (e) {
// Couldn't go ahead (e.g. the ticket was scanned meanwhile): the offer is void.
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'cancelled' } }).catch(() => {});
throw e;
}
}
async function declineTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'declined', respondedAt: new Date() } });
return request;
}
/** The sender (or a supervisor) withdraws an offer that hasn't been accepted yet. */
async function cancelTransfer(ticketId, actor) {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId }, select: { userId: true, holderUserId: true } });
if (!ticket) throw transferError('Ticket not found', 404);
if (!SUPERVISORS.includes(actor.role) && ticket.userId !== actor.id && ticket.holderUserId !== actor.id) throw transferError('Not authorized', 403);
const { count } = await prisma.ticketTransferRequest.updateMany({ where: { ticketId, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
if (!count) throw transferError('There is no transfer waiting for this ticket.', 404);
}
/** The transfer that retired this QR code, if any. */
async function findTransferByOldCode(qrCode) {
if (!qrCode) return null;
@@ -137,4 +285,7 @@ function transferredMessage(transfer) {
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
}
module.exports = { transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage };
module.exports = {
transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage,
checkTransfer, requestTransfer, findRequestByToken, acceptTransfer, declineTransfer, cancelTransfer,
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.23.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.23.0",
"version": "1.24.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.23.0",
"version": "1.24.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+6 -7
View File
@@ -3,13 +3,13 @@ import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { saveToken } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import { appName } from "@/lib/siteConfig";
import { ShieldCheck } from "lucide-react";
function ActivateAccountContent() {
const search = useSearchParams();
const router = useRouter();
const token = search.get("token") || "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
@@ -25,12 +25,11 @@ function ActivateAccountContent() {
setLoading(true);
setStatus(null);
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
// Auto-login: save token and redirect to dashboard
if (data?.token) {
localStorage.setItem("token", data.token);
}
// Auto-login: save the session token (under the key AuthContext reads) and load the
// dashboard fresh so the new session is picked up.
if (data?.token) saveToken(data.token);
setStatus("Your account is now active. Redirecting…");
setTimeout(() => router.push("/dashboard"), 1500);
setTimeout(() => { window.location.href = "/dashboard"; }, 1500);
} catch (err: any) {
setStatus(err?.message || "Activation failed. The link may have expired.");
} finally {
+12
View File
@@ -1011,6 +1011,18 @@ export default function UserDashboardPage() {
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
)}
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Print</button>
{(t.transferRequests || [])[0] && (
<span className="w-full text-xs text-violet-800 bg-violet-50 rounded px-2 py-1 flex items-center justify-between gap-2">
<span>Waiting for {t.transferRequests[0].toName} to accept</span>
<button className="underline" onClick={async () => {
try {
const r: any = await apiFetch(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
setInfo(r?.message || "Transfer cancelled");
reloadAfterAttendees();
} catch (e: any) { setError(e?.message || "Couldn't cancel"); }
}}>Cancel</button>
</span>
)}
{canOfferTransfer(t, t.event) && (
<button onClick={() => setTransferTarget(t)} className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-100 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" title="Give this ticket to someone else">Transfer</button>
)}
+89 -38
View File
@@ -4,23 +4,25 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
const sections = [
{ id: "summary", title: "Plain English Summary" },
{ id: "introduction",title: "1. Introduction" },
{ id: "information", title: "2. Information We Collect" },
{ id: "use", title: "3. How We Use Your Information" },
{ id: "whatsapp", title: "4. WhatsApp Communications" },
{ id: "storage", title: "5. Where We Store Your Data" },
{ id: "retention", title: "6. Data Retention" },
{ id: "children", title: "7. Children and Minors" },
{ id: "sharing", title: "8. Sharing of Information" },
{ id: "rights", title: "9. Your Rights (under POPIA)" },
{ id: "cookies", title: "10. Cookies and Local Storage" },
{ id: "security", title: "11. Security" },
{ id: "changes", title: "12. Changes to This Policy" },
{ id: "officer", title: "13. Information Officer" },
{ id: "contact", title: "14. Contact" },
{ id: "others", title: "3. People You Register or Transfer Tickets To" },
{ id: "use", title: "4. How We Use Your Information" },
{ id: "whatsapp", title: "5. WhatsApp Communications" },
{ id: "storage", title: "6. Where We Store Your Data" },
{ id: "retention", title: "7. Data Retention" },
{ id: "children", title: "8. Children, Parental Consent and Health Information" },
{ id: "sharing", title: "9. Who Can See Your Information" },
{ id: "rights", title: "10. Your Rights (under POPIA)" },
{ id: "cookies", title: "11. Cookie Policy (Cookies and Browser Storage)" },
{ id: "security", title: "12. Security" },
{ id: "changes", title: "13. Changes to This Policy" },
{ id: "officer", title: "14. Information Officer" },
{ id: "contact", title: "15. Contact" },
];
export default function PrivacyPolicyPage() {
@@ -89,15 +91,15 @@ export default function PrivacyPolicyPage() {
<h2 className="text-2xl font-semibold text-brand-600 mb-4">🟢 Plain English Summary</h2>
<p>We respect your privacy — here&apos;s what you need to know:</p>
<ul className="list-disc ml-6 space-y-1">
<li>We only collect info needed to register you for events and process payments.</li>
<li>We only collect info needed to run events: registrations, payments, tickets and check-in.</li>
<li>We store your info safely on South African servers.</li>
<li>We don&apos;t sell or share your data with anyone.</li>
<li>You can ask us what info we have about you or request deletion at any time.</li>
<li>Under 18s must register with a parent&apos;s permission.</li>
<li>We don&apos;t sell your data, and only share it with the services that process payments and deliver messages.</li>
<li>If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.</li>
<li>For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.</li>
<li>Payments go through Yoco; we never see your card details.</li>
<li>We may send you security alerts (e.g. new login notifications) via email and/or WhatsApp to keep your account safe.</li>
<li>If you opt in to WhatsApp notifications, we use your mobile number to send you event updates, tickets, and account alerts via WhatsApp (powered by WAWP).</li>
<li>We keep your data for up to 5 years after your last event registration.</li>
<li>We send tickets, updates and security alerts by email and/or WhatsApp (powered by WAWP).</li>
<li>Visit statistics are only collected if you click &quot;Allow&quot; on the cookie notice — no third-party analytics.</li>
<li>You can ask what we hold about you, or delete your account, at any time. We keep data for up to 5 years after your last event registration.</li>
</ul>
</section>
@@ -129,21 +131,36 @@ function Section({
],
information: [
"We collect the following information when you interact with our platform:",
"• Account Information: name, email address, phone number, and password (encrypted).",
"• Event Information: events you register for, ticket selections, and related preferences.",
"• Payment Information: processed by Yoco; we receive only payment status and reference numbers.",
"• Communication Data: emails or messages exchanged with our team.",
"• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.",
"• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.",
"• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.",
"• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).",
"• Payment information: processed by Yoco; we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
"• Communication data: emails or messages exchanged with our team.",
"• Visit statistics: only if you allow them on the cookie notice (see section 11).",
"We do not collect or store credit card numbers or bank details.",
"Administrator-created accounts (e.g. walk-in event registrations) may be created with a mobile number only. These accounts use a placeholder email address and can be activated by the account holder at any time.",
"Accounts may be created by an administrator, or when someone registers you or transfers a ticket to you, with a mobile number or email address only. You can activate such an account at any time.",
],
others: [
"You can register other people (for example family members) and give each one their own ticket, or pass your ticket on to someone else.",
"• You type their name and, optionally, their email address or phone number. There is no search or list of existing users to choose from, and we never tell you whether the details you enter belong to an account.",
"• We use those details only to send that person their ticket, their attendee form, a parental consent request (youth events), or — for a transfer — a link to accept the ticket. A transfer only happens once they accept it; they can also decline.",
"• If the email or phone number matches an existing account, the ticket also appears in that person's own account. Otherwise an account may be created for them, which they can activate if they wish.",
"• Some events ask for each person's date of birth or gender (for example, to allocate rooms at a camp).",
"By entering someone else's details, you confirm that you are allowed to share them with us for this purpose (for a child, that you are their parent or guardian or have their permission).",
],
use: [
"We use your information to:",
"• Process event registrations and payments.",
"• Process event registrations, payments, discounts and refunds.",
"• Communicate event updates, confirmations, or changes via email and/or WhatsApp (based on your preference).",
"• Send security notifications for new logins, password changes, and account actions on your account.",
"• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).",
"• Manage user accounts and event attendance records.",
"• Improve our website and event experience.",
"• Check tickets in at the door, including with scanners that keep working without an internet connection.",
"• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.",
"• Send you an optional feedback form after an event, with one reminder.",
"• Manage user accounts and event attendance records, and give support when you ask for help.",
"• Improve our events and website (using anonymous statistics, and only with your permission).",
"• Comply with legal obligations.",
],
whatsapp: [
@@ -165,19 +182,33 @@ function Section({
],
retention: [
"We retain your personal data for up to 5 years after your last event registration, or until you request deletion of your account — whichever comes first.",
"Medical and emergency-contact details from parental consent forms are deleted a set number of days after the event ends (30 days unless the organisation sets a different period). The record of who gave consent, when, and to which wording is kept.",
"Detailed visit statistics are combined into anonymous daily totals after 90 days.",
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
"Records of support access to accounts (see section 9) and other audit records are kept for accountability.",
"You can delete your account at any time from the Profile & Security page in your account. Deletion anonymises your personal information (name, email address, and phone number are replaced with placeholder values) while retaining event and payment records in anonymised form for financial record-keeping purposes.",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 13).",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 14).",
],
children: [
"Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.",
"We do not knowingly collect or process personal information of minors without such consent.",
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
"A parent or guardian may also say whether photos of the young person may be used.",
],
sharing: [
"We do not sell, rent, or share your personal information with any third parties.",
"The only exceptions are:",
"• Processing payments securely through Yoco (a South African payment processor).",
"• Delivering WhatsApp messages through WAWP (wawp.net), a WhatsApp Business API provider — only your mobile number and message content are shared, solely for delivery purposes.",
"• Where required by South African law.",
"We do not sell or rent your personal information. Outside our organisation, it is shared only with:",
"• Yoco (a South African payment processor), to process payments securely.",
"• WAWP (wawp.net), a WhatsApp Business API provider, to deliver WhatsApp messages — only your mobile number and the message content, solely for delivery.",
"• Our email delivery provider, to deliver emails.",
"• Anyone we are required to share it with under South African law.",
"Within our organisation, people see only what they need:",
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
"• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.",
"• Supervisors and administrators manage registrations, payments and communication for events.",
"• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.",
"• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.",
"• Nobody else can see whether you have an account.",
],
rights: [
"Under POPIA, you have the right to:",
@@ -188,16 +219,31 @@ function Section({
"To contact the Information Regulator:",
"• Website: www.inforeg.org.za",
"• Email: enquiries@inforeg.org.za",
"To exercise your rights directly with us, contact our Information Officer using the details in section 13 below.",
"To exercise your rights directly with us, contact our Information Officer using the details in section 14 below.",
],
cookies: [
"This website uses browser localStorage to store your login session token and user interface preferences (such as dismissed notifications). No third-party cookies, tracking pixels, or analytics tools are used.",
"If you choose \"Allow\" on the cookie notice, we also keep a random, anonymous visit identifier so we can count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link) and the type of device — never your name, IP address or browsing history. Detailed records are combined into daily totals after 90 days. If you choose \"No thanks\", nothing is counted.",
"You can clear your browser's local storage at any time through your browser settings, which will log you out of the site.",
"Our own site does not set tracking cookies. It keeps a few small items in your browser's storage (localStorage, and on staff scanners IndexedDB and an offline page cache). These work much like cookies, so this policy covers them. We do not use third-party analytics, advertising or tracking pixels.",
"Strictly necessary — always used, because the site can't work without them:",
"• hope_events_token — keeps you signed in.",
"• hope_events_user_cache — your last loaded profile, so pages keep working if your connection drops.",
"• hope_events_device — only if you tick \"Trust this device\" when entering a two-factor code, so you aren't asked for a code every time.",
"• hope_events_cookie_consent — remembers your choice on the cookie notice.",
"• banner_dismissed_… — remembers notices you have closed.",
"Staff and administrators only:",
"• hope_events_scan_device, hope_events_offline_lock and the \"hope-events-offline-scan\" database — on a staff member's scanner in offline mode: the device's id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.",
"• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.",
"Optional — only if you click \"Allow\" on the cookie notice:",
"• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose \"No thanks\", nothing is counted and no visit id is created.",
"Third parties:",
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
"• Payments open Yoco's secure checkout page, which uses its own cookies on Yoco's site.",
"You can change your cookie choice at any time with \"Cookie settings\" at the bottom of every page, or with the button above. You can also clear your browser's storage in your browser settings; this signs you out.",
],
security: [
"Your personal data is encrypted during transmission and stored using industry-standard security protocols.",
"Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.",
"Passwords are encrypted and cannot be viewed by anyone, including administrators.",
"Two-factor authentication is available to everyone and is required for supervisors and administrators.",
"Links we send for consent, feedback and ticket transfers are personal and time-limited. Only a secure fingerprint of each link is stored, not the link itself.",
"In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.",
],
changes: [
@@ -219,6 +265,11 @@ function Section({
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-brand-600 mb-3">{title}</h3>
{id === "cookies" && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
)}
{content[id]?.map((line, i) => {
// Render email addresses as links
const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
+54 -15
View File
@@ -12,14 +12,19 @@ const sections = [
{ id: "accounts", title: "3. User Accounts" },
{ id: "payments", title: "4. Payments & Refunds" },
{ id: "registrations", title: "5. Registrations & Tickets" },
{ id: "acceptable", title: "6. Acceptable Use" },
{ id: "communication", title: "7. Communication" },
{ id: "intellectual", title: "8. Intellectual Property" },
{ id: "liability", title: "9. Limitation of Liability" },
{ id: "forcemajeure", title: "10. Force Majeure" },
{ id: "termination", title: "11. Termination of Access" },
{ id: "law", title: "12. Governing Law" },
{ id: "contact", title: "13. Contact" },
{ id: "others", title: "6. Registering Other People" },
{ id: "youth", title: "7. Youth Events & Parental Consent" },
{ id: "transfers", title: "8. Ticket Transfers" },
{ id: "extras", title: "9. Discount Codes, Waitlists, Sponsorships & Feedback" },
{ id: "acceptable", title: "10. Acceptable Use" },
{ id: "communication", title: "11. Communication" },
{ id: "support", title: "12. Account Security & Support Access" },
{ id: "intellectual", title: "13. Intellectual Property" },
{ id: "liability", title: "14. Limitation of Liability" },
{ id: "forcemajeure", title: "15. Force Majeure" },
{ id: "termination", title: "16. Termination of Access" },
{ id: "law", title: "17. Governing Law" },
{ id: "contact", title: "18. Contact" },
];
export default function TermsOfUsePage() {
@@ -88,7 +93,9 @@ export default function TermsOfUsePage() {
<li>Respect that under 18s need parental permission to register.</li>
<li>Understand that all payments go through Yoco, and we don&apos;t store your card details.</li>
<li>Accept that we may contact you via email and/or WhatsApp (based on your preference) about events you register for, your tickets, and account security alerts.</li>
<li>Understand that registrations are personal to you and non-transferable.</li>
<li>Only enter other people&apos;s details (when booking for them or passing on a ticket) if you&apos;re allowed to.</li>
<li>Where an event allows it, you can pass your ticket to someone else before the event — it moves to them once they accept, with a new QR code.</li>
<li>For youth events, a parent or guardian must give consent before a young person can be checked in.</li>
<li>If we cancel an event, you&apos;ll get a full refund on request or your registration moved to the new date.</li>
</ul>
<p className="mt-2">If you disagree with these terms, please don&apos;t use the site.</p>
@@ -126,11 +133,11 @@ function Section({
],
accounts: [
"You must create an account to register for certain events or access restricted features.",
"You are responsible for keeping your password secure.",
"You are responsible for keeping your password secure, and for any two-factor codes sent to you.",
"Passwords are encrypted and not accessible to any staff member.",
"Users under the age of 18 must only register or create an account with the consent and supervision of a parent or guardian.",
"You agree to provide accurate, current, and complete information when creating your account.",
`Accounts may also be created on your behalf by an administrator (e.g. for walk-in event registration). In this case, you will receive a WhatsApp message with an activation link if your mobile number is provided.`,
`Accounts may also be created on your behalf — by an administrator (e.g. for walk-in event registration), or when someone registers you for an event or transfers a ticket to you. You will receive an activation link by email or WhatsApp, and can choose whether to activate the account.`,
],
payments: [
"Payments are processed securely through Yoco, a third-party payment processor.",
@@ -142,18 +149,46 @@ function Section({
`Refund Policy: Refunds are handled on a per-event basis. As a baseline, if you are unable to attend an event you have paid for, please contact us at ${orgEmail} at least 48 hours before the event. Refund requests at this stage are at the discretion of the event organiser. No refunds will be issued for no-shows. Any event-specific refund terms will be communicated at the time of registration and take precedence over this baseline.`,
],
registrations: [
`Registrations and tickets are personal to the registered individual and are non-transferable unless explicitly authorised by ${orgName} in writing.`,
"You may not sell, gift, or pass on your registration to another person without written authorisation.",
"Each ticket has a QR code that is checked at the event. Keep it private — anyone holding a copy of it may be able to use it.",
"A ticket can only be used for the number of places it covers. Once a ticket has been scanned it cannot be transferred.",
"Tickets may be passed on to another person only through the platform's transfer feature, where the event allows it (see section 8). You may not sell tickets for profit.",
"At some events, door staff may check tickets against a list downloaded to their device in case the internet connection fails. If a ticket was bought or changed after that list was downloaded, you may be asked to go to the door desk.",
"You may cancel your own registration at any time before any payment has been made. Once a payment has been recorded against your registration, cancellation must be requested through the organisation. Please contact us to arrange this.",
`If ${orgName} cancels or significantly postpones an event, affected registrants will be notified by email. In this case, a full refund will be issued upon request, or your registration may be transferred to the rescheduled or replacement event — whichever you prefer. Refund requests for organiser-initiated cancellations must be submitted to ${orgEmail} within 30 days of the cancellation notice.`,
],
others: [
"When you register other people (for example family members) or give each person their own ticket, you confirm that:",
"• the details you enter are accurate, and you are allowed to share them with us for this purpose; and",
"• for anyone under 18, you are their parent or guardian or have their parent's or guardian's permission.",
"We will use the email address or phone number you enter to send that person their ticket, attendee form, or other event messages. There is no searching for other users — you must type their details yourself.",
"The person who registers and pays remains responsible for the registration and its payment.",
],
youth: [
"Some events are marked as youth events. Each attendee's date of birth is required, and for anyone under 18 a parent or guardian must give consent through the link we send them before the young person can be checked in.",
"The parent or guardian is responsible for the accuracy of any medical and emergency information they provide, and for telling the organisers about changes.",
"A supervisor may in exceptional cases check someone in without recorded consent; such decisions are logged.",
],
transfers: [
"Where an event allows it, the person who booked a ticket or the person currently holding it can offer the ticket to someone else before the event starts, provided the ticket has not been scanned. Supervisors may transfer tickets even where an event does not otherwise allow it.",
"You enter the new holder's name and email address and/or phone number. We send them a link to accept the ticket. Nothing changes until they accept; they may also decline, and you may cancel the offer until then. Offers expire after 7 days or when the event starts.",
"When the transfer is accepted, the new holder receives their own ticket with a new QR code, and the old QR code (including any copies or screenshots of it) stops working.",
"The person who booked stays responsible for the registration and its payment; a transfer does not move or refund any payment.",
"On youth events, the new holder needs their own parental consent, and any attendee form answers from the previous holder are removed.",
],
extras: [
"Discount codes: codes are issued at the organisers' discretion, may be limited to certain events, people, dates or numbers of uses, and may not be sold or shared where they are restricted. We may cancel a discount obtained through misuse.",
"Waitlists: joining a waitlist does not guarantee a place. If a place becomes available, it is offered to the next person for a limited time; if the offer is not claimed in time, it passes to the next person.",
"Sponsor a spot: a sponsorship is a donation towards places for people who couldn't otherwise attend. The organisers decide how sponsorship money is allocated, and sponsors are not told who benefits. Refunds of sponsorships are at the organisers' discretion.",
"Feedback: post-event feedback forms are optional. Please don't include other people's personal information in your answers.",
],
acceptable: [
"You agree not to:",
"• Use the website for any unlawful or fraudulent purpose.",
"• Impersonate another person or misrepresent your identity.",
"• Interfere with or disrupt the operation of the site.",
"• Attempt to gain unauthorised access to our systems.",
"• Resell, tout, or transfer your registration or tickets without written authorisation.",
"• Resell or tout tickets, or transfer them other than through the platform's transfer feature.",
"• Enter another person's details without being allowed to, or use the registration, transfer or messaging features to contact people who have not agreed to it.",
"• Use automated tools, bots, or scrapers to access or extract data from the site.",
],
communication: [
@@ -163,6 +198,10 @@ function Section({
"For accounts without a valid email address (e.g. walk-in or at-the-door registrations), these required communications will be delivered via WhatsApp if a mobile number is on file.",
"WhatsApp messages are delivered via WAWP, a third-party WhatsApp Business API provider. By enabling WhatsApp notifications, you consent to your mobile number being used to send you messages via this service.",
],
support: [
"Two-factor authentication is required for supervisors and administrators, and recommended for everyone.",
"To help solve a problem with your account (for example, a ticket not showing), an administrator may view the site as you see it, for up to 30 minutes. This support access is strictly read-only: nothing can be changed, registered, paid, cancelled or sent on your behalf. A reason must be given, and every session is recorded in an audit log. The organisation may choose to notify you by email whenever this happens.",
],
intellectual: [
`All content on the platform, including text, graphics, and logos, is owned by ${orgName} unless otherwise stated.`,
"You may not copy, modify, or distribute any content without written permission.",
@@ -173,7 +212,7 @@ function Section({
"We are not liable for any loss or damages arising from:",
"• Technical failures; or",
"• Misuse of the platform.",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 10 (Force Majeure).",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 15 (Force Majeure).",
],
forcemajeure: [
`${orgName} shall not be liable for any failure or delay in performing its obligations where such failure or delay results from causes beyond its reasonable control, including but not limited to: acts of God, natural disasters, government restrictions or regulations, public health emergencies, venue closures, or any other event outside our reasonable control ('force majeure event').`,
@@ -0,0 +1,99 @@
"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 { Ticket, CheckCircle2, XCircle } from "lucide-react";
type Offer = {
status: "pending" | "accepted" | "declined" | "cancelled" | "expired";
toName: string;
fromName: string;
expiresAt: string;
option: string;
event: { id: string; title: string; startDate: string; location: string | null };
};
const STATUS_TEXT: Record<string, string> = {
accepted: "This ticket has already been accepted.",
declined: "This ticket offer was declined.",
cancelled: "This ticket offer was withdrawn.",
expired: "This ticket offer has expired.",
};
/** Public page the person a ticket is offered to opens from their email or WhatsApp. */
export default function TransferOfferPage() {
const params = useParams<{ token: string }>();
const token = params?.token;
const [offer, setOffer] = useState<Offer | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<{ ok: boolean; message: string } | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<Offer>(`/api/tickets/transfer-offer/${encodeURIComponent(token)}`)
.then(setOffer)
.catch(e => setLoadError(e?.message || "This transfer link isn't valid."));
}, [token]);
const answer = async (action: "accept" | "decline") => {
setBusy(true);
setError(null);
try {
const r = await apiFetch<{ message: string }>(`/api/tickets/transfer-offer/${encodeURIComponent(token!)}/${action}`, { method: "POST" });
setDone({ ok: action === "accept", message: r.message });
} catch (e: any) {
setError(e?.message || "Something went wrong");
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navbar />
<main className="flex-1 max-w-xl 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">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">A ticket for you</h1>
</div>
{loadError && <div className="p-4 border rounded-xl bg-white text-sm text-gray-700">{loadError}</div>}
{done && (
<div className="p-5 border rounded-xl bg-white shadow-sm flex items-start gap-3">
{done.ok ? <CheckCircle2 className="w-6 h-6 text-green-600 shrink-0" /> : <XCircle className="w-6 h-6 text-gray-500 shrink-0" />}
<p className="text-sm text-gray-700">{done.message}</p>
</div>
)}
{offer && !done && (
<div className="p-5 border rounded-xl bg-white shadow-sm space-y-3">
<p className="text-sm text-gray-700">Hi {offer.toName.split(" ")[0]}, {offer.fromName || "someone"} would like to give you their ticket.</p>
<div className="rounded-lg bg-gray-50 p-3 text-sm">
<div className="font-semibold">{offer.event.title}</div>
<div className="text-gray-600">{formatDate(new Date(offer.event.startDate))}{offer.event.location ? ` · ${offer.event.location}` : ""}</div>
<div className="text-gray-600">{offer.option}</div>
</div>
{offer.status === "pending" ? (
<>
<p className="text-xs text-gray-500">Accepting sends you your own ticket and QR code. If you don&apos;t have an account with us yet, we&apos;ll create one and send you a link to activate it. This offer is open until {new Date(offer.expiresAt).toLocaleString()}.</p>
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<div className="flex gap-2">
<button onClick={() => answer("accept")} disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">{busy ? "Please wait…" : "Accept ticket"}</button>
<button onClick={() => answer("decline")} disabled={busy} className="px-4 py-2 rounded-lg border hover:bg-gray-50 disabled:opacity-50">Decline</button>
</div>
</>
) : (
<p className="text-sm text-gray-600">{STATUS_TEXT[offer.status]}</p>
)}
</div>
)}
</main>
<Footer />
</div>
);
}
@@ -3,6 +3,7 @@
import Link from "next/link";
import { appName } from "@/lib/siteConfig";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
export const Footer = () => {
const { settings } = useSiteSettings();
@@ -19,6 +20,7 @@ export const Footer = () => {
<Link href="/legal/privacy" className="hover:underline text-gray-300">
Privacy Policy
</Link>
<CookieSettingsButton className="hover:underline text-gray-300" />
</div>
</footer>
{/* Spacer so content isn't hidden behind the fixed mobile bottom nav */}
@@ -0,0 +1,13 @@
"use client";
import React from "react";
import { resetConsent } from "@/lib/analytics";
/** Lets a visitor change their cookie choice: clears it so the notice asks again. */
export function CookieSettingsButton({ className = "", children = "Cookie settings" }: { className?: string; children?: React.ReactNode }) {
return (
<button type="button" className={className} onClick={() => { resetConsent(); window.location.reload(); }}>
{children}
</button>
);
}
@@ -14,8 +14,9 @@ export function canOfferTransfer(ticket: any, event: any, isSupervisor = false):
}
/**
* Hand a ticket to someone else: their name and email/phone (plus date of birth and a
* parent's details on youth events). They get a new QR code; the old one stops working.
* Offer a ticket to someone else: their name and email/phone (plus date of birth and a
* parent's details on youth events). There's deliberately no user search — we send a link
* to the details typed, and the ticket only moves (with a new QR code) once they accept.
*/
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
ticket: any;
@@ -57,9 +58,10 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
<h3 className="text-lg font-semibold">Transfer ticket</h3>
</div>
<p className="text-sm text-gray-600">
We&apos;ll send them a link to accept the ticket. Nothing changes until they accept — then{" "}
{places > 1
? `This ticket is for ${places} people. One place moves to the new holder on their own ticket; the other ${places - 1} stay on this one.`
: "The new holder gets their own ticket with a new QR code. The current QR code (including any screenshots of it) will stop working."}
? `one of this ticket's ${places} places moves to them on their own ticket; the other ${places - 1} stay on this one.`
: "they get their own ticket with a new QR code, and the current QR code (including any screenshots of it) stops working."}
{" "}The booking and payment stay with the person who booked.
</p>
<div>
@@ -86,7 +88,7 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
</select>
</div>
)}
<p className="text-xs text-gray-500">An email or phone number is needed to send them the ticket. If they don&apos;t have an account yet, one is created and they get a link to activate it.</p>
<p className="text-xs text-gray-500">Enter the email and/or phone number to send the link to — we don&apos;t look people up. The link works for 7 days (or until the event starts).</p>
{youth && (
<div className="space-y-2 border-t pt-3">
<div>
@@ -109,7 +111,7 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={busy || !f.name.trim() || (!f.email.trim() && !f.phone.trim())} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">
{busy ? "Transferring…" : "Transfer"}
{busy ? "Sending…" : "Send transfer link"}
</button>
</div>
</form>
+1 -1
View File
@@ -61,7 +61,7 @@ export const dashboardUserHelpContent: HelpContent = {
Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message.
</GuideItem>
<GuideItem icon={ArrowRightLeft} title="Transfer a ticket" tone="violet">
Can&apos;t make it? Tap &quot;Transfer&quot; to give your ticket to someone else before the event. Enter their name and email or phone, and they get a new ticket with a new QR code. The old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can&apos;t be transferred once it has been scanned.
Can&apos;t make it? Tap &quot;Transfer&quot; to offer your ticket to someone else before the event. Type their name and email and/or phone number — there&apos;s no searching for people — and we send them a link to accept it. Until they accept, the ticket is still yours (you&apos;ll see &quot;Waiting for … to accept&quot;, and can cancel). Once they accept, they get their own ticket with a new QR code, and the old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can&apos;t be transferred once it has been scanned.
</GuideItem>
</div>
</div>
@@ -25,7 +25,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
Per ticket-type totals with a stepper to check attendees in against the scan record.
</GuideItem>
<GuideItem icon={Ticket} title="Tickets" tone="amber">
Browse all tickets for the event; print or send (email/WhatsApp) any of them individually, or transfer one to someone else before the event starts (supervisors can transfer even when transfers are turned off for the event). Scanning a transferred ticket&apos;s old QR code says who it went to.
Browse all tickets for the event; print or send (email/WhatsApp) any of them individually, or offer one to someone else before the event starts — they get a link and the ticket moves when they accept (supervisors can transfer even when transfers are turned off for the event). Scanning a transferred ticket&apos;s old QR code says who it went to.
</GuideItem>
<GuideItem icon={RotateCcw} title="Refund" tone="rose">
Issue a refund against an existing payment for the selected registration.
+8
View File
@@ -32,6 +32,14 @@ export function setConsent(v: "accepted" | "declined") {
try { window.dispatchEvent(new Event(CONSENT_EVENT)); } catch { /* no window */ }
}
/** Forgets the visitor's choice (and any visit id) so the cookie notice asks again. */
export function resetConsent() {
try {
localStorage.removeItem(CONSENT_KEY);
localStorage.removeItem(SESSION_KEY);
} catch { /* storage unavailable */ }
}
/** The anonymous session id — only when cookies were accepted. */
export function analyticsSessionId(): string | undefined {
if (getConsent() !== "accepted") return undefined;
+1 -1
View File
@@ -34,7 +34,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
- **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.
- **Ticket transfer** — can't make it? Send your ticket to a friend's email or phone; once they accept, they get a fresh QR code and the old one stops working.
- **Works without Wi-Fi** — lock a check-in point to a phone and keep scanning with no signal; scans sync automatically when the connection is back.
- **Scan points** — set up separate check-in points (e.g. main hall, dinner) that only accept the right tickets.
- **At-the-door desk** — search by name, phone, or ticket number; register walk-ins; take cash, card, or EFT payments; check people in; and print or resend tickets on the spot.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.23.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.23.0",
"version": "1.24.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.23.0",
"version": "1.24.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",