Compare commits

...
21 Commits
Author SHA1 Message Date
joshua 8a75c9155b Bump version to 1.3.1 2026-07-28 16:17:37 +02:00
joshua 8850984055 Merge branch 'fix/self-service-kiosk' 2026-07-28 16:16:31 +02:00
joshua 21176e1e0b Populate email/phone from an unmatched kiosk search query
When the account lookup finds no match, carry the typed query into whichever
of Email/Phone it resembles (email-shaped or phone-shaped) instead of
discarding it, so the operator doesn't have to retype it. Leaves both blank
if it matches neither shape, and resets Name/notification preference for a
fresh entry.
2026-07-28 16:05:41 +02:00
joshua b4cd140168 Replace as-you-type account check with an explicit search field
The kiosk previously ran a debounced background lookup on every keystroke
in the visitor Email/Phone fields, silently autofilling matches. Replaced
with a dedicated "Look up existing account" field that only searches when
the operator presses Enter or clicks Search, and matches exactly against
either email or phone (never both from one query, to avoid digits in an
email being misread as an unrelated phone number). Also simplified the
"account already exists" banner wording.
2026-07-28 14:35:28 +02:00
joshua 5167706d1b Update changelog for self-service kiosk fixes 2026-07-28 14:24:21 +02:00
joshua 047f61b627 Fix self-service kiosk: hide closed events and autofill known visitors
- GET /api/events/all gains an opt-in excludeClosed=true param, used only by
  the kiosk, so closed events no longer show as selectable there while other
  admin/supervisor screens that still need to see closed events are unaffected.
- GET /api/users/check-exists now also returns the matched account's name,
  email, phone, and notification preference (safe fields only). The kiosk's
  existing debounced lookup uses this to autofill whichever fields are still
  blank when a visitor enters an email or phone that matches an existing
  account, without overwriting anything already typed.
2026-07-28 14:21:47 +02:00
joshuaandClaude Sonnet 5 1815f78c85 Bump version to 1.3.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:06:08 +02:00
joshuaandClaude Sonnet 5 29036d0612 Merge branch 'feature/door-checkin'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:04:51 +02:00
joshuaandClaude Sonnet 5 c07e9c928a Add door check-in flow for Main Tickets
Lets staff redeem a registration's Main Tickets by quantity at the door
(via the Payment/registration flow) instead of scanning each QR code,
and automatically emails/WhatsApps a check-in confirmation to the
attendee. Also routes the At The Door "Open" button and post-payment
flow dynamically: paid registrations jump straight to Check-In instead
of a forced ticket print, since tickets are already sent automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:56:31 +02:00
joshuaandClaude Sonnet 5 adb512a7d9 Bump version to 1.2.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:35:21 +02:00
joshua ad9ead807d Fix wrong auth token key and mislabeled loading dialog on payment flows
registration/success read the token from the wrong localStorage key
(token instead of hope_events_token), which broke registration/form
loading there and made the new "Pay with Yoco" checkout call fail with
"Not authorized, token failed" — switched to the shared auth context
like the rest of the app.

dashboard/user's "Make payment" reused the ticket-email dialog, which
hardcoded "Sending tickets…" while loading — the dialog now takes an
optional loading title/subtitle so payNow can show its own message.
2026-07-27 10:29:42 +02:00
joshua 36b61d968c Simplify self-service payments to full-amount checkout, add manual-page payment tab
Self-service "pay now" flows (registration success + user dashboard) now go
straight to a Yoco checkout for the full outstanding balance instead of
prompting for a partial amount; that page has been removed. Partial-amount
payment links remain supervisor/admin-only via the Payments dashboard.

Also adds a "Record Payment" tab to the supervisor manual registration page,
pre-filled with the most recently created registration, so staff can capture
a payment right after registering someone without leaving the page.
2026-07-27 10:14:49 +02:00
joshuaandClaude Sonnet 5 c710da4dff Bump version to 1.1.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:36:38 +02:00
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00
joshuaandClaude Sonnet 5 193739c042 Apply payment method normalization consistently across user-facing views
Audited every place Payment.method reaches the UI. The main user
dashboard's per-registration payment list (fed by GET
/api/payments/registration/:id) was still showing the raw gateway
string, since that endpoint is shared with the staff-facing supervisor
payments page and wasn't touched by the earlier /mypayments fix.

Extracted the cash/card/eft/voucher/other normalization (matching
normalizeUserMethod on the backend) into frontend/src/lib/paymentMethod.ts
and applied it to both user-facing payment displays. Staff-facing views
(supervisor payments, at-the-door, reports, cashup) intentionally keep
showing the raw method for reconciliation and were left unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:41:43 +02:00
joshuaandClaude Sonnet 5 12e5dfc643 Fold apple_pay/google_pay into the card bucket, keep everything else under other
Per feedback: card-network wallet payments (Apple Pay, Google Pay) should
report and filter as "card" on the user payment history page rather than
"other", since they settle the same way as a card payment. Any other
gateway-reported method still falls under "other".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:27:52 +02:00
joshuaandClaude Sonnet 5 325ab87729 Switch payment method filter to a static list with an "other" bucket
Per feedback, drop the dynamic /mypayments/methods lookup (wasn't
loading reliably) in favor of a static cash/card/eft/voucher/other
dropdown. Server-side normalization now folds any gateway-reported
method outside those four manual-entry values (apple_pay, google_pay,
yoco, etc.) into "other" instead of "card", both in the returned data
and in the filter query.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:24:53 +02:00
joshuaandClaude Sonnet 5 7f074e43a6 Normalize user-facing payment method to cash/card/eft/voucher
Per feedback, stop trying to surface every gateway-reported wallet
type (apple_pay, google_pay, yoco, ...) as its own filter/display
value on the user payment history page. Both /mypayments and
/mypayments/methods now fold anything outside the four manual-entry
methods into "card", both in the returned data and in the filter
query, so Apple Pay/Google Pay payments show up under Card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:02:59 +02:00
joshuaandClaude Sonnet 5 59194349d2 Populate payment method filter from real data instead of a guessed list
The previous fix hardcoded apple_pay/google_pay as extra filter options,
but Payment.method is free-text set by whatever the gateway reports, so
guessing at literal values was fragile and still didn't surface them for
this user. Add GET /api/payments/mypayments/methods returning the
distinct method values actually present in the user's payments, and have
the dashboard filter build its options from that instead. Also switch
the method filter from a startsWith match to an exact match, since the
values now come straight from the same column being filtered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 08:55:58 +02:00
joshuaandClaude Sonnet 5 90bea25338 Fix payment method handling for non-default types (apple_pay, google_pay)
Yoco webhook payments are tagged with whatever wallet type the gateway
reports, not just cash/card/eft/voucher. Cashup/report totals were
silently dropping those into the "other" bucket instead of "card", and
the new user payment history page couldn't filter by them and showed
raw snake_case values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 08:28:59 +02:00
joshua 37681aec50 Add paginated payment history to user dashboard
Users can now view their own payments (excluding donations) with
server-side pagination (25/page), date range, method, and
payment/refund filters, both on the API and the new client page.
2026-07-23 18:02:53 +02:00
42 changed files with 1192 additions and 481 deletions
+58 -1
View File
@@ -7,6 +7,59 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [1.3.1] - 2026-07-28
### Fixed
- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file.
- Self-service kiosk: when the account lookup finds no match, the typed query now carries over into whichever of Email/Phone it resembles (instead of being discarded), while Name and everything else resets blank for a fresh entry.
## [1.3.0] - 2026-07-27
### Added
- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search.
- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain.
### Changed
- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead.
- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path.
## [1.2.0] - 2026-07-27
### Added
- Supervisor dashboard: `/dashboard/supervisor/manual` now has a "Record Payment" tab (alongside "Register"), for capturing a payment without leaving the page. It pre-fills with the user and registration from the most recently created manual registration, but only switches tabs when a supervisor/admin clicks it themselves.
### Changed
- Self-service payments ("Pay with Yoco" after registering, and "Make payment" on the user dashboard) now go straight to a Yoco checkout for the full remaining outstanding balance, instead of first showing a page to choose a custom/partial amount. Generating a partial-amount payment link remains available only from the supervisor/admin Payments dashboard.
### Removed
- `/dashboard/user/pay` — the self-service partial-payment page — has been removed; it's no longer linked to from anywhere in the app.
### Fixed
- `/registration/success` read the auth token from the wrong `localStorage` key (`token` instead of `hope_events_token`), which silently broke loading the registration/attendee-form data on that page and made the "Pay with Yoco" button fail with "Not authorized, token failed". Now uses the shared auth context, like the rest of the app.
- User dashboard: clicking "Make payment" briefly showed a "Sending tickets…" loading dialog (borrowed from the ticket-email flow) instead of a payment-specific message.
## [1.1.0] - 2026-07-24
### Added
- User dashboard: new "Payment history" page listing the user's own payments (donations excluded), with server-side pagination (25 per page), date range, method, and payment/refund filters.
- User dashboard: registration status (Pending/Confirmed/Partially Paid/Paid/Cancelled) is now shown as a colored badge, matching the existing event Closed/Past/Inactive badge convention, instead of a raw status string.
- Dashboard-wide: inline success/error/confirmation messages (e.g. after creating a manual registration on `/dashboard/supervisor/manual`) now auto-dismiss after 7 seconds instead of persisting indefinitely, via a new shared `useDismissingState` hook. Applied consistently across all dashboard pages with this pattern; excluded are message-only modal dialogs (e.g. ticket-scanning's success/error confirmations, which still require a manual OK) and a couple of mixed validation/async error states shown inside actively-open forms (the registration-edit modal and the event create/edit modal), which continue to persist until the user acts.
### Fixed
- Cashup/reports: payments tagged with a digital wallet method (e.g. `apple_pay`, `google_pay` from online checkouts) are now bucketed as "card" for reconciliation instead of silently falling into "other".
- User dashboard payment history: `GET /api/payments/mypayments` now normalizes `method` to the fixed set cash/card/eft/voucher/other. Card-network wallet payments (`apple_pay`, `google_pay`) are reported and filterable as "card"; any other gateway-reported value falls under "other" — instead of exposing raw, inconsistent gateway strings the filter dropdown didn't know about.
- User dashboard: the registration payment list (shown on the main dashboard when viewing a registration's bill) applies the same cash/card/eft/voucher/other normalization client-side, so it no longer shows a raw `apple_pay`/`google_pay` string. Staff-facing payment views (supervisor payments, reports, cashup) are unaffected — they still show the raw method, which is what reconciliation needs.
## [1.0.1] - 2026-07-23 ## [1.0.1] - 2026-07-23
### Added ### Added
@@ -24,6 +77,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend). - Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...main [Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...main
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1 [1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0 [1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.0.1", "version": "1.3.1",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
try { try {
const includePast = req.query.includePast === 'true'; const includePast = req.query.includePast === 'true';
const includeInactive = req.query.includeInactive === 'true'; const includeInactive = req.query.includeInactive === 'true';
const excludeClosed = req.query.excludeClosed === 'true';
const where = {}; const where = {};
if (!includeInactive) where.isActive = true; if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() }; if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
+78 -19
View File
@@ -349,30 +349,89 @@ const getPayments = async (req, res) => {
} }
}; };
// @desc Get user payments // Payment.method is free-text — online checkouts get tagged with whatever wallet type the
// gateway reports (apple_pay, google_pay, ...), not just the manual-entry methods below.
// For the user-facing dashboard: card-network wallets count as "card" (same settlement, no
// separate float); anything else unrecognized falls into "other".
const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
const CARD_ALIASES = ['apple_pay', 'google_pay'];
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
function normalizeUserMethod(method) {
const m = String(method || '').toLowerCase();
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return 'card';
return 'other';
}
// @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters)
// @route GET /api/payments/mypayments // @route GET /api/payments/mypayments
// @access Private // @access Private
const getUserPayments = async (req, res) => { const getUserPayments = async (req, res) => {
try { try {
// Users should see payments they made (donations) and payments tied to their registrations const page = Math.max(1, parseInt(req.query.page) || 1);
const payments = await prisma.payment.findMany({ const limit = Math.min(25, Math.max(1, parseInt(req.query.limit) || 25));
where: { const skip = (page - 1) * limit;
OR: [
{ userId: req.user.id }, // payments made by the user (includes donations)
{ registration: { userId: req.user.id } } // payments tied to the user's registrations
]
},
include: {
registration: {
include: {
event: true
}
},
event: true
}
});
res.json(payments); // Users should see payments they made and payments tied to their registrations,
// excluding donations.
const where = {
isDonation: false,
OR: [
{ userId: req.user.id },
{ registration: { userId: req.user.id } }
]
};
if (req.query.method) {
const requested = String(req.query.method).toLowerCase();
if (requested === 'card') {
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
// settlement as a card payment, no separate float to reconcile.
where.AND = [{
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
} else if (requested === 'other') {
// "Other" covers every method that isn't one of the recognized buckets above.
where.NOT = {
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
};
} else if (USER_FACING_METHODS.includes(requested)) {
where.method = { equals: requested, mode: 'insensitive' };
}
}
if (req.query.kind === 'refund') {
where.amount = { lt: 0 };
} else if (req.query.kind === 'payment') {
where.amount = { gte: 0 };
}
if (req.query.startDate || req.query.endDate) {
where.createdAt = {
...(req.query.startDate && { gte: new Date(req.query.startDate) }),
...(req.query.endDate && { lte: new Date(req.query.endDate) })
};
}
const [payments, total] = await prisma.$transaction([
prisma.payment.findMany({
where,
include: {
registration: { include: { event: true } },
event: true
},
orderBy: { createdAt: 'desc' },
skip,
take: limit
}),
prisma.payment.count({ where })
]);
// Normalize the displayed method so the dashboard never shows a raw gateway string.
const normalizedPayments = payments.map(p => ({ ...p, method: normalizeUserMethod(p.method) }));
res.json({ data: normalizedPayments, total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) { } catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
} }
+10 -1
View File
@@ -321,6 +321,7 @@ const scanTicket = async (req, res) => {
e.title AS "eventTitle", e.title AS "eventTitle",
eo.id AS "eventOptionId", eo.id AS "eventOptionId",
eo.name AS "eventOptionName", eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE( COALESCE(
json_agg( json_agg(
json_build_object( json_build_object(
@@ -352,7 +353,7 @@ const scanTicket = async (req, res) => {
isUsed: r.isUsed, isUsed: r.isUsed,
eventId: r.eventId, eventId: r.eventId,
event: { title: r.eventTitle }, event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } }, registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || [] usages: r.usages || []
}; };
@@ -398,6 +399,14 @@ const scanTicket = async (req, res) => {
}) })
]); ]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
res.json({ res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`, message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
ticketUsage, ticketUsage,
+14 -3
View File
@@ -482,13 +482,24 @@ const checkUserExists = async (req, res) => {
const existingUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses }, where: { OR: searchClauses },
select: { email: true, phoneNumber: true }, select: { name: true, email: true, phoneNumber: true, notificationPreference: true },
}); });
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
const hasPhone = !!existingUser?.phoneNumber;
res.json({ res.json({
exists: !!existingUser, exists: !!existingUser,
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'), hasEmail,
hasPhone: !!existingUser?.phoneNumber, hasPhone,
// Safe-to-display fields only, for autofilling a lookup form — never the password.
// Guest placeholder emails are withheld the same way hasEmail already treats them.
user: existingUser ? {
name: existingUser.name,
email: hasEmail ? existingUser.email : null,
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
notificationPreference: existingUser.notificationPreference,
} : null,
}); });
} catch (error) { } catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
+3 -2
View File
@@ -543,8 +543,9 @@ app.get('/docs', async (req, res) => {
responses:[ responses:[
{ status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }}, { status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }},
]}, ]},
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history', { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }]}]}, queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments', { method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' }, queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]}, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
+3 -1
View File
@@ -11,11 +11,13 @@ function emptyByMethod(fill = 0) {
} }
// Normalizes a free-text Payment.method into one of the fixed cashup buckets. // Normalizes a free-text Payment.method into one of the fixed cashup buckets.
// Card-network wallets (Apple Pay, Google Pay, etc.) settle exactly like a card payment —
// no separate float to reconcile — so they belong in the 'card' bucket, not 'other'.
function bucketForMethod(method) { function bucketForMethod(method) {
const m = String(method || '').toLowerCase(); const m = String(method || '').toLowerCase();
if (m.includes('cash')) return 'cash'; if (m.includes('cash')) return 'cash';
if (m.includes('eft')) return 'eft'; if (m.includes('eft')) return 'eft';
if (m.includes('card') || m.includes('yoco')) return 'card'; if (m.includes('card') || m.includes('yoco') || m.includes('pay')) return 'card';
return 'other'; return 'other';
} }
+66
View File
@@ -80,6 +80,17 @@ async function loadRegistrationFull(registrationId) {
}); });
} }
async function loadTicketFull(ticketId) {
return prisma.ticket.findUnique({
where: { id: ticketId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
event: true,
registrationOption: { include: { eventOption: true, variant: true } },
},
});
}
async function loadPaymentFull(paymentId) { async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({ return prisma.payment.findUnique({
where: { id: paymentId }, where: { id: paymentId },
@@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) {
return { subject, text, html: emailWrapper(body, { preheader }) }; return { subject, text, html: emailWrapper(body, { preheader }) };
} }
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
const fullyCheckedIn = remaining <= 0;
const subject = `Checked in ${eventTitle}`;
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! </p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
}`, fullyCheckedIn ? 'success' : 'info')}`;
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Donation applied to a registration ──────────────────────────────────────── // ─── Donation applied to a registration ────────────────────────────────────────
// //
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply // Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
@@ -893,6 +931,32 @@ async function sendRefundEmail(refundPaymentId) {
} }
} }
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
try {
const ticket = await loadTicketFull(ticketId);
if (!ticket) return;
const user = ticket.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWACheckIn } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail && shouldEmail(user)) {
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
if (hasValidEmail) {
sends.push(waText(user, waMsg));
} else {
sends.push(waTextAny(user, waMsg));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send check-in emails:', e);
}
}
// Sent when staff apply a donation to someone's registration. Only the registrant is // Sent when staff apply a donation to someone's registration. Only the registrant is
// notified (anonymously, per design) — the donor already received their donation-received // notified (anonymously, per design) — the donor already received their donation-received
// notification when the donation was originally made, so they are deliberately not emailed // notification when the donation was originally made, so they are deliberately not emailed
@@ -981,4 +1045,6 @@ module.exports = {
sendRefundEmail, sendRefundEmail,
sendSelfServiceRegistrationEmails, sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails, sendDonationAssignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
}; };
+21
View File
@@ -107,6 +107,26 @@ function buildWAPayment(payment) {
].join('\n'); ].join('\n');
} }
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const name = ticket.user?.name || 'there';
const fullyCheckedIn = remaining <= 0;
return [
`✅ *Checked In*`,
'',
`Hi ${name},`,
'',
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Login notification ─────────────────────────────────────────────────────── // ─── Login notification ───────────────────────────────────────────────────────
function buildWALogin({ name, when, location, userAgent }) { function buildWALogin({ name, when, location, userAgent }) {
@@ -255,4 +275,5 @@ module.exports = {
buildWAWelcome, buildWAWelcome,
buildWAAccountClosed, buildWAAccountClosed,
buildWATicketCaption, buildWATicketCaption,
buildWACheckIn,
}; };
+1 -2
View File
@@ -164,7 +164,6 @@ frontend/src/
|-------|-------------| |-------|-------------|
| `/dashboard/user` | My registrations + tickets overview | | `/dashboard/user` | My registrations + tickets overview |
| `/dashboard/user/profile` | Edit profile, notification preferences | | `/dashboard/user/profile` | Edit profile, notification preferences |
| `/dashboard/user/pay` | Pay outstanding balance |
| `/dashboard/user/donate` | Make a donation | | `/dashboard/user/donate` | Make a donation |
| `/dashboard/user/forms` | Complete registration forms | | `/dashboard/user/forms` | Complete registration forms |
| `/dashboard/user/reset-password` | Change password (authenticated) | | `/dashboard/user/reset-password` | Change password (authenticated) |
@@ -255,7 +254,7 @@ A "Select Email/both" or "Select WhatsApp/both" quick-select button is available
5. Backend reconciles payment, updates registration status, generates and emails tickets 5. Backend reconciles payment, updates registration status, generates and emails tickets
6. User sees `/payment/success` and tickets appear in their dashboard 6. User sees `/payment/success` and tickets appear in their dashboard
Outstanding balances can be paid at any time from `/dashboard/user/pay`. Outstanding balances can be paid at any time from the "Make payment" button on `/dashboard/user` — this always creates a checkout for the full remaining balance. Partial-amount payment links can only be generated by a supervisor/admin from `/dashboard/supervisor/payments`.
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.0.1", "version": "1.3.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types"; import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
@@ -29,7 +30,7 @@ export default function EventCashupPage() {
const [data, setData] = useState<EventFinancials | null>(null); const [data, setData] = useState<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]); const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const isClosed = data?.event?.cashupStatus === "closed"; const isClosed = data?.event?.cashupStatus === "closed";
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function CashupLandingPage() { export default function CashupLandingPage() {
const { token } = useAuth(); const { token } = useAuth();
@@ -11,7 +12,7 @@ export default function CashupLandingPage() {
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showPast, setShowPast] = useState(true); const [showPast, setShowPast] = useState(true);
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const; const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -29,8 +30,8 @@ export default function AdminRegistrationsPage() {
const [registrations, setRegistrations] = useState<any[]>([]); const [registrations, setRegistrations] = useState<any[]>([]);
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false); const [loadingRegs, setLoadingRegs] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Filters // Filters
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal"; type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
@@ -36,19 +37,17 @@ function Field({
} }
function SaveBar({ function SaveBar({
saving, onSave, result, onDismiss, saving, onSave, result,
}: { }: {
saving: boolean; saving: boolean;
onSave: () => void; onSave: () => void;
result: { ok: boolean; message: string } | null; result: { ok: boolean; message: string } | null;
onDismiss: () => void;
}) { }) {
return ( return (
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3"> <div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
{result ? ( {result ? (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}> <span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message} {result.ok ? "✓" : "✗"} {result.message}
<button type="button" onClick={onDismiss} className="ml-1 text-gray-400 hover:text-gray-600 text-xs">×</button>
</span> </span>
) : ( ) : (
<span /> <span />
@@ -75,7 +74,7 @@ export default function SiteSettingsPage() {
// Per-tab save state // Per-tab save state
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); const [result, setResult] = useDismissingState<{ ok: boolean; message: string } | null>(null);
// ── Organisation ── // ── Organisation ──
const [orgName, setOrgName] = useState(""); const [orgName, setOrgName] = useState("");
@@ -104,7 +103,7 @@ export default function SiteSettingsPage() {
const [smtpPass, setSmtpPass] = useState(""); const [smtpPass, setSmtpPass] = useState("");
const [smtpPassSet, setSmtpPassSet] = useState(false); const [smtpPassSet, setSmtpPassSet] = useState(false);
const [smtpTesting, setSmtpTesting] = useState(false); const [smtpTesting, setSmtpTesting] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; message: string; raw?: string } | null>(null); const [smtpTestResult, setSmtpTestResult] = useDismissingState<{ ok: boolean; message: string; raw?: string } | null>(null);
// ── Legal ── // ── Legal ──
const [legalOperatorName, setLegalOperatorName] = useState(""); const [legalOperatorName, setLegalOperatorName] = useState("");
@@ -332,7 +331,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="https://events.yourchurch.org" <input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} /> value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field> </Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div> </div>
)} )}
@@ -371,7 +370,7 @@ export default function SiteSettingsPage() {
}} className="text-sm" /> }} className="text-sm" />
</Field> </Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveBranding} result={result} />
</div> </div>
)} )}
@@ -385,7 +384,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org" <input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} /> value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
</Field> </Field>
<SaveBar saving={saving} onSave={saveNotifications} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveNotifications} result={result} />
</div> </div>
)} )}
@@ -464,7 +463,7 @@ export default function SiteSettingsPage() {
)} )}
</div> </div>
<SaveBar saving={saving} onSave={saveSmtp} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveSmtp} result={result} />
</div> </div>
)} )}
@@ -503,7 +502,7 @@ export default function SiteSettingsPage() {
</div> </div>
</div> </div>
<SaveBar saving={saving} onSave={saveLegal} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveLegal} result={result} />
</div> </div>
)} )}
</div> </div>
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
interface UserItem { interface UserItem {
id: string; id: string;
@@ -43,7 +44,7 @@ export default function AdminUsersPage() {
// Data state // Data state
const [users, setUsers] = useState<UserItem[]>([]); const [users, setUsers] = useState<UserItem[]>([]);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -123,7 +124,7 @@ export default function WhatsAppAdminPage() {
const [pairingPhone, setPairingPhone] = useState(""); const [pairingPhone, setPairingPhone] = useState("");
// ── Shared action feedback ─────────────────────────────────────────────────── // ── Shared action feedback ───────────────────────────────────────────────────
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
// ── Load config ────────────────────────────────────────────────────────────── // ── Load config ──────────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react"; import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date"; import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage"; import { QrImage } from "@/components/shared/QrImage";
@@ -18,7 +19,7 @@ function EventTicketsContent() {
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState<string>(paramEventId); const [eventId, setEventId] = useState<string>(paramEventId);
const [tickets, setTickets] = useState<any[]>([]); const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId); const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
@@ -4,14 +4,15 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner"; import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function TicketScanningPage() { export default function TicketScanningPage() {
const router = useRouter(); const router = useRouter();
const scannerRef = useRef<QRScannerHandle>(null); const scannerRef = useRef<QRScannerHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null); const [lastResult, setLastResult] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useState<string | null>(null); const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null); const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null); const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth(); const { token, user } = useAuth();
@@ -5,8 +5,17 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
type Mode = "registration" | "payment" | "tickets" | "refund"; type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
const MODE_LABELS: Record<Mode, string> = {
registration: "REGISTRATION",
payment: "PAYMENT",
checkin: "CHECK-IN",
tickets: "TICKETS",
refund: "REFUND",
};
// ─── Fuzzy search helpers ───────────────────────────────────────────────────── // ─── Fuzzy search helpers ─────────────────────────────────────────────────────
@@ -154,34 +163,11 @@ export default function AtTheDoorPage() {
const [mode, setMode] = useState<Mode>("registration"); const [mode, setMode] = useState<Mode>("registration");
const [activeRegistration, setActiveRegistration] = useState<any | null>(null); const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
useEffect(() => {
if (!info) return;
const id = setTimeout(() => {
setInfo(null);
}, 10000); // ⏱ disappears after 5s
return () => clearTimeout(id);
}, [info]);
useEffect(() => {
if (!error) return;
const id = setTimeout(() => {
setError(null);
}, 15000); // errors linger slightly longer
return () => clearTimeout(id);
}, [error]);
const handleRegistrationCreated = (registration: any) => { const handleRegistrationCreated = (registration: any) => {
setActiveRegistration(registration); setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
setMode("payment"); // 🚀 Jump automatically
}; };
const handleRegistrationSelected = (registration: any) => { const handleRegistrationSelected = (registration: any) => {
@@ -192,7 +178,9 @@ export default function AtTheDoorPage() {
if (!activeRegistration) return; if (!activeRegistration) return;
const id = setTimeout(() => { const id = setTimeout(() => {
setMode("payment"); // Fully paid → head straight to check-in; otherwise there's still a
// balance to collect, so go capture payment first.
setMode(activeRegistration.status === "paid" ? "checkin" : "payment");
}, 0); }, 0);
return () => clearTimeout(id); return () => clearTimeout(id);
@@ -338,28 +326,12 @@ export default function AtTheDoorPage() {
const reg = registration || activeRegistration; const reg = registration || activeRegistration;
try { // Tickets are generated and emailed/WhatsApped automatically server-side
const res = await apiFetch<any>("/api/tickets/generate", { // once a registration reaches "paid" (see paymentController) — no need to
method: "POST", // fetch/print them here, just move straight to checking the attendee in.
authToken: token, setActiveRegistration(reg);
body: { setInfo("Payment recorded — tickets sent. Ready to check in.");
registrationId: reg.id setMode("checkin");
}
});
const tickets = res?.tickets || [];
if (tickets.length) {
printTickets(tickets);
}
setInfo("Payment recorded & tickets printed");
setActiveRegistration(null);
setMode("registration");
} catch (e: any) {
setError(e?.message || "Tickets failed to generate");
}
}; };
const handleDonation = (user?: any) => { const handleDonation = (user?: any) => {
@@ -367,97 +339,6 @@ export default function AtTheDoorPage() {
setShowDonationModal(true); setShowDonationModal(true);
}; };
const buildTicketHtmlCard = (t: any) => {
const eventTitle =
t.event?.title ||
t.registrationOption?.registration?.event?.title ||
"Event";
const ticketType = ticketLabel(t);
const qty = t.quantity || 1;
const holder =
t.user?.name ||
t.registrationOption?.registration?.user?.name ||
"";
const qrValue = t.qrCode || t.id;
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`;
return `<div class="ticket">
<div class="top">
<div class="evt">${eventTitle}</div>
<div class="type">${ticketType}</div>
${holder ? `<div class="holder">${holder}</div>` : ""}
<div class="qty">Qty: <strong>${qty}</strong></div>
</div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">${t.id}</div>
</div>`;
};
const openPrintWindow = (cardsHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><style>
@page { size: A4; margin: 8mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; background: #fff; }
.page {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 194mm;
height: 281mm;
gap: 4mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket {
border: 1.5px solid #222;
border-radius: 3mm;
padding: 5mm;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
min-height: 0;
}
.top { flex: 0 0 auto; }
.evt { font-weight: bold; font-size: 13pt; line-height: 1.2; }
.type { font-size: 11pt; margin-top: 2mm; color: #333; }
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
.qty { font-size: 10pt; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1 1 auto; padding: 3mm 0; }
.qrwrap img { width: 48mm; height: 48mm; display: block; }
.meta { font-size: 7pt; color: #777; text-align: center; flex: 0 0 auto; word-break: break-all; }
</style></head><body>${cardsHtml}<script>
(function(){
function go(){
var imgs=Array.prototype.slice.call(document.images);
if(!imgs.length){window.print();return;}
var n=imgs.length;
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
}
if(document.readyState==='complete')go();else window.addEventListener('load',go);
})();
</script></body></html>`);
w.document.close();
};
const printTickets = (tickets: any[]) => {
if (!tickets?.length) return;
// 4 per A4 page (2 columns × 2 rows)
const PAGE_SIZE = 4;
const pages: string[] = [];
for (let i = 0; i < tickets.length; i += PAGE_SIZE) {
const chunk = tickets.slice(i, i + PAGE_SIZE);
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
return ( return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6"> <div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
@@ -503,7 +384,7 @@ export default function AtTheDoorPage() {
{/* Mode Buttons */} {/* Mode Buttons */}
<div className="flex gap-2 mb-4 flex-wrap"> <div className="flex gap-2 mb-4 flex-wrap">
{(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => ( {(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
<button <button
key={m} key={m}
onClick={() => setMode(m)} onClick={() => setMode(m)}
@@ -515,7 +396,7 @@ export default function AtTheDoorPage() {
: "bg-white hover:bg-gray-50" : "bg-white hover:bg-gray-50"
}`} }`}
> >
{m.toUpperCase()} {MODE_LABELS[m]}
</button> </button>
))} ))}
</div> </div>
@@ -543,6 +424,10 @@ export default function AtTheDoorPage() {
/> />
)} )}
{mode === "checkin" && (
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
)}
{mode === "tickets" && ( {mode === "tickets" && (
<DoorTicketsPanel token={token} eventId={eventId} /> <DoorTicketsPanel token={token} eventId={eventId} />
)} )}
@@ -1102,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) {
); );
} }
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const load = async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
} catch {
setTicketsForEvent([]);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [token, eventId]);
if (!registration) {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
No registration selected
</div>
);
}
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
const setQtyFor = (t: any, n: number) =>
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
const commit = async (ticket: any) => {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<any>(
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
{ method: "POST", authToken: token, body: { qty } }
);
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
res?.remaining > 0 ? `${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e: any) {
setError(e?.message || "Check-in failed");
} finally {
setSubmittingId(null);
}
};
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
{/* ✅ User */}
<div className="mb-4">
<div className="text-lg font-semibold">
{registration.user?.name || "Guest"}
</div>
</div>
{loading && (
<div className="text-sm text-gray-500">Loading</div>
)}
{!loading && myTickets.length === 0 && (
<div className="text-sm text-gray-500">
No Main Tickets on this registration
</div>
)}
<div className="space-y-4">
{myTickets.map(t => {
const qty = qtyFor(t);
return (
<div key={t.id} className="border rounded-lg p-4">
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="text-lg font-semibold">{t.quantity || 1}</div>
</div>
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">CHECKED IN</div>
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
</div>
<div className="border rounded-lg p-3 text-center bg-emerald-50">
<div className="text-xs text-gray-500">REMAINING</div>
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
</div>
</div>
{t.remaining > 0 ? (
<div className="flex items-center gap-3">
<button
onClick={() => setQtyFor(t, qty - 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
>
</button>
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
<button
onClick={() => setQtyFor(t, qty + 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
>
+
</button>
<button
onClick={() => commit(t)}
disabled={submittingId === t.id}
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
>
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
</button>
</div>
) : (
<div className="text-sm text-emerald-600 font-medium">Fully checked in </div>
)}
</div>
);
})}
</div>
</div>
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) { function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
if (!open) return null; if (!open) return null;
@@ -1332,7 +1359,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useDismissingState("");
const [localInfo, setLocalInfo] = useState(""); const [localInfo, setLocalInfo] = useState("");
useEffect(() => { useEffect(() => {
@@ -1369,7 +1396,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
}, },
}); });
setLocalInfo("Tickets sent successfully."); setLocalInfo("Tickets sent successfully.");
setTimeout(() => { setLocalInfo(""); onClose(); }, 2000); setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
} catch (e: any) { } catch (e: any) {
setLocalError(e?.message || "Failed to send tickets"); setLocalError(e?.message || "Failed to send tickets");
} finally { } finally {
@@ -1461,7 +1488,7 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
const [method, setMethod] = useState("cash"); const [method, setMethod] = useState("cash");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useDismissingState("");
useEffect(() => { useEffect(() => {
if (open) { setAmount(""); setReason(""); setLocalError(""); } if (open) { setAmount(""); setReason(""); setLocalError(""); }
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type Attendee = { id: string; name: string; email: string; pref: string }; type Attendee = { id: string; name: string; email: string; pref: string };
@@ -171,8 +172,8 @@ function EmailAttendeesPageInner() {
// Load events for selection // Load events for selection
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Tabs: attendees (current), automations (coming soon), broadcasts // Tabs: attendees (current), automations (coming soon), broadcasts
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees'); const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local"> // Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm // This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -112,8 +113,8 @@ function EventOptionsContent() {
const [selectedEventId, setSelectedEventId] = useState<string>(""); const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]); const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false); const [loadingEv, setLoadingEv] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── helpers ──────────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────────
@@ -690,7 +691,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []); const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []);
const [loading, setLoading] = useState(!hasInitial); const [loading, setLoading] = useState(!hasInitial);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
useEffect(() => { useEffect(() => {
if (hasInitial) return; // already have the data — skip the network round trip entirely if (hasInitial) return; // already have the data — skip the network round trip entirely
@@ -1415,7 +1416,7 @@ export default function ManageEventsPage() {
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph'; type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
@@ -144,8 +145,8 @@ export default function FormsBrowserPage() {
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -18,7 +19,7 @@ export default function ManualRegistrationPage() {
const [phoneNumber, setPhoneNumber] = useState(""); const [phoneNumber, setPhoneNumber] = useState("");
const [registerAsGuest, setRegisterAsGuest] = useState(false); const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null); const [createdReg, setCreatedReg] = useState<any | null>(null);
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({}); const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
@@ -110,8 +111,8 @@ export default function ManualRegistrationPage() {
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) { function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
const { token } = useAuth(); const { token } = useAuth();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0); const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets); const count = Math.max(0, mainTickets);
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Pricing helpers ───────────────────────────────────────────────────────── // ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -38,6 +39,18 @@ function fmtPrice(n: number) {
return n === 0 ? "Free" : `R ${n.toFixed(2)}`; return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
} }
// Effective unit price for an existing registration option (as returned by /api/registrations).
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
// back to a live early-bird calculation for legacy rows without a snapshot.
function optionUnitPrice(opt: any): number {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
if (opt.variant) return effectiveVariantUnit(eo, opt.variant);
return effectiveOptionUnit(eo);
}
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
export default function ManualRegistrationPage() { export default function ManualRegistrationPage() {
@@ -72,8 +85,25 @@ export default function ManualRegistrationPage() {
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
// ── Tabs ──────────────────────────────────────────────────────────────────
const [tab, setTab] = useState<"register" | "payment">("register");
// ── Record Payment tab state ─────────────────────────────────────────────
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
const [payUserId, setPayUserId] = useState<string>("");
const [payUserQuery, setPayUserQuery] = useState("");
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
const paySearchRef = useRef<HTMLDivElement>(null);
const [payRegistrationId, setPayRegistrationId] = useState<string>("");
const [payAmount, setPayAmount] = useState<string>("");
const [payMethod, setPayMethod] = useState<string>("cash");
const [payPaidAtLocal, setPayPaidAtLocal] = useState<string>("");
const [paySubmitting, setPaySubmitting] = useState(false);
const [payMessage, setPayMessage] = useDismissingState<string | null>(null);
const [payError, setPayError] = useDismissingState<string | null>(null);
// Load all users for client-side fuzzy matching // Load all users for client-side fuzzy matching
useEffect(() => { useEffect(() => {
@@ -83,6 +113,97 @@ export default function ManualRegistrationPage() {
.catch(() => {}); .catch(() => {});
}, [token]); }, [token]);
// Load all registrations for the Record Payment tab (embeds payments, so outstanding
// balances can be computed without a per-registration fetch loop).
useEffect(() => {
if (!token) return;
apiFetch<any[]>("/api/registrations", { authToken: token })
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
.catch(() => {});
}, [token]);
const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
return map;
}, [allRegistrations]);
const payMatchedUsers = useMemo(() => {
if (payUserQuery.trim().length < 2) return [];
return allUsers
.map(u => ({ u, score: scoreUser(u, payUserQuery) }))
.filter(x => x.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 6)
.map(x => x.u);
}, [payUserQuery, allUsers]);
const selectPayUser = (u: any) => {
setPayUserId(u.id);
setPayUserQuery(u.name || "");
setPayDropdownOpen(false);
setPayRegistrationId("");
};
// Close payment-tab user dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (paySearchRef.current && !paySearchRef.current.contains(e.target as Node)) {
setPayDropdownOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const regsForPayUser = useMemo(() => {
if (!payUserId) return [];
return allRegistrations
.filter((r: any) => String(r.userId || r.user?.id) === String(payUserId))
.filter((r: any) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [allRegistrations, payUserId, regOutstanding]);
const createManualPayment = async () => {
if (!token) return;
setPayError(null);
setPayMessage(null);
const amt = parseFloat(payAmount || "0");
if (!amt || amt <= 0) { setPayError("Enter a valid amount"); return; }
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiFetch<any>("/api/payments", {
method: "POST",
authToken: token,
body: {
amount: amt,
method: payMethod,
userId: payUserId || undefined,
registrationId: payRegistrationId,
isDonation: false,
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
}
});
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
setPayAmount("");
setPayPaidAtLocal("");
// Refresh registrations so the displayed outstanding balance updates
try {
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
setAllRegistrations(Array.isArray(regs) ? regs : []);
} catch {}
} catch (e: any) {
setPayError(e?.message || "Failed to record payment");
} finally {
setPaySubmitting(false);
}
};
// Fuzzy match results (top 6, score threshold 0.45) // Fuzzy match results (top 6, score threshold 0.45)
const matchedUsers = useMemo(() => { const matchedUsers = useMemo(() => {
if (userQuery.trim().length < 2) return []; if (userQuery.trim().length < 2) return [];
@@ -192,6 +313,14 @@ export default function ManualRegistrationPage() {
}); });
setMessage("Manual registration created successfully."); setMessage("Manual registration created successfully.");
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
setAllRegistrations(prev => [res, ...prev.filter((r: any) => r.id !== res.id)]);
setPayUserId(res.userId || res.user?.id || "");
setPayUserQuery(res.user?.name || guest.name || "");
setPayRegistrationId(res.id);
setPayDropdownOpen(false);
// Reset guest/ticket fields so the next registration starts from a clean slate // Reset guest/ticket fields so the next registration starts from a clean slate
setGuest({ name: "", email: "", phoneNumber: "" }); setGuest({ name: "", email: "", phoneNumber: "" });
setRegisterAsGuest(false); setRegisterAsGuest(false);
@@ -219,6 +348,19 @@ export default function ManualRegistrationPage() {
</div> </div>
)} )}
<div className="mb-4 flex items-center gap-2">
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
Register
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
Record Payment
</label>
</div>
{tab === 'register' && (
<>
{message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>} {message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>} {error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
@@ -446,6 +588,92 @@ export default function ManualRegistrationPage() {
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div> <div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button> <button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div> </div>
</>
)}
{tab === 'payment' && (
<div className="max-w-xl">
{payMessage && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{payMessage}</div>}
{payError && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{payError}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Record a payment</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Amount</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="number" step="0.01" value={payAmount} onChange={e => setPayAmount(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={payMethod} onChange={e => setPayMethod(e.target.value)}>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
<option value="voucher">Voucher</option>
</select>
</div>
<div ref={paySearchRef} className="relative">
<label className="block text-xs text-gray-600 mb-1">User</label>
<input
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Search by name, email or phone…"
value={payUserQuery}
autoComplete="off"
onChange={e => { setPayUserQuery(e.target.value); setPayDropdownOpen(true); if (!e.target.value) { setPayUserId(""); setPayRegistrationId(""); } }}
onFocus={() => { if (payUserQuery.length >= 2) setPayDropdownOpen(true); }}
/>
{payDropdownOpen && payUserQuery.trim().length >= 2 && (
<div className="absolute z-30 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{payMatchedUsers.length > 0 ? (
<>
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">
{payMatchedUsers.length} match{payMatchedUsers.length !== 1 ? "es" : ""}
</div>
{payMatchedUsers.map(u => (
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectPayUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{u.email && <span>{u.email}</span>}
{u.phoneNumber && <span>· {u.phoneNumber}</span>}
</div>
</button>
))}
</>
) : (
<div className="px-3 py-3 text-sm text-gray-500 italic">No matching users found.</div>
)}
</div>
)}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={payRegistrationId} onChange={e => setPayRegistrationId(e.target.value)} disabled={!payUserId}>
<option value="">Select registration</option>
{regsForPayUser.map((r: any) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id} title={label}>{label}</option>;
})}
</select>
{!payUserId && <div className="text-xs text-gray-500 mt-1">Search for a user above to see their registrations.</div>}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Paid at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={payPaidAtLocal} onChange={e => setPayPaidAtLocal(e.target.value)} max={new Date().toISOString().slice(0,16)} />
<div className="text-[10px] text-gray-500 mt-1">Leave blank to use current time</div>
</div>
</div>
<div className="mt-3">
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api"; import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
function RegistrationOptions({ regs, regOutstanding }: { function RegistrationOptions({ regs, regOutstanding }: {
@@ -115,8 +116,8 @@ function PaymentsContent() {
const [payments, setPayments] = useState<any[]>([]); const [payments, setPayments] = useState<any[]>([]);
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [registrations, setRegistrations] = useState<any[]>([]); const [registrations, setRegistrations] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false); const [loadingRegs, setLoadingRegs] = useState(false);
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({}); const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Attendee with preference info // Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string }; type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -358,8 +359,8 @@ function WhatsAppAttendeesPageInner() {
if (!user) router.replace("/login"); if (!user) router.replace("/login");
}, [user, loading, router]); }, [user, loading, router]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees"); const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]); const [allEvents, setAllEvents] = useState<any[]>([]);
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function DonatePage() { export default function DonatePage() {
const { token } = useAuth(); const { token } = useAuth();
@@ -9,8 +10,8 @@ export default function DonatePage() {
const [eventId, setEventId] = useState<string>(""); const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>(""); const [amount, setAmount] = useState<string>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -3,6 +3,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Types for form fields // Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -16,8 +17,8 @@ function FormsContent() {
const [registration, setRegistration] = useState<any | null>(null); const [registration, setRegistration] = useState<any | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Local entry state for new responses // Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({}); const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
+65 -10
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date"; import { formatDate } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage"; import { QrImage } from "@/components/shared/QrImage";
import { useDismissingState } from "@/hooks/useDismissingState";
// Helper formatters // Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`; const formatRand = (n: number) => `R ${n.toFixed(2)}`;
@@ -29,6 +31,20 @@ function EventStatusBadge({ event }: { event: any }) {
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>; return <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>;
} }
// Status badge for a registration, shown wherever registration.status is displayed on this
// page. Colors match the status coloring already used on dashboard/admin/registrations.
const REGISTRATION_STATUS_STYLES: Record<string, { label: string; className: string }> = {
pending: { label: 'Pending', className: 'bg-gray-100 text-gray-600' },
confirmed: { label: 'Confirmed', className: 'bg-blue-50 text-blue-700' },
partial_paid: { label: 'Partially Paid', className: 'bg-amber-50 text-amber-700' },
paid: { label: 'Paid', className: 'bg-green-50 text-green-700' },
cancelled: { label: 'Cancelled', className: 'bg-red-50 text-red-700' },
};
function RegistrationStatusBadge({ status }: { status: string }) {
const s = REGISTRATION_STATUS_STYLES[status] || { label: status, className: 'bg-gray-100 text-gray-500' };
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${s.className}`}>{s.label}</span>;
}
// Effective unit price for an event option (or one of its variants), early-bird aware. // Effective unit price for an event option (or one of its variants), early-bird aware.
// Used by the registration editor, which works off raw /api/events/:id data rather than // Used by the registration editor, which works off raw /api/events/:id data rather than
// a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx. // a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx.
@@ -52,8 +68,8 @@ export default function UserDashboardPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const [registrations, setRegistrations] = useState<any[]>([]); const [registrations, setRegistrations] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]); const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
// Filters // Filters
@@ -71,7 +87,7 @@ export default function UserDashboardPage() {
// Registration details modal // Registration details modal
const [activeRegId, setActiveRegId] = useState<string | null>(null); const [activeRegId, setActiveRegId] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean }>({ open: false, message: "", loading: false }); const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false });
// Track whether the active registration's event has attendee forms // Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null); const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
@@ -296,6 +312,41 @@ export default function UserDashboardPage() {
} }
}; };
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
// choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({ open: true, message: "Creating payment link…", loading: true, loadingTitle: "Creating payment link…", loadingSubtitle: "Please wait while we redirect you to Yoco." });
try {
const res = await createFullPaymentCheckout(token, registrationId);
if (res.priceUpdated) {
setDialog({ open: false, message: "", loading: false });
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
// Refresh this registration's billing so the displayed outstanding reflects the new price
try {
const now = new Date();
const r = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
} catch {}
return;
}
if (!res.redirectUrl) {
setDialog({ open: false, message: "", loading: false });
setError("Failed to create checkout");
return;
}
window.location.href = res.redirectUrl;
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to create checkout");
}
};
// Print helpers // Print helpers
const buildTicketHtmlCard = (t: any, buyerName?: string) => { const buildTicketHtmlCard = (t: any, buyerName?: string) => {
const eventTitle = t.event?.title || t.eventId || "Event"; const eventTitle = t.event?.title || t.eventId || "Event";
@@ -629,6 +680,10 @@ export default function UserDashboardPage() {
className="px-2.5 py-1 text-xs 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" className="px-2.5 py-1 text-xs 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"
onClick={() => router.push("/dashboard/user/reset-password")} onClick={() => router.push("/dashboard/user/reset-password")}
>Reset password</button> >Reset password</button>
<button
className="px-2.5 py-1 text-xs 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"
onClick={() => router.push("/dashboard/user/payments")}
>Payment history</button>
<button <button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")} onClick={() => router.push("/dashboard/user/donate")}
@@ -677,7 +732,7 @@ export default function UserDashboardPage() {
<EventStatusBadge event={r.event} /> <EventStatusBadge event={r.event} />
</div> </div>
<div className="text-xs text-gray-600"> <div className="text-xs text-gray-600">
Status: {r.status}{isCancelled && <span className="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">cancelled</span>} Status: <RegistrationStatusBadge status={r.status} />
</div> </div>
{(() => { {(() => {
const fs = formStatuses[r.id]; const fs = formStatuses[r.id];
@@ -864,7 +919,7 @@ export default function UserDashboardPage() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="text-sm text-gray-600">Status: {activeReg.status}</div> <div className="text-sm text-gray-600">Status: <RegistrationStatusBadge status={activeReg.status} /></div>
{activeBill && ( {activeBill && (
<div className="text-sm"> <div className="text-sm">
<div>Total: {formatRand(activeBill.totalDue)}</div> <div>Total: {formatRand(activeBill.totalDue)}</div>
@@ -1004,7 +1059,7 @@ export default function UserDashboardPage() {
<div className="font-medium mb-1">Payments</div> <div className="font-medium mb-1">Payments</div>
<ul className="text-sm list-disc pl-5 space-y-1"> <ul className="text-sm list-disc pl-5 space-y-1">
{activeBill.payments.map((p: any) => ( {activeBill.payments.map((p: any) => (
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({p.method || "Payment"})</li> <li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -1019,7 +1074,7 @@ export default function UserDashboardPage() {
{canModifyActive && activeBill && activeBill.outstanding > 0 ? ( {canModifyActive && activeBill && activeBill.outstanding > 0 ? (
<button <button
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" 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"
onClick={() => router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(activeReg.id)}`)} onClick={() => payNow(activeReg.id)}
>Make payment</button> >Make payment</button>
) : ( ) : (
<> <>
@@ -1076,8 +1131,8 @@ export default function UserDashboardPage() {
{dialog.loading ? ( {dialog.loading ? (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-10 h-10 mb-3 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" /> <div className="w-10 h-10 mb-3 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="text-base font-medium">Sending tickets</div> <div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
<p className="text-sm text-gray-600 mt-1">Please wait while we send your tickets.</p> <p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
</div> </div>
) : ( ) : (
<> <>
@@ -1,207 +0,0 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
function MakePaymentContent() {
const searchParams = useSearchParams();
const registrationId = searchParams.get("registrationId");
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [registration, setRegistration] = useState<any | null>(null);
const [payments, setPayments] = useState<any[]>([]);
const [amount, setAmount] = useState<string>("");
const [priceUpdated, setPriceUpdated] = useState<{ newTotal: number; message: string } | null>(null);
// Compute effective unit price for a registration option.
// Uses priceSnapshot when available (authoritative backend price, variant-aware).
// Falls back to deadline-only early-bird calculation for legacy rows without a snapshot.
const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
const variantId: string | null = opt.variantId || null;
const base = (opt.variant?.price !== null && opt.variant?.price !== undefined)
? Number(opt.variant.price)
: Number(eo?.price || 0);
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
const tiers = variantId
? allTiers.filter((t: any) => t.variantId === variantId)
: allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const t = atTime ? new Date(atTime) : new Date();
const ref = referenceTime ? new Date(referenceTime) : t;
const applicable = tiers
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price);
if (applicable.length === 0) return base;
const price = Number(applicable[0].price);
return (price >= 0) ? price : base;
};
const totals = useMemo(() => {
if (!registration) return { totalDue: 0, totalPaid: 0, outstanding: 0 };
const now = new Date();
// totalDue uses priceSnapshot — not time-dependent, consistent with what the backend charges
const totalDue = (registration.registrationOptions || []).reduce((s: number, opt: any) => s + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = payments.reduce((s: number, p: any) => s + (p.amount || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
return { totalDue, totalPaid, outstanding };
}, [registration, payments]);
const minAllowed = useMemo(() => {
if (totals.outstanding <= 0) return 0;
return totals.outstanding >= 15 ? 15 : totals.outstanding;
}, [totals.outstanding]);
useEffect(() => {
(async () => {
if (!token || !registrationId) return;
try {
setError(null);
setLoading(true);
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
setPayments(pays);
} catch (e: any) {
setError(e?.message || "Failed to load registration");
} finally {
setLoading(false);
}
})();
}, [token, registrationId]);
useEffect(() => {
if (totals.outstanding <= 0) {
setAmount("");
}
}, [totals.outstanding]);
const submit = async () => {
if (!token || !registrationId) return;
const amt = parseFloat(amount);
if (!(amt > 0)) { setError("Enter a valid amount"); return; }
if (amt > totals.outstanding) { setError(`Amount cannot exceed outstanding (R ${totals.outstanding.toFixed(2)})`); return; }
if (amt < minAllowed) {
if (totals.outstanding >= 15) {
setError("Minimum payment is R15");
} else {
setError(`Please pay the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`);
}
return;
}
try {
setError(null);
setInfo(null);
setPriceUpdated(null);
setLoading(true);
const res = await apiFetch<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }>("/api/payments/yoco-checkout", {
method: "POST",
body: {
registrationId,
amount: amt,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
authToken: token,
});
if (res.priceUpdated) {
// Early-bird price changed — show warning and reload registration data
setPriceUpdated({ newTotal: res.newTotal ?? 0, message: res.message ?? 'Prices have changed.' });
// Reload registration so totals reflect updated priceSnapshot
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
setPayments(pays);
} catch {}
setAmount("");
return;
}
setInfo("Redirecting to payment...");
window.location.href = res.redirectUrl!;
} catch (e: any) {
setError(e?.message || "Failed to create checkout");
} finally {
setLoading(false);
}
};
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
return (
<div className="max-w-xl mx-auto w-full p-6">
<h1 className="text-2xl font-semibold mb-4">Make a payment</h1>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{priceUpdated && (
<div className="mb-4 p-3 border border-amber-300 bg-amber-50 rounded text-sm text-amber-800">
<strong>Pricing has changed.</strong> {priceUpdated.message}
<div className="mt-1">New outstanding: <strong>R {priceUpdated.newTotal.toFixed(2)}</strong></div>
<button
className="mt-2 text-xs underline text-amber-700 hover:text-amber-900"
onClick={() => setPriceUpdated(null)}
>OK, I understand continue with new price</button>
</div>
)}
{registration ? (
<div className="border rounded p-3 mb-4">
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
<div className="text-sm text-gray-600">Registration #{registration.id.slice(0,8)}</div>
<div className="mt-2 text-sm">
<div>Total: R {totals.totalDue.toFixed(2)}</div>
<div>Paid: R {totals.totalPaid.toFixed(2)}</div>
<div>Outstanding: <span className={totals.outstanding>0?"text-red-600":"text-green-700"}>R {totals.outstanding.toFixed(2)}</span></div>
</div>
{(() => {
const tiers = (registration.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
const upcoming = tiers.map((t: any) => ({ ...t, deadline: new Date(t.deadline) })).filter((t: any) => new Date() < t.deadline).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
if (upcoming.length === 0) return null;
const d = upcoming[0].deadline as Date;
const dateStr = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
return <div className="mt-2 text-xs text-amber-700">Early bird pricing applies if paid before {dateStr}.</div>;
})()}
</div>
) : (
<p className="text-sm text-gray-600 mb-4">Loading registration...</p>
)}
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<input
type="number"
min={minAllowed.toFixed(2)}
max={totals.outstanding > 0 ? totals.outstanding.toFixed(2) : "0"}
step="1"
placeholder={totals.outstanding >= 15 ? "Enter amount (min R15)" : `Enter amount (max R ${totals.outstanding.toFixed(2)})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={totals.outstanding <= 0}
className="w-full border rounded px-3 py-2 mb-1 disabled:opacity-60"
/>
{totals.outstanding > 0 && (
<p className="text-xs text-gray-600 mb-3">Minimum payment {totals.outstanding >= 15 ? "is R15" : `is the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`}.</p>
)}
<button
disabled={loading || totals.outstanding <= 0}
onClick={submit}
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
>{loading?"Creating checkout...": totals.outstanding <= 0 ? "No outstanding amount" : "Pay now"}</button>
</div>
);
}
export default function MakePaymentPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<MakePaymentContent />
</Suspense>
);
}
@@ -0,0 +1,203 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
interface PaymentItem {
id: string;
amount: number;
method: string | null;
createdAt: string;
registrationId: string | null;
eventId: string | null;
registration?: { event?: { title?: string } | null } | null;
event?: { title?: string } | null;
}
const formatRand = (n: number) => `R ${Math.abs(n).toFixed(2)}`;
export default function UserPaymentsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// Filters
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [method, setMethod] = useState("");
const [kind, setKind] = useState<"" | "payment" | "refund">("");
const buildQuery = useCallback((p: number) => {
const qs = new URLSearchParams({ page: String(p), limit: "25" });
if (startDate) qs.set("startDate", startDate);
if (endDate) qs.set("endDate", endDate);
if (method) qs.set("method", method);
if (kind) qs.set("kind", kind);
return `/api/payments/mypayments?${qs.toString()}`;
}, [startDate, endDate, method, kind]);
const loadPayments = useCallback(async (p = 1) => {
if (!token) return;
setError(null);
setFetching(true);
try {
const res = await apiFetch<any>(buildQuery(p), { authToken: token });
setPayments(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
setPage(p);
} catch (e: any) {
setError(e?.message || "Failed to load payments");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]);
const goToPage = (p: number) => loadPayments(p);
return (
<div className="max-w-4xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">My Payments</h1>
<button
className="px-2.5 py-1 text-xs 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"
onClick={() => router.push("/dashboard/user")}
>Back to dashboard</button>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input
type="date"
className="border rounded px-2 py-1.5 text-sm"
value={startDate}
onChange={e => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input
type="date"
className="border rounded px-2 py-1.5 text-sm"
value={endDate}
onChange={e => setEndDate(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
<option value="voucher">Voucher</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Type</label>
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as any)}>
<option value="">Payments &amp; refunds</option>
<option value="payment">Payments only</option>
<option value="refund">Refunds only</option>
</select>
</div>
{(startDate || endDate || method || kind) && (
<button
className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200"
onClick={() => { setStartDate(""); setEndDate(""); setMethod(""); setKind(""); }}
>Clear filters</button>
)}
</div>
<div className="text-xs text-gray-500 mb-2">
{total} payment{total !== 1 ? "s" : ""} total
{total > 0 && ` — page ${page} of ${totalPages}`}
</div>
<ul className="text-sm space-y-2">
{payments.map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
const eventTitle = p.registration?.event?.title || p.event?.title;
return (
<li key={p.id} className={`border rounded p-2 ${isRefund ? "bg-red-50" : ""}`}>
<div className="flex justify-between">
<div className={`font-medium ${isRefund ? "text-red-700" : ""}`}>
{isRefund ? "-" : ""}{formatRand(amt)}
{isRefund && <span className="text-xs text-red-700 ml-1">(refund)</span>}
</div>
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
</div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
</li>
);
})}
{payments.length === 0 && !fetching && (
<li className="text-gray-500">No payments found.</li>
)}
{fetching && <li className="text-gray-400">Loading</li>}
</ul>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<span className="text-gray-500">Page {page} of {totalPages}</span>
<div className="flex gap-1">
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page <= 1 || fetching}
onClick={() => goToPage(page - 1)}
> Prev</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400"></span>
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>{p}</button>
)
)}
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page >= totalPages || fetching}
onClick={() => goToPage(page + 1)}
>Next </button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone"; import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function UserProfilePage() { export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth(); const { user, token, logout, updateToken } = useAuth();
@@ -19,7 +20,7 @@ export default function UserProfilePage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email"); const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [profileMsg, setProfileMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [profileMsg, setProfileMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
const hasValidPhone = isValidZAPhone(phone); const hasValidPhone = isValidZAPhone(phone);
@@ -63,7 +64,7 @@ export default function UserProfilePage() {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [pwMsg, setPwMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [pwMsg, setPwMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingPw, setSavingPw] = useState(false); const [savingPw, setSavingPw] = useState(false);
const changePassword = async (e: React.FormEvent) => { const changePassword = async (e: React.FormEvent) => {
@@ -98,7 +99,7 @@ export default function UserProfilePage() {
}; };
// ── Revoke sessions ─────────────────────────────────────────────────────── // ── Revoke sessions ───────────────────────────────────────────────────────
const [revokeMsg, setRevokeMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [revokeMsg, setRevokeMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [revoking, setRevoking] = useState(false); const [revoking, setRevoking] = useState(false);
const revokeSessions = async () => { const revokeSessions = async () => {
@@ -126,7 +127,7 @@ export default function UserProfilePage() {
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle"); const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false); const [deleteData, setDeleteData] = useState(false);
const [closePassword, setClosePassword] = useState(""); const [closePassword, setClosePassword] = useState("");
const [closeMsg, setCloseMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); const [closeMsg, setCloseMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const submitAccountClosure = async (e: React.FormEvent) => { const submitAccountClosure = async (e: React.FormEvent) => {
@@ -2,6 +2,7 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
@@ -11,7 +12,7 @@ export default function ResetPasswordPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]); const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]);
+32 -10
View File
@@ -3,12 +3,14 @@ import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
function RegistrationSuccessContent() { function RegistrationSuccessContent() {
const search = useSearchParams(); const search = useSearchParams();
const router = useRouter(); const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId") || search.get("id"); const registrationId = search.get("registrationId") || search.get("id");
const fallbackTotalParam = search.get("totalDue"); const fallbackTotalParam = search.get("totalDue");
const fallbackTotalDue = React.useMemo(() => { const fallbackTotalDue = React.useMemo(() => {
@@ -22,6 +24,7 @@ function RegistrationSuccessContent() {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [info, setInfo] = React.useState<string | null>(null); const [info, setInfo] = React.useState<string | null>(null);
const [payLoading, setPayLoading] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
@@ -31,7 +34,6 @@ function RegistrationSuccessContent() {
try { try {
setLoading(true); setLoading(true);
// Try to fetch registration and event form // Try to fetch registration and event form
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null; const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
if (r) { if (r) {
setReg(r); setReg(r);
@@ -49,7 +51,7 @@ function RegistrationSuccessContent() {
setLoading(false); setLoading(false);
} }
})(); })();
}, [registrationId]); }, [registrationId, token]);
const totalDue = React.useMemo(() => { const totalDue = React.useMemo(() => {
if (!reg) return 0; if (!reg) return 0;
@@ -69,7 +71,6 @@ function RegistrationSuccessContent() {
// attempt ticket generation if status is paid (may fail if required forms aren't completed) // attempt ticket generation if status is paid (may fail if required forms aren't completed)
(async () => { (async () => {
try { try {
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (token && reg?.id) { if (token && reg?.id) {
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } }); await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } });
} }
@@ -77,11 +78,33 @@ function RegistrationSuccessContent() {
setTimeout(() => router.replace('/dashboard'), 600); setTimeout(() => router.replace('/dashboard'), 600);
})(); })();
} }
}, [reg, totalDue, router]); }, [reg, totalDue, router, token]);
const goPay = () => { const goPay = async () => {
if (!registrationId) return; if (!registrationId) return;
router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(registrationId)}`); if (!token) { setError('Please login to pay.'); return; }
try {
setError(null);
setPayLoading(true);
const { createFullPaymentCheckout } = await import('@/lib/api');
const res = await createFullPaymentCheckout(token, registrationId);
if (res.priceUpdated) {
// Early-bird price changed since registration — surface the new total and
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
try {
const r = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
if (r) setReg(r);
} catch {}
return;
}
if (!res.redirectUrl) { setError('Failed to create checkout'); return; }
window.location.href = res.redirectUrl;
} catch (e: any) {
setError(e?.message || 'Failed to create checkout');
} finally {
setPayLoading(false);
}
}; };
const goDashboard = () => { const goDashboard = () => {
@@ -108,9 +131,9 @@ function RegistrationSuccessContent() {
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && ( {(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
<button <button
onClick={goPay} onClick={goPay}
disabled={!registrationId} disabled={!registrationId || payLoading}
className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60" className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60"
>Pay with Yoco</button> >{payLoading ? "Creating checkout..." : "Pay with Yoco"}</button>
)} )}
<button <button
onClick={goDashboard} onClick={goDashboard}
@@ -129,6 +152,7 @@ function RegistrationSuccessContent() {
} }
function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }: { reg: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; setError: any; setInfo: any; }) { function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }: { reg: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; setError: any; setInfo: any; }) {
const { token } = useAuth();
const registrationId = reg?.id; const registrationId = reg?.id;
const mainTickets = (reg?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0); const mainTickets = (reg?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets); const count = Math.max(0, mainTickets);
@@ -157,7 +181,6 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
const submit = async () => { const submit = async () => {
try { try {
setError(null); setInfo(null); setError(null); setInfo(null);
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (!token) { setError('Please login to submit forms.'); return; } if (!token) { setError('Please login to submit forms.'); return; }
const payload = [] as any[]; const payload = [] as any[];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -177,7 +200,6 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
const saveDraft = async () => { const saveDraft = async () => {
try { try {
setError(null); setInfo(null); setError(null); setInfo(null);
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (!token) { setError('Please login to save drafts.'); return; } if (!token) { setError('Please login to save drafts.'); return; }
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
method: 'PUT', method: 'PUT',
+102 -30
View File
@@ -85,6 +85,13 @@ function fmtCurrency(val: number) {
return val === 0 ? "Free" : `R${val.toFixed(2)}`; return val === 0 ? "Free" : `R${val.toFixed(2)}`;
} }
// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0),
// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
function looksLikePhone(s: string): boolean {
const digits = s.replace(/\D/g, "");
return digits.length >= 9 && digits.length <= 11;
}
// ─── Kiosk Page ─────────────────────────────────────────────────────────────── // ─── Kiosk Page ───────────────────────────────────────────────────────────────
export default function SelfServicePage() { export default function SelfServicePage() {
const [screen, setScreen] = useState<Screen>("setup"); const [screen, setScreen] = useState<Screen>("setup");
@@ -113,8 +120,12 @@ export default function SelfServicePage() {
const [createAccount, setCreateAccount] = useState(false); const [createAccount, setCreateAccount] = useState(false);
const [visitorPassword, setVisitorPassword] = useState(""); const [visitorPassword, setVisitorPassword] = useState("");
const [accountExists, setAccountExists] = useState(false); const [accountExists, setAccountExists] = useState(false);
const [checkingAccount, setCheckingAccount] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({}); const [quantities, setQuantities] = useState<Record<string, number>>({});
// ── Account lookup (search-by-email/phone) state ──────────────────
const [lookupQuery, setLookupQuery] = useState("");
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
const [formLoading, setFormLoading] = useState(false); const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@@ -180,7 +191,7 @@ export default function SelfServicePage() {
setEventsLoading(true); setEventsLoading(true);
try { try {
const data: KioskEvent[] = await apiFetch( const data: KioskEvent[] = await apiFetch(
"/api/events/all?includePast=false&includeInactive=false", "/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
{ authToken: token } { authToken: token }
); );
setEvents(data); setEvents(data);
@@ -416,6 +427,8 @@ export default function SelfServicePage() {
setCreateAccount(false); setCreateAccount(false);
setVisitorPassword(""); setVisitorPassword("");
setAccountExists(false); setAccountExists(false);
setLookupQuery("");
setLookupMessage(null);
setFormError(null); setFormError(null);
setCurrentRegistrationId(null); setCurrentRegistrationId(null);
setCurrentUserId(null); setCurrentUserId(null);
@@ -431,33 +444,66 @@ export default function SelfServicePage() {
}; };
}, []); }, []);
// ─── Check whether an account already exists for the entered email/phone ── // ─── Explicit account lookup — only runs when the operator submits the search
useEffect(() => { // (Enter or the Search button), never on keystroke. Matches exactly against a
const email = visitorEmail.trim(); // single email or phone value and only ever returns that one matched account
const phone = visitorPhone.trim(); // (or nothing) — never a broader/fuzzy match. ──
if (!supervisorToken || (!email && !phone)) { async function handleLookup() {
setAccountExists(false); const q = lookupQuery.trim();
return; if (!q || !supervisorToken) return;
} setLookupLoading(true);
const handle = setTimeout(async () => { setLookupMessage(null);
setCheckingAccount(true); try {
try { // Classify the query as email- or phone-shaped and send it as only that param —
const params = new URLSearchParams(); // sending the same raw string as both could let digits embedded in an email
if (email) params.set("email", email); // (e.g. a numeric local-part) get misread as an unrelated phone number.
if (phone) params.set("phone", phone); const isEmailLike = q.includes("@");
const data = await apiFetch<{ exists: boolean }>( const params = new URLSearchParams();
`/api/users/check-exists?${params.toString()}`, if (isEmailLike) params.set("email", q);
{ authToken: supervisorToken } else params.set("phone", q);
); const data = await apiFetch<{
setAccountExists(!!data?.exists); exists: boolean;
} catch { user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null;
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
if (data?.exists && data.user) {
const found = data.user;
setVisitorName(found.name);
setVisitorEmail(found.email || "");
setVisitorPhone(found.phoneNumber || "");
setNotificationPref(found.notificationPreference);
setAccountExists(true);
setLookupMessage("Account found — details filled in below.");
} else {
// No match — still save the retype: carry the query into whichever field it
// resembles, and leave everything else blank for a fresh entry.
setAccountExists(false); setAccountExists(false);
} finally { setVisitorName("");
setCheckingAccount(false); setNotificationPref("email");
if (isEmailLike) {
setVisitorEmail(q);
setVisitorPhone("");
} else if (looksLikePhone(q)) {
setVisitorPhone(q);
setVisitorEmail("");
} else {
setVisitorEmail("");
setVisitorPhone("");
}
setLookupMessage("No matching account found.");
} }
}, 400); } catch {
return () => clearTimeout(handle); setLookupMessage("Lookup failed. Please try again.");
}, [visitorEmail, visitorPhone, supervisorToken]); } finally {
setLookupLoading(false);
}
}
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
handleLookup();
}
}
// Existing accounts are linked automatically — never show the "create account" toggle for them // Existing accounts are linked automatically — never show the "create account" toggle for them
useEffect(() => { useEffect(() => {
@@ -628,6 +674,34 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p> <p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</div> </div>
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50">
<label className="block text-sm font-medium text-gray-700 mb-1">Look up existing account</label>
<p className="text-xs text-gray-500 mb-2">Search by email or phone number to fill in a returning visitor&apos;s details.</p>
<div className="flex gap-2">
<input
type="text"
value={lookupQuery}
onChange={(e) => setLookupQuery(e.target.value)}
onKeyDown={handleLookupKeyDown}
className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Email or phone number"
/>
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading || !lookupQuery.trim()}
className="px-5 rounded-xl bg-gray-700 text-white font-medium hover:bg-gray-800 disabled:opacity-50 transition"
>
{lookupLoading ? "Searching…" : "Search"}
</button>
</div>
{lookupMessage && (
<p className={`text-sm mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
{lookupMessage}
</p>
)}
</div>
<form onSubmit={handleRegister} className="space-y-5"> <form onSubmit={handleRegister} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label> <label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
@@ -808,9 +882,7 @@ export default function SelfServicePage() {
</div> </div>
<div> <div>
<p className="font-medium text-gray-800">Create an account</p> <p className="font-medium text-gray-800">Create an account</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">Save your details for future events</p>
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
</p>
</div> </div>
</label> </label>
{createAccount && ( {createAccount && (
+24
View File
@@ -0,0 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Like useState, but a truthy value auto-clears back to the initial falsy value after
* `ms` milliseconds. Every call to the setter cancels any pending timer and (if the new
* value is truthy) arms a fresh one, so rapid-fire updates reset the countdown instead of
* cutting it short. Meant for post-action confirmation/error banners that shouldn't linger.
*/
export function useDismissingState<T>(initial: T, ms = 7000) {
const [value, setValue] = useState<T>(initial);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const set = useCallback((next: T) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setValue(next);
if (next) {
timeoutRef.current = setTimeout(() => setValue(initial), ms);
}
}, [ms, initial]);
useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);
return [value, set] as const;
}
+16
View File
@@ -155,3 +155,19 @@ export function fetchAllUsers(token: string, params: Record<string, string> = {}
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> { export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
return fetchAllPages("/api/payments", token, params); return fetchAllPages("/api/payments", token, params);
} }
// Creates a Yoco checkout for a registration's full remaining outstanding balance.
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }> {
return apiFetch("/api/payments/yoco-checkout", {
method: "POST",
authToken: token,
body: {
registrationId,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
});
}
+26
View File
@@ -0,0 +1,26 @@
// Payment.method is free-text — online checkouts get tagged with whatever wallet type the
// gateway reports (apple_pay, google_pay, ...), not just the manual-entry methods below.
// Mirrors normalizeUserMethod in backend/src/controllers/paymentController.js: card-network
// wallets count as "card" (same settlement, no separate float); anything else is "other".
const USER_FACING_METHODS = ["cash", "card", "eft", "voucher"];
const CARD_ALIASES = ["apple_pay", "google_pay"];
const METHOD_LABELS: Record<string, string> = {
cash: "Cash",
card: "Card",
eft: "EFT",
voucher: "Voucher",
other: "Other",
};
export function normalizePaymentMethod(method: string | null | undefined): string {
const m = String(method || "").toLowerCase();
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return "card";
return "other";
}
export function formatPaymentMethod(method: string | null | undefined): string {
if (!method) return "Payment";
return METHOD_LABELS[normalizePaymentMethod(method)];
}
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events", "name": "hope-events",
"version": "1.0.1", "version": "1.3.1",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",
@@ -8,7 +8,8 @@
"start:backend": "cd backend && npm run start", "start:backend": "cd backend && npm run start",
"start:frontend": "cd frontend && npm run start", "start:frontend": "cd frontend && npm run start",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"", "start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"" "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",