Compare commits

..
Author SHA1 Message Date
joshua e9588aeba8 Merge branch 'feat/ticket-transfer' 2026-10-19 14:23:34 +02:00
joshuaandClaude Opus 5.5 83df90b531 Bump to 1.18.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:23:31 +02:00
joshuaandClaude Opus 5.5 87ffe3b84b Add ticket transfer
Payers and holders can hand a ticket to someone else before the event.
The new holder gets a new QR code (and an inactive account with an
activation link if they're new); the old code stops working and scans
of it, online or in offline sync, say it was transferred. One place can
be split off a shared group ticket. Transfers can be turned off per
event (supervisors can always transfer from At the door), youth events
need the new holder's own consent, and every transfer is audited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:23:24 +02:00
joshua 9a4bf51ad4 Merge branch 'feat/offline-scanning' 2026-10-19 14:06:40 +02:00
27 changed files with 607 additions and 19 deletions
+18
View File
@@ -7,6 +7,24 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.18.0] - 2026-10-19
### Added
- Ticket transfer. On My Events, "Transfer" hands a ticket to someone else before the event: enter their name and email or phone.
- The new holder gets the ticket by email/WhatsApp with a new QR code. If they're new, an inactive account is created and they get an activation link.
- The old QR code stops working, so screenshots of it won't scan. Scanning it says who the ticket was transferred to, and offline-mode sync flags it.
- For a ticket covering several people, one place is split off to the new holder.
- The payer stays responsible for the registration.
- Transfer rules:
- Only before the event starts, and only for tickets that haven't been scanned.
- It can be turned off per event (new "Ticket transfer" setting in the event wizard, on by default). Supervisors can always transfer, from At the door → Tickets.
- On youth events, the new holder's date of birth, and a parent's details for a minor, are needed, and a new consent request is sent.
- The previous holder's attendee form answers and consent medical details are removed.
- Every transfer is recorded in the audit log.
- New endpoints `POST /api/tickets/:id/transfer` and `GET /api/tickets/:id/transfers`.
- Requires the `add_ticket_transfer` database migration.
## [1.17.0] - 2026-10-19
### Added
+9
View File
@@ -266,6 +266,15 @@ 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
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
always transfer, from At the door → Tickets). On youth events the new holder needs
their own parental consent, and the previous holder's form answers are removed.
Every transfer is in the audit log.
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
date, method and payment/refund.
- **Donate** page.
+22
View File
@@ -22,6 +22,7 @@ Tokens are issued by `POST /api/users/login`.
- [Parental consent](#parental-consent)
- [Waitlist](#waitlist)
- [Offline scanning](#offline-scanning)
- [Ticket transfer](#ticket-transfer)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -279,6 +280,27 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Ticket transfer
| 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) |
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
- `Event.allowTicketTransfer` turns it off for non-supervisors.
What a transfer does:
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
- An account is matched by email/phone, or created inactive with an activation link.
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
Old QR codes:
- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`.
- Offline sync reports them as `transferred`.
- Each transfer is audited as `ticket_transferred`.
## Offline scanning
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.17.0",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.17.0",
"version": "1.18.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.17.0",
"version": "1.18.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,36 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'ticket_transferred';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowTicketTransfer" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "TicketTransfer" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"fromHolderName" TEXT NOT NULL,
"fromUserId" TEXT,
"toName" TEXT NOT NULL,
"toUserId" TEXT,
"byUserId" TEXT,
"oldQrCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransfer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TicketTransfer_ticketId_idx" ON "TicketTransfer"("ticketId");
-- CreateIndex
CREATE INDEX "TicketTransfer_oldQrCode_idx" ON "TicketTransfer"("oldQrCode");
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_byUserId_fkey" FOREIGN KEY ("byUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+25
View File
@@ -106,6 +106,8 @@ model User {
heldTickets Ticket[] @relation("TicketHolder")
waitlistEntries WaitlistEntry[]
scanLocks ScanLock[] @relation("ScanLockOwner")
transfersTo TicketTransfer[] @relation("TransferRecipient")
transfersBy TicketTransfer[] @relation("TransferActor")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
@@ -141,6 +143,7 @@ model Event {
// them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's
// fewer places than asked for (otherwise skip to the next person who fits).
waitlistEnabled Boolean @default(false)
allowTicketTransfer Boolean @default(true)
waitlistOfferHours Int @default(24)
waitlistPartialOffers Boolean @default(true)
contactName String?
@@ -365,6 +368,7 @@ model Ticket {
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
transfers TicketTransfer[]
@@index([holderUserId])
@@index([eventId])
@@ -476,6 +480,7 @@ enum AdminAuditAction {
scan_lock_released
scan_lock_force_released
offline_sync
ticket_transferred
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -990,6 +995,26 @@ 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.
/// 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 {
id String @id @default(uuid())
ticketId String
fromHolderName String
fromUserId String?
toName String
toUserId String?
byUserId String?
oldQrCode String?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
toUser User? @relation("TransferRecipient", fields: [toUserId], references: [id], onDelete: SetNull)
byUser User? @relation("TransferActor", fields: [byUserId], references: [id], onDelete: SetNull)
@@index([ticketId])
@@index([oldQrCode])
}
model Attendee {
id String @id @default(uuid())
registrationId String
+4 -2
View File
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin
const createEvent = async (req, res) => {
try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
id: uuidv4(),
@@ -70,6 +70,7 @@ const createEvent = async (req, res) => {
consentCollectEmergency: consentCollectEmergency === true,
consentCollectPhoto: consentCollectPhoto === true,
waitlistEnabled: waitlistEnabled === true,
allowTicketTransfer: allowTicketTransfer !== false,
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
waitlistPartialOffers: waitlistPartialOffers !== false,
contactName: contactName || null,
@@ -536,7 +537,7 @@ const updateEvent = async (req, res) => {
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
title: title || event.title,
@@ -559,6 +560,7 @@ const updateEvent = async (req, res) => {
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
// Editing the consent text starts a new version; parents who already agreed keep
+79 -1
View File
@@ -287,6 +287,9 @@ const getScanPreview = async (req, res) => {
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
@@ -351,6 +354,9 @@ const scanTicket = async (req, res) => {
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
@@ -482,6 +488,7 @@ const getTicketsByEvent = async (req, res) => {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
},
orderBy: { createdAt: 'desc' },
@@ -691,6 +698,75 @@ 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? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { transferTicket } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone } = req.body || {};
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone });
const { 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 },
ip: getClientIp(req),
});
// Tell the new holder: activation link for a new account, their ticket, and
// anything they need to do themselves (consent, attendee form).
if (r.accountCreated) {
const { sendActivationLink } = require('./userController');
sendActivationLink(r.toUser);
}
const a = r.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' };
const silent = { status: () => silent, json: () => silent };
emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient)
.catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e));
if (r.needsConsent) {
const { createConsentRequest } = require('../utils/consent');
createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e));
}
const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } });
if (form && form.fields.length > 0) {
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } });
sendAttendeeFormLinkMessage(full).catch(() => {});
}
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.`,
ticketId: r.target.id,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Transfer history of a ticket
// @route GET /api/tickets/:id/transfers
// @access Private (payer or holder; staff)
const getTicketTransfers = async (req, res) => {
try {
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } });
if (!ticket) { res.status(404); throw new Error('Ticket not found'); }
const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role);
if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); }
const transfers = await prisma.ticketTransfer.findMany({
where: { ticketId: req.params.id },
select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
res.json(transfers);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// Internal helper used by both emailTickets and sendTicketsTo
async function emailTicketsInternal(req, res, userOverride) {
const { ticketIds, registrationId } = req.body;
@@ -888,5 +964,7 @@ module.exports = {
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats
getScanStats,
transferTicketHandler,
getTicketTransfers
};
+6
View File
@@ -635,6 +635,12 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
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.' }}]},
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
]},
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
+5 -1
View File
@@ -13,7 +13,9 @@ const {
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats
getScanStats,
transferTicketHandler,
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
@@ -21,6 +23,8 @@ const { protect, admin, supervisor, staff } = require('../middleware/authMiddlew
router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.get('/:id/transfers', protect, getTicketTransfers);
// Staff routes
router.post('/send-to', protect, staff, sendTicketsTo);
+32 -1
View File
@@ -33,4 +33,35 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) };
}
module.exports = { findAccountByContact, hasValidEmail };
/**
* The account for these contact details, or a new inactive one (same as guest
* registration: a placeholder email for phone-only people, a random password, and an
* activation link sent by the caller when `created` is true).
*/
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;
}
if (match.user) return { user: match.user, created: false };
const { v4: uuidv4 } = require('uuid');
const { hashPassword } = require('../config/auth');
const validEmail = hasValidEmail(email);
const user = await db.user.create({
data: {
id: uuidv4(),
name,
email: validEmail ? String(email).trim() : `guest+${uuidv4().slice(0, 8)}@guest.local`,
password: await hashPassword(uuidv4()),
phoneNumber: match.phone || null,
isActive: false,
notificationPreference: validEmail && match.phone ? 'both' : validEmail ? 'email' : 'whatsapp',
updatedAt: new Date(),
},
});
return { user, created: true };
}
module.exports = { findAccountByContact, findOrCreateAccount, hasValidEmail };
+7 -2
View File
@@ -52,6 +52,7 @@ function lockCovers(lock, { sectionId = null, optionId }) {
*/
function judgeSyncedScan(ticket, qty) {
if (!ticket) return { redeem: 0, conflict: 'not_found' };
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
@@ -61,7 +62,8 @@ function judgeSyncedScan(ticket, qty) {
}
const CONFLICT_TEXT = {
not_found: 'Ticket not found (possibly transferred or reissued)',
not_found: 'Ticket not found',
transferred: 'Old QR code of a ticket that was transferred to someone else',
cancelled: 'Registration was cancelled',
wrong_scope: 'Ticket is not valid for this section',
already_used: 'Ticket had already been fully used',
@@ -172,7 +174,10 @@ async function syncScans(lock, scans) {
include: { usages: true, registrationOption: { include: { registration: { select: { status: true } }, eventOption: { select: { isMainTicket: true } } } } },
});
let state = null;
if (ticket && ticket.eventId === lock.eventId) {
if (!ticket) {
const { findTransferByOldCode } = require('./ticketTransfer');
if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true };
} else if (ticket.eventId === lock.eventId) {
const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
state = {
+140
View File
@@ -0,0 +1,140 @@
/**
* Ticket transfer — handing a ticket to someone else before the event.
*
* - 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
* is kept on the TicketTransfer so scanners can say "transferred" instead of "not found".
* - A shared (unnamed) ticket for several places has one place split off into a new
* ticket for the new holder; the rest stay on the shared ticket and its code.
* - The previous holder's attendee form answers are removed, and on youth events the new
* holder needs their own parental consent.
*/
const { v4: uuidv4 } = require('uuid');
const prisma = require('../config/db');
const { validateAttendees } = require('./attendees');
const { isMinor } = require('./consent');
const SUPERVISORS = ['admin', 'supervisor'];
function transferError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/**
* Why this ticket can't be transferred by `actor`, or null when it can. Pure.
* @param {object} t - { userId, holderUserId, usages[], event: { startDate, allowTicketTransfer }, registrationStatus }
* @param {object} actor - { id, role }
*/
function transferBlocker(t, actor, now = new Date()) {
const supervisor = SUPERVISORS.includes(actor.role);
if (!supervisor && t.userId !== actor.id && t.holderUserId !== actor.id) return { status: 403, message: 'Only the person who booked or holds this ticket can transfer it.' };
if (t.registrationStatus === 'cancelled') return { status: 400, message: 'This registration was cancelled.' };
if (new Date(t.event.startDate) <= now) return { status: 400, message: 'Tickets can only be transferred before the event starts.' };
if ((t.usages || []).length > 0) return { status: 400, message: 'This ticket has already been used (or partly used), so it can’t be transferred.' };
if (t.event.allowTicketTransfer === false && !supervisor) return { status: 400, message: 'Ticket transfers are turned off for this event.' };
return null;
}
/**
* Transfers a ticket. Returns what the caller needs to notify people afterwards.
* @param {object} input - { ticketId, actor, name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
*/
async function transferTicket({ ticketId, actor, ...holder }) {
const { findOrCreateAccount } = require('./accountMatch');
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true,
holder: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { 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 event = ticket.event;
// Same rules as naming an attendee at registration (incl. date of birth and parent
// details on youth events), for exactly one place.
const [clean] = validateAttendees({ ...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.');
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.`);
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
const attendeeData = {
name: clean.name, email: clean.email, phone: clean.phone, userId: toUser.id,
dateOfBirth: clean.dateOfBirth, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone,
};
const result = await prisma.$transaction(async (tx) => {
// Re-check inside the transaction: a scan or another transfer may have just happened.
const fresh = await tx.ticket.findUnique({ where: { id: ticket.id }, include: { usages: { select: { id: true } } } });
if (!fresh || fresh.qrCode !== ticket.qrCode || fresh.usages.length > 0) throw transferError('This ticket changed while you were transferring it. Please try again.', 409);
let target, attendee, oldQrCode;
if (ticket.attendeeId) {
attendee = await tx.attendee.update({ where: { id: ticket.attendeeId }, data: attendeeData });
// The previous holder's answers and medical details aren't the new holder's.
await tx.formResponse.deleteMany({ where: { attendeeId: attendee.id } });
await tx.consentRequest.updateMany({ where: { attendeeId: attendee.id }, data: { medical: null, emergency: null, medicalPurgedAt: new Date() } });
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), holderUserId: toUser.id, emailSent: false } });
oldQrCode = ticket.qrCode;
} else {
const order = await tx.attendee.count({ where: { registrationId: ro.registration.id } });
attendee = await tx.attendee.create({
data: { registrationId: ro.registration.id, registrationOptionId: ro.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId, order, ...attendeeData },
});
if ((ticket.quantity || 1) > 1) {
// Split one place off the shared ticket; its code stays valid for the rest.
await tx.ticket.update({ where: { id: ticket.id }, data: { quantity: ticket.quantity - 1 } });
target = await tx.ticket.create({
data: { qrCode: uuidv4(), registrationOptionId: ro.id, userId: ticket.userId, eventId: ticket.eventId, quantity: 1, attendeeId: attendee.id, holderUserId: toUser.id },
});
oldQrCode = null;
} else {
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), attendeeId: attendee.id, holderUserId: toUser.id, emailSent: false } });
oldQrCode = ticket.qrCode;
}
}
const transfer = await tx.ticketTransfer.create({
data: { ticketId: target.id, fromHolderName, fromUserId: currentHolderId || null, toName: clean.name, toUserId: toUser.id, byUserId: actor.id, oldQrCode },
});
return { target, attendee, transfer };
});
return {
...result,
toUser,
accountCreated: created,
fromHolderName,
event,
registrationId: ro.registration.id,
needsConsent: !!event.requiresParentalConsent && isMinor(clean.dateOfBirth, event.startDate),
};
}
/** The transfer that retired this QR code, if any. */
async function findTransferByOldCode(qrCode) {
if (!qrCode) return null;
return prisma.ticketTransfer.findFirst({ where: { oldQrCode: String(qrCode) }, orderBy: { createdAt: 'desc' } });
}
function transferredMessage(transfer) {
const when = new Date(transfer.createdAt).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short' });
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 };
+46
View File
@@ -0,0 +1,46 @@
const { transferBlocker } = require('../src/utils/ticketTransfer');
const { judgeSyncedScan } = require('../src/utils/scanLocks');
describe('transferBlocker', () => {
const now = new Date('2026-10-01T10:00:00Z');
const ticket = {
userId: 'payer', holderUserId: 'holder', usages: [], registrationStatus: 'paid',
event: { startDate: '2026-10-05T08:00:00Z', allowTicketTransfer: true },
};
const user = (id, role = 'user') => ({ id, role });
test('the payer and the holder can transfer', () => {
expect(transferBlocker(ticket, user('payer'), now)).toBeNull();
expect(transferBlocker(ticket, user('holder'), now)).toBeNull();
});
test('anyone else needs to be a supervisor', () => {
expect(transferBlocker(ticket, user('someone'), now).status).toBe(403);
expect(transferBlocker(ticket, user('staffer', 'staff'), now).status).toBe(403);
expect(transferBlocker(ticket, user('sup', 'supervisor'), now)).toBeNull();
});
test('not once the event has started', () => {
expect(transferBlocker({ ...ticket, event: { ...ticket.event, startDate: '2026-10-01T09:00:00Z' } }, user('sup', 'supervisor'), now).message).toMatch(/before the event starts/);
});
test('not once any of it has been scanned', () => {
expect(transferBlocker({ ...ticket, usages: [{ id: 'u1' }] }, user('payer'), now).message).toMatch(/already been used/);
});
test('not for a cancelled registration', () => {
expect(transferBlocker({ ...ticket, registrationStatus: 'cancelled' }, user('payer'), now)).not.toBeNull();
});
test('turned off per event, except for supervisors', () => {
const off = { ...ticket, event: { ...ticket.event, allowTicketTransfer: false } };
expect(transferBlocker(off, user('payer'), now).message).toMatch(/turned off/);
expect(transferBlocker(off, user('admin', 'admin'), now)).toBeNull();
});
});
describe('offline sync of a transferred code', () => {
test('is flagged and not recorded', () => {
expect(judgeSyncedScan({ transferred: true }, 1)).toEqual({ redeem: 0, conflict: 'transferred' });
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.17.0",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.17.0",
"version": "1.18.0",
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.11",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.17.0",
"version": "1.18.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -29,6 +29,7 @@ const ACTIONS = [
"scan_lock_released",
"scan_lock_force_released",
"offline_sync",
"ticket_transferred",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -52,6 +53,7 @@ const ACTION_LABELS: Record<string, string> = {
scan_lock_released: "Offline scanning ended",
scan_lock_force_released: "Offline lock force-released",
offline_sync: "Offline scans synced",
ticket_transferred: "Ticket transferred",
};
interface AuditLogEntry {
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
@@ -869,6 +870,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
const [allTickets, setAllTickets] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [sendTarget, setSendTarget] = useState<any | null>(null);
const [transferTarget, setTransferTarget] = useState<any | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Load all tickets for the event once
useEffect(() => {
@@ -1000,6 +1003,7 @@ function DoorTicketsPanel({ token, eventId }: any) {
</div>
)}
{notice && <div className="mt-2 p-2 rounded bg-green-50 text-green-800 text-sm">{notice}</div>}
<div className="mt-3 space-y-2 max-h-96 overflow-auto">
{tickets.map(t => (
<div
@@ -1008,8 +1012,11 @@ function DoorTicketsPanel({ token, eventId }: any) {
>
<div className="text-sm">
<div className="font-medium">
{t.user?.name || "Guest"}
{t.attendee?.name || t.user?.name || "Guest"}
</div>
{t.attendee?.name && t.user?.name && t.attendee.name !== t.user.name && (
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
)}
<div className="text-xs text-gray-500">
{ticketLabel(t)}
</div>
@@ -1028,6 +1035,14 @@ function DoorTicketsPanel({ token, eventId }: any) {
>
Send
</button>
{canOfferTransfer(t, t.event, true) && (
<button
onClick={() => setTransferTarget(t)}
className="px-3 py-1 text-xs rounded border hover:bg-gray-50"
>
Transfer
</button>
)}
</div>
</div>
))}
@@ -1039,6 +1054,15 @@ function DoorTicketsPanel({ token, eventId }: any) {
)}
</div>
{transferTarget && (
<TransferTicketModal
ticket={transferTarget}
event={transferTarget.event}
token={token}
onClose={() => setTransferTarget(null)}
onDone={(msg) => { setTransferTarget(null); setNotice(msg); load(); }}
/>
)}
{sendTarget && (
<SendTicketsModal
open={true}
@@ -773,6 +773,7 @@ interface EventDraft {
requiresParentalConsent: boolean; consentText: string;
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
allowTicketTransfer: boolean;
}
const blankDraft = (location = ""): EventDraft => ({
@@ -784,6 +785,7 @@ const blankDraft = (location = ""): EventDraft => ({
requiresParentalConsent: false, consentText: "",
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
allowTicketTransfer: true,
});
const blankOptions = (): OptionDraft[] => [
@@ -822,6 +824,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "",
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
allowTicketTransfer: ev.allowTicketTransfer !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
} : blankDraft(settings.org_address || ""));
@@ -1102,6 +1105,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
consentCollectEmergency: draft.consentCollectEmergency,
consentCollectPhoto: draft.consentCollectPhoto,
waitlistEnabled: draft.waitlistEnabled,
allowTicketTransfer: draft.allowTicketTransfer,
waitlistOfferHours: parseInt(draft.waitlistOfferHours, 10) || 24,
waitlistPartialOffers: draft.waitlistPartialOffers,
contactName: draft.contactName || undefined,
@@ -1391,6 +1395,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
)}
</div>
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ticket transfer</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={draft.allowTicketTransfer} onChange={e => upd({ allowTicketTransfer: e.target.checked })} />
<span><strong>Let people transfer their tickets</strong> — before the event, a ticket that hasn&apos;t been scanned can be handed to someone else, who gets a new QR code (supervisors can always transfer)</span>
</label>
</div>
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Youth event</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
+16
View File
@@ -10,6 +10,7 @@ import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
import { MyWaitlists } from "@/components/shared/MyWaitlists";
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
// Helper formatters
@@ -534,6 +535,8 @@ export default function UserDashboardPage() {
}
};
const [transferTarget, setTransferTarget] = useState<any | null>(null);
// Re-fetch registrations and tickets after something on one changes (attendee names).
const reloadAfterAttendees = async () => {
if (!token) return;
@@ -1003,6 +1006,9 @@ 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>
{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>
)}
</div>
</li>
);
@@ -1012,6 +1018,16 @@ export default function UserDashboardPage() {
</section>
</div>
{transferTarget && token && (
<TransferTicketModal
ticket={transferTarget}
event={transferTarget.event}
token={token}
onClose={() => setTransferTarget(null)}
onDone={(msg) => { setTransferTarget(null); setInfo(msg); reloadAfterAttendees(); }}
/>
)}
{activeReg && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
@@ -0,0 +1,108 @@
"use client";
import React, { useState } from "react";
import { ArrowRightLeft } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
/** Whether to offer "Transfer" on a ticket (the server checks the rules again). */
export function canOfferTransfer(ticket: any, event: any, isSupervisor = false): boolean {
if (!ticket || !event) return false;
if ((ticket.usages || []).length > 0 || ticket.isUsed) return false;
if (event.startDate && new Date(event.startDate) <= new Date()) return false;
return isSupervisor || event.allowTicketTransfer !== 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.
*/
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
ticket: any;
event: any;
token: string;
onClose: () => void;
onDone: (message: string) => void;
}) {
const youth = !!event?.requiresParentalConsent;
const [f, setF] = useState({ name: "", email: "", phone: "", dateOfBirth: "", parentName: "", parentEmail: "", parentPhone: "" });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const minor = youth && isMinorOn(f.dateOfBirth, event?.startDate);
const places = ticket.quantity || 1;
const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement>) => setF(prev => ({ ...prev, [k]: e.target.value }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const body: Record<string, string> = { name: f.name, email: f.email, phone: f.phone };
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body });
onDone(res.message);
} catch (err: any) {
setError(err?.message || "Couldn't transfer the ticket");
} finally {
setBusy(false);
}
};
const input = "w-full border rounded-lg px-3 py-2 text-sm";
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-2">
<ArrowRightLeft className="w-5 h-5 text-brand-600" />
<h3 className="text-lg font-semibold">Transfer ticket</h3>
</div>
<p className="text-sm text-gray-600">
{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."}
{" "}The booking and payment stay with the person who booked.
</p>
<div>
<label className="block text-xs text-gray-600 mb-1">New holder&apos;s name</label>
<input className={input} value={f.name} onChange={set("name")} required maxLength={100} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Email</label>
<input type="email" className={input} value={f.email} onChange={set("email")} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
<input type="tel" className={input} value={f.phone} onChange={set("phone")} />
</div>
</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>
{youth && (
<div className="space-y-2 border-t pt-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
<input type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
</div>
{minor && (
<>
<p className="text-xs text-amber-800 bg-amber-50 rounded p-2">They&apos;re under 18, so a parent or guardian will be asked for consent before they can check in.</p>
<input className={input} placeholder="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input type="email" className={input} placeholder="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
<input type="tel" className={input} placeholder="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
</div>
</>
)}
</div>
)}
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<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"}
</button>
</div>
</form>
</div>
);
}
+4 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users } from "lucide-react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users, ArrowRightLeft } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -60,6 +60,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={Printer} title="Print, email, or WhatsApp" tone="green">
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.
</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.
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.
</GuideItem>
<GuideItem icon={RotateCcw} title="Refund" tone="rose">
Issue a refund against an existing payment for the selected registration.
+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.
- **Ticket transfer** — can't make it? Pass your ticket on to a friend; they get a fresh QR code and the old one stops working.
- **Works without Wi-Fi** — lock a check-in point to a phone and keep scanning with no signal; scans sync automatically when the connection is back.
- **Scan points** — set up separate check-in points (e.g. main hall, dinner) that only accept the right tickets.
- **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.17.0",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.17.0",
"version": "1.18.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.17.0",
"version": "1.18.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",