Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 d268236686 Bump to 1.26.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:51 +02:00
joshua 95a8bd0796 Merge branch 'chore/demo-data' 2026-10-21 12:21:03 +02:00
joshuaandClaude Opus 5.5 101632e758 Add set-demo script for staging demo data
npm run set-demo wipes the database, keeping the site settings and the
admin account, and stages six upcoming events with registrations,
payments, tickets, a waitlist, camp cabins, coupons, costs and funnel
stats for showing the platform to other churches. Documented in
backend/DEMO_DATA.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:21:03 +02:00
joshuaandClaude Opus 5.5 7cf5f79d01 Merge branch 'chore/frontend-lint'
Includes chore/deps-security-prisma7 (Prisma 7 upgrade and npm audit fixes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:16:38 +02:00
joshuaandClaude Opus 5.5 00464c086d Bump to 1.26.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:16:38 +02:00
joshuaandClaude Opus 5.5 4e07034bbd Add frontend lint cleanup to the changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:11:49 +02:00
joshuaandClaude Opus 5.5 2d6c840614 Type the reports page and turn lint back on in builds
ReportsV2 gets typed API responses (funnel, feedback summary, coupon
redemptions, cashup audit, consent) and derived rows. Master-order
quantities move into a per-row map, so an option name can no longer
overwrite a fixed column. Consent row types move to src/types. Removes
unused export helpers and state.

With the frontend now at zero lint errors and warnings, next build runs
ESLint again (eslint.ignoreDuringBuilds is gone).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 235da5bf61 Type the at-the-door page and share pricing helpers
Each door panel and modal gets typed props; unused setError/setInfo/
onCreated props are dropped. The current early-bird price helpers move
to src/lib/pricing.ts (shared with manual registration), dead fallbacks
for a legacy registration 'options' field are removed, and the refund
history now shows a refund's reason (stored in its status field) instead
of a 'reason' field that never exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 f6581eb441 Type payments and user dashboard pages
Moves the duplicated registration-option pricing helper into
src/lib/pricing.ts and drops an unused users list from payments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 0a8788afff Type supervisor forms, manual registration and events pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 b560d0d340 Type registrations, email and WhatsApp messaging pages
Adds typed payloads and results for the email/WhatsApp attendee and
broadcast endpoints. The WhatsApp page's messageDirty flag becomes a ref
(it only gated the template effect), and a no-op branch in the email
template effect is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 b2439a781e Type more frontend pages and fix their hook dependencies
Cashup, users, profile, consent, allocations, event options, forms,
event tickets, ticket scanning and manual registration now use real
types. Data loaders are wrapped in useCallback and listed as effect
dependencies. Also adds shared Attendee, Section and form response
types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 545f3d913e Add shared API types and type the smaller frontend pages
Expand src/types with User, Event, EventOption, Registration, Payment,
Ticket, paginated results and dashboard stats, modelled on the Prisma
schema. apiFetch now defaults to unknown instead of any. Dashboard
loaders are wrapped in useCallback so their effects can list them as
dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 619b5b9a93 Type caught errors as unknown in the frontend
Replace `catch (e: any)` with `catch (e)` and read the message through a
new errorMessage() helper. ApiError.data gets a real type, and
useDismissingState's setter keeps one identity across renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:00 +02:00
joshuaandClaude Opus 5.5 958a5a5950 Fix frontend lint: links, unused code, quotes, img elements
- Internal links use next/link instead of <a> (no full page reload).
- Remove dead code: RegisterForm's unreachable Yoco checkout branch, the
  unused AttendeeForms on the registration success page, unused Geist
  fonts, and assorted unused variables and catch bindings.
- Escape quotes in JSX text; replace @ts-ignore in apiFetch with a cast.
- Keep the intentional raw <img> elements (data URLs, runtime API host)
  with a documented lint exception; the event editor uses ApiImage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:00 +02:00
joshuaandClaude Opus 5.5 f2be3a8b45 Upgrade to Prisma 7 and clear npm audit advisories
- Prisma 5.22 -> 7.10 using the @prisma/adapter-pg driver adapter; the
  datasource URL moves to backend/prisma.config.js. All clients are built
  in src/config/db.js, and index.js reuses the shared client.
- Backend: patch follow-redirects, drop nodemon for `node --watch`, and use
  js-yaml 4 for the Jest coverage loader. npm audit reports 0.
- Frontend: override postcss-selector-parser to 7.1.7 (Tailwind 3 output
  is unchanged). braces has no patched release and stays, build/lint only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:58:11 +02:00
joshua ce22f44f7c Merge branch 'fix/mobile-cookie-banner' 2026-10-20 12:47:41 +02:00
joshuaandClaude Opus 5.5 30b567f091 Bump to 1.25.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:47:36 +02:00
joshuaandClaude Opus 5.5 0b22eca0fb Lift cookie banner and help button above mobile bottom nav
The cookie consent banner was fixed at bottom-0 under the z-50 BottomNav, so
on mobile it was hidden. It now sits above the nav (h-16 + safe-area) and
publishes its height as --cookie-banner-h, which HelpFab adds to its offset
so the banner no longer covers the help button either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:47:13 +02:00
joshua 5529811119 Merge branch 'feat/yoco-settings' 2026-10-20 10:28:57 +02:00
joshuaandClaude Opus 5.5 1f0b2b1425 Bump to 1.25.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:53 +02:00
joshuaandClaude Opus 5.5 865a16abe9 Store Yoco secrets in the database; add Yoco settings tab
The Yoco secret key and webhook secret now live in AppSetting (AES-256-GCM
encrypted), with .env as a fallback. A new Site Settings → Yoco tab shows
them masked card-style, registers a webhook with Yoco (storing the secret it
returns) or accepts one entered by hand, and lists/deletes the account's
webhooks. Generic settings endpoints never read or write these keys.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:46:15 +02:00
joshua cd42ca375e Merge branch 'feat/impersonate-user' 2026-10-19 18:25:10 +02:00
128 changed files with 6535 additions and 2453 deletions
+91
View File
@@ -7,6 +7,97 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.26.1] - 2026-10-21
### Added
- `npm run set-demo` in the backend replaces the database contents with demo data for showing the platform to other churches. It keeps the site settings and the admin account, and stages six upcoming events with registrations, payments, tickets, a waitlist, camp cabins, coupons, costs and funnel stats. See `backend/DEMO_DATA.md`.
## [1.26.0] - 2026-10-21
### Changed
- Upgraded Prisma from 5.22 to 7.10. Prisma 7 connects through the `@prisma/adapter-pg` driver adapter (node-postgres) instead of the Rust query engine. The database URL moved from `schema.prisma` to the new `backend/prisma.config.js`, which loads `backend/.env` itself. All Prisma clients are now created in `src/config/db.js`, and the server shares one client instead of opening two connection pools. No schema or migration changes.
- Prisma 7 checks TLS certificates on database connections. A `DATABASE_URL` that uses SSL with a self-signed or otherwise invalid certificate now fails to connect where Prisma 5 accepted it.
- `npm run dev` in the backend now uses Node's built-in `node --watch` instead of `nodemon`.
- The frontend now passes ESLint with no errors or warnings, down from 1,137, and `next build` runs ESLint again. It was skipped before (`eslint.ignoreDuringBuilds`), which is how the issues built up.
- API data is now typed. `src/types` describes the records the backend returns, modelled on the Prisma schema, and `apiFetch` returns `unknown` unless the caller names a type, so untyped responses no longer pass silently.
- Effects list everything they depend on. Data loaders are wrapped in `useCallback`, so a page reloads when its inputs change instead of showing stale data.
- The pricing helpers that were copied into several pages now live in `src/lib/pricing.ts`.
### Fixed
- Internal links on the home page, the 404 page and "Forgot your password?" now navigate client-side instead of reloading the whole page.
- Changing the page size on Admin → Users fetched the list twice; it now fetches once.
- At the door, the refund history now shows a refund's reason. The page was reading a field that doesn't exist, so the reason never appeared.
- In the Master Orders report, a ticket option named like one of the fixed columns (e.g. "name") could overwrite that column's value.
- Removed code that could never run: an unreachable "Pay with Yoco" button on the registration form, an unused attendee-forms block on the registration success page, unused export helpers in Reports, and fonts that were downloaded but never applied.
### Security
- Backend `npm audit` now reports 0 vulnerabilities:
- Patched `follow-redirects` (pulled in by `axios`) to 1.16.1, which fixes leaking credential headers on cross-origin redirects.
- Removed `nodemon`, the only backend package that pulled in `braces`.
- Jest's coverage loader now uses `js-yaml` 4, which drops the `sprintf-js` advisory.
- Frontend: overrode `postcss-selector-parser` to 7.1.7, used by the Tailwind 3 build. The generated CSS is byte-identical. The remaining frontend findings (7 high) are all the same `braces` advisory (GHSA-vfj7-8cjw-p6xm). It has no patched release, comes in only through build and lint tools (Tailwind 3 and `eslint-config-next`), and only ever handles glob patterns from our own config, never user input.
## [1.25.1] - 2026-10-20
### Fixed
- On mobile, the cookie consent banner now sits above the bottom navigation bar instead of being hidden behind it.
- The floating help button moves up above the cookie consent banner while it is showing, so the banner no longer covers it.
## [1.25.0] - 2026-10-20
### Added
- A **Yoco** tab in Site Settings for online payments:
- **Secret key:** shown masked like a card number (`sk_test_***…aba`) with a Test/Live badge. Yoco checks a new key before it is saved.
- **Webhook secret:** **Generate** registers this server's webhook URL (`BACKEND_URL/api/webhooks/yoco`, editable) with Yoco and saves the secret Yoco returns. **Enter manually** takes the secret of a webhook you registered yourself.
- **Registered webhooks:** every webhook on the Yoco account, with its URL and mode. It shows which one is active and warns when a URL isn't this server. Each can be deleted; deleting the active one also removes its stored secret.
- New admin endpoints under `/api/yoco` (config and webhooks). Changes are written to the admin audit log, without the secret values.
### Changed
- The Yoco secret key and webhook secret are now stored in the database, AES-256-GCM encrypted, instead of only in `backend/.env`. `YOCO_SECRET_KEY` and `YOCO_WEBHOOK_SECRET` still work as fallbacks when the database has no value. `npm run migrate:env-to-db` moves them across.
- Yoco secrets are never returned by `/api/settings/all` and can't be changed through `PUT /api/settings`.
- Starting a checkout or sponsorship without a Yoco key now gives a clear "online payments are not configured" message instead of failing at Yoco.
## [1.24.0] - 2026-10-19
### Changed
- Ticket transfers now need the new holder to accept.
- "Transfer" (in My Events, or At the door → Tickets) sends the person a link by email and/or WhatsApp, to the details typed.
- Nothing changes on the ticket until they accept on the new `/transfer/<link>` page. They can also decline.
- On accepting, the transfer completes as before: new QR code, ticket delivery, account activation link, parental consent on youth events.
- My Events shows "Waiting for … to accept" with a Cancel button.
- Offers expire after 7 days or when the event starts, and a new offer replaces an open one.
- Privacy for group tickets and transfers: there is no user search, and nothing reveals whether an email or phone number belongs to an account.
- Registration responses no longer include named attendees' account links.
- If an email and phone point at two different accounts, this is no longer reported to the person typing them. The email's account is used.
- Terms of Use and Privacy Policy updated for everything added since 1.12:
- booking for other people, ticket transfers, and youth-event consent with health information and how long it is kept;
- who can see what (door staff, group leaders, supervisors), and read-only admin support access;
- sponsorships, waitlists, discount codes and feedback;
- two-factor authentication and offline scanning.
- The Privacy Policy has a full **cookie policy**: every item the site stores in the browser (necessary, staff-only and optional), the optional visit statistics, and the third-party cookies from the Google Maps embed and Yoco checkout.
- A new "Cookie settings" link in the footer (and a button in the policy) lets visitors change their choice.
- The API docs page (`/docs`) now lists every mounted route. 45 were missing, including dashboard stats, backups, attachments, the audit log, ticket transfer links and WhatsApp admin.
### Fixed
- After activating an account, the visitor is now really signed in. The token was saved under a key the app never read, so the "auto-login" didn't work.
### Notes
- `npm install` succeeds in the root, `backend` and `frontend` with no missing packages.
- The remaining `npm audit` findings are all in development/build tools (Jest, nodemon, ESLint, Tailwind's build), and none affect the running site.
- `braces` and `sprintf-js` have no patched release.
- `postcss-selector-parser` 7 would break Tailwind 3.
- Requires the `add_transfer_requests` database migration.
## [1.23.0] - 2026-10-19
### Added
+9 -4
View File
@@ -164,7 +164,8 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
## Payments, donations & refunds
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook.
confirmed by a signed webhook. Keys and the webhook are set up from
Site Settings → Yoco.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
@@ -306,9 +307,12 @@ logout controls. Disconnected sessions are recovered automatically.
- Registration detail: itemised bill (early-bird lines grouped), payments,
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
- **Ticket transfer** — hand a ticket to someone else before the event (name plus
email/phone). They get a new ticket and QR code, and an inactive account with an
activation link if they're new. The old code stops working, and scanning it says it
- **Ticket transfer** — offer a ticket to someone else before the event by typing
their name and email/phone (no user search, and nothing reveals whether they have an
account). They get a link to accept or decline; the ticket only moves once they
accept, and the sender can cancel until then (offers expire after 7 days or at the
event start). On accepting they get a new ticket and QR code, and an inactive account
with an activation link if they're new. The old code stops working, and scanning it says it
was transferred, both online and in offline-mode sync reports. One place can be split
off a ticket covering several people. Billing stays with the payer. It's not allowed
once a ticket has been scanned, and it can be turned off per event (supervisors can
@@ -453,6 +457,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
| Yoco | Secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
| Backups | List, run now, download database backups |
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
+1 -2
View File
@@ -59,8 +59,6 @@ Documentation: see Frontend README for environment and scripts.
- DATABASE_URL
- JWT_SECRET
- PORT (optional; defaults to 3000 if not set)
- YOCO_SECRET_KEY (for payments)
- YOCO_WEBHOOK_SECRET (to verify webhooks)
- EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM (for ticket emails)
- FRONTEND_URL or APP_BASE_URL (used in email links)
@@ -69,6 +67,7 @@ Documentation: see Frontend README for environment and scripts.
Example for default backend: NEXT_PUBLIC_API_URL=http://localhost:5000
- Organisation name, logo, brand colour, and notification emails are configured via the admin panel (Admin → Site Settings) and do not need to be set in .env files.
- Yoco payments are set up in Admin → Site Settings → Yoco: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
### 3) Run the apps
+10
View File
@@ -57,6 +57,16 @@ or Branding tab has been saved even once — the wizard always writes
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
## Payments (Yoco)
Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints).
| Setting | Default when unset | Notes |
|---|---|---|
| `yoco_secret_key` | `YOCO_SECRET_KEY` env var, else *(blank)* | Stored AES-256-GCM encrypted. If blank, online checkouts and sponsorships fail with "online payments are not configured". |
| `yoco_webhook_secret` | `YOCO_WEBHOOK_SECRET` env var, else *(blank)* | Stored encrypted. If blank, Yoco webhooks are rejected, so payments aren't confirmed automatically. |
| `yoco_webhook_id` | *(blank)* | The Yoco webhook whose secret is stored, set when it's generated from the tab. Used to mark it "Active" and to clear the secret when it's deleted. |
## Legal (Terms of Use / Privacy Policy)
| Setting | Default when unset | Notes |
+6 -3
View File
@@ -33,9 +33,11 @@ FRONTEND_URL=http://localhost:3000
# EMAIL_PASS=your_email_password
# EMAIL_FROM=no-reply@example.com
# ─── Payments (Yoco) ──────────────────────────────────────────────────────────
YOCO_SECRET_KEY=your_yoco_secret_key
YOCO_WEBHOOK_SECRET=your_yoco_webhook_secret
# ─── Payments (Yoco) — optional fallbacks ─────────────────────────────────────
# Preferred: set the secret key and generate the webhook in Admin → Site Settings →
# Yoco (stored AES-256 encrypted). These are only used if the database has no value.
# YOCO_SECRET_KEY=sk_test_...
# YOCO_WEBHOOK_SECRET=whsec_...
# ─── Public URLs ──────────────────────────────────────────────────────────────
# Used to build links in emails (password reset, activation, etc.)
@@ -72,6 +74,7 @@ BACKUP_ENABLED=true
# - Registration notification emails
# - SMTP settings (username and password stored AES-256-GCM encrypted, key
# derived from JWT_SECRET — do not change JWT_SECRET after saving SMTP creds)
# - Yoco secret key and webhook secret (encrypted the same way)
# - Legal page content (operator name, Information Officer, website URL, etc.)
# The first-time setup wizard handles initial configuration on fresh deployments.
# You no longer need ORG_NAME, ORG_TAGLINE, EMAIL_HEADER_COLOR, REGISTRATIONS_EMAIL,
+23 -2
View File
@@ -373,9 +373,18 @@ Related changes:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }` (date of birth, plus parent details for a minor, on youth events) |
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one |
| DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer |
| GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` |
| POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) |
| POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline |
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
Privacy:
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
- Offers expire after 7 days or when the event starts.
- Only an HMAC of the link token is stored.
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
- `Event.allowTicketTransfer` turns it off for non-supervisors.
@@ -697,7 +706,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
## Settings
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, Yoco secrets) are stored AES-256-GCM encrypted. Yoco secrets are never returned by `/api/settings/all` and can't be written through `PUT /api/settings`; use `/api/yoco` below.
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
@@ -709,6 +718,18 @@ For what each individual setting key defaults to when it's never been saved, see
| PUT | `/api/settings` | admin | Upsert settings; `smtp_user`/`smtp_pass` encrypted before storage; sending `••••••••` for `smtp_pass` keeps existing value |
| POST | `/api/settings/test-smtp` | admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
## Yoco
Managed from Admin → Site Settings → Yoco. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/yoco/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/api/yoco/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/api/yoco/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/api/yoco/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/api/yoco/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
### POST /api/settings/test-smtp
```json
{
+135
View File
@@ -0,0 +1,135 @@
# Demo data
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
for showing the platform to other churches. It stages six upcoming events with
registrations, payments, tickets and people, so every dashboard, report and check-in
screen has something to show.
> **This deletes real data.** Run it only against a database you're happy to wipe, such
> as a dedicated demo deployment. It is not something to run on a live church site.
---
## Running it
```bash
cd backend
npm run set-demo # shows the database it will wipe and asks you to type "demo"
npm run set-demo -- --yes # no prompt (for scripts)
```
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
for one run, set it in the environment first. A value already in the environment wins
over `.env`:
```bash
DATABASE_URL="postgresql://user:pass@host:5432/hope_demo" npm run set-demo
```
It takes a few seconds and is safe to re-run as often as you like. Each run wipes the
previous demo and creates it again.
### Before you run it
- The admin account must already exist. By default that's `joshua@crosscode.co.za`.
To keep a different account, set `DEMO_ADMIN_EMAIL`:
```bash
DEMO_ADMIN_EMAIL="admin@example.org" npm run set-demo
```
If the account isn't found, the script stops before changing anything.
- Migrations must be up to date (`npm run prisma:deploy`).
---
## What it keeps and what it clears
| Kept | Cleared |
|---|---|
| Site settings (`AppSetting`): org name, branding, SMTP, Yoco, WhatsApp, legal pages | Every event, with its options, variants, early-bird tiers, sections, forms, costs and cash-ups |
| The admin account, including its login history, trusted devices and 2FA | Every other user |
| Migration history | Registrations, payments, tickets, scans, coupons, waitlists, consents, cabins, feedback, analytics, audit log |
| Uploaded files on disk (`public/uploads`) | Queued scheduled emails/WhatsApps (`data/scheduled-emails.json`) |
The wipe and the new data are written in one database transaction. If anything fails,
nothing changes.
---
## What it creates
All dates are worked out from the day you run it, so the events are always upcoming.
Registrations are spread over the past month, as if sign-ups have been coming in.
The data is generated from a fixed seed, so every run looks the same.
| When (from today) | Event | Shows off |
|---|---|---|
| ~1½ weeks, Sat 07:30 | **Men's Breakfast: Built to Last** | **Sold out** (60 places), 5 people on the **waitlist**, card / cash / EFT payments recorded by staff, 2 cancelled registrations |
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Several ticket types (adult, child, family pass), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
| ~5 weeks, Fri–Sat | **Rooted Women's Conference** | Two **early-bird tiers** (one already expired, one still open) plus a Saturday-only ticket, T-shirt **variants** (S–XXL), a **required sign-up form** (dietary needs, workshop choice, prayer requests), a 20% **group coupon** (`ROOTED20`) used by 4 registrations |
| ~7 weeks, Mon–Thu | **Ignite Summer Camp** | **Named attendees** (campers aged 12–17), signed **parental consent**, gender collected, **deposits and part-payments**, campers placed in boys' and girls' **cabins** with staff leaders, **sponsor-a-spot** donations towards a 10-spot goal |
| ~8 weeks, Sun 19:00 | **Carols by Candlelight** | A **free event** with group tickets (1–6 people), plus general **donations** |
| ~14 weeks, Fri–Sun | **Marriage Enrichment Weekend** | Room-type **variants with limited stock**, early-bird prices per room type, a per-person spa add-on, R1 000 EFT deposits |
Each event also gets:
- A cover photo (from Unsplash, which `next.config` already allows) and a contact person.
- **Costs**, both once-off and per item, so the profit view and cash-up have figures.
- About 30 days of **funnel analytics** (views → started → submitted → paid) by source
(WhatsApp, Facebook, Instagram, email, direct) and device.
A typical run gives roughly:
| | |
|---|---|
| Users | 130 (plus the admin) |
| Registrations | ~220 (paid, part-paid, pending and cancelled) |
| Payments | ~180, including donations and sponsorships |
| Tickets | ~245 |
| Named campers | ~45 |
| Funnel events | ~1 700 |
Paid registrations have tickets with QR codes, the same way the app issues them once
payment is complete. Part-paid and pending ones don't have tickets yet.
---
## Logging in
Every demo account uses the password **`Demo@1234`**.
| Role | Email | Good for showing |
|---|---|---|
| Supervisor | `lerato.khumalo@example.com` | Event setup, reports, cash-up, coupons |
| Staff | `sipho.dlamini@example.com` | At-the-door payments and check-in scanning |
| Staff | `anri.botha@example.com` | |
| Staff | `daniel.pillay@example.com` | |
The other 126 people are ordinary members (role `user`), with emails like
`thandi.mokoena@example.com`. Log in as one of them to show the member view: their
registrations, tickets and payments. Any registrant's email from an event's attendee
list works.
Your own admin account keeps its existing password.
---
## Safety
- **Nothing is sent.** The script writes straight to the database and never calls the
email, WhatsApp or Yoco code.
- **Nothing can reach a real person later.** Demo people use `@example.com` addresses
(a reserved domain that can't receive mail) and have **no phone numbers**, so
reminders or broadcasts sent during a demo go nowhere. All parental consents are
already signed, so no consent reminders go out.
- Card payments have made-up Yoco ids (`demo_…`). They don't exist in Yoco, so leave
the demo site's Yoco settings in test mode or empty.
---
## Customising
Everything lives in [`scripts/set-demo.js`](scripts/set-demo.js). There is one
`build…()` function per event (`buildMensBreakfast`, `buildYouthCamp`, …), and
`main()` decides which ones run. To change an event's name, prices, ticket types or
number of registrations, edit its function. To add an event, write a new `build…()`
and add it to the list in `main()`.
+21 -4
View File
@@ -79,8 +79,8 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. |
| `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins |
| `YOCO_SECRET_KEY` | Yes | Yoco secret API key |
| `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret |
| `YOCO_SECRET_KEY` | — | Fallback Yoco secret API key (preferred: set via Admin → Site Settings → Yoco) |
| `YOCO_WEBHOOK_SECRET` | — | Fallback Yoco webhook secret (preferred: generate or enter it via Admin → Site Settings → Yoco) |
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
| `PORT` | — | Port to listen on (default `3000`) |
@@ -99,7 +99,9 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
> **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard.
>
> **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable.
> **SMTP credentials** (`smtp_user`, `smtp_pass`) and the **Yoco secret key and webhook secret** are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving them — the stored values will become unreadable.
>
> To move existing Yoco values from `.env` into the database, run `npm run migrate:env-to-db` (or save them in the Yoco tab).
---
@@ -141,6 +143,10 @@ npm run prisma:status
npm run prisma:generate
```
### Demo data
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
---
## Running the Server
@@ -333,6 +339,15 @@ Full interactive API docs available at `GET /docs` (requires admin JWT — pass
| PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) |
| POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
#### Yoco — `/api/yoco`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
#### Setup — `/api/setup`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -500,6 +515,8 @@ The resolution priority at registration/payment time is: **variant tier → vari
## Payments (Yoco)
Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with Yoco before saving), then **Generate** a webhook — this registers `BACKEND_URL/api/webhooks/yoco` with Yoco and stores the secret Yoco returns (it's only shown once). Or **Enter** the secret of a webhook you registered yourself. The tab also lists the account's webhooks and can delete them.
1. Frontend calls `POST /api/payments/yoco-checkout`
2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries.
3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`.
@@ -524,6 +541,6 @@ pm2 save && pm2 startup
- [ ] `JWT_SECRET` is long (32+ chars) and secret
- [ ] `FRONTEND_URL` lists only production origins
- [ ] `NODE_ENV=production`
- [ ] Yoco webhook registered and HTTPS
- [ ] Yoco secret key saved and webhook generated (Site Settings → Yoco), over HTTPS
- [ ] WAWP credentials saved via admin panel
- [ ] SMTP credentials correct
+1666 -372
View File
File diff suppressed because it is too large Load Diff
+14 -10
View File
@@ -1,11 +1,11 @@
{
"name": "event-management-backend",
"version": "1.23.0",
"version": "1.26.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"dev": "node --watch src/index.js",
"test": "jest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
@@ -13,10 +13,12 @@
"prisma:status": "prisma migrate status",
"prisma:reset": "prisma migrate reset --force",
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run"
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
"set-demo": "node scripts/set-demo.js"
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@sentry/node": "^10.71.0",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
@@ -37,16 +39,18 @@
},
"devDependencies": {
"jest": "^30.4.2",
"nodemon": "^3.0.1",
"prisma": "^5.4.2"
"prisma": "^7.10.1"
},
"overrides": {
"uuid": "^11.1.1"
"uuid": "^11.1.1",
"@istanbuljs/load-nyc-config": {
"js-yaml": "^4.1.1"
},
"follow-redirects": "^1.16.1"
},
"allowScripts": {
"prisma@5.22.0": true,
"@prisma/client@5.22.0": true,
"@prisma/engines@5.22.0": true,
"prisma@7.10.1": true,
"@prisma/engines@7.10.1": true,
"unrs-resolver@1.12.2": true,
"@parcel/watcher": false
}
+16
View File
@@ -0,0 +1,16 @@
// Prisma CLI config (migrate, generate, studio). Prisma 7 no longer reads the
// datasource URL from schema.prisma or auto-loads .env, so both happen here.
require('dotenv/config');
const { defineConfig } = require('prisma/config');
module.exports = defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
// process.env rather than env() so `prisma generate` still works on a
// machine with no DATABASE_URL (generate never connects).
url: process.env.DATABASE_URL,
},
});
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "TicketTransferRequest" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"toName" TEXT NOT NULL,
"toEmail" TEXT,
"toPhone" TEXT,
"details" JSONB,
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" TIMESTAMP(3) NOT NULL,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransferRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TicketTransferRequest_tokenHash_key" ON "TicketTransferRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "TicketTransferRequest_ticketId_status_idx" ON "TicketTransferRequest"("ticketId", "status");
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_config_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_registered';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_deleted';
+27 -1
View File
@@ -7,7 +7,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
@@ -112,6 +111,7 @@ model User {
feedbackInvites FeedbackInvite[]
feedbackGiven FeedbackResponse[]
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
transferRequests TicketTransferRequest[] @relation("TransferRequester")
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
@@ -389,6 +389,7 @@ model Ticket {
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
transfers TicketTransfer[]
transferRequests TicketTransferRequest[]
@@index([holderUserId])
@@index([eventId])
@@ -503,6 +504,9 @@ enum AdminAuditAction {
ticket_transferred
impersonation_started
impersonation_ended
yoco_config_updated
yoco_webhook_registered
yoco_webhook_deleted
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -1047,6 +1051,28 @@ model AllocationAssignment {
@@index([attendeeId])
}
/// A transfer waiting for the new holder to accept it from the link sent to the email /
/// phone the sender typed. Nothing changes on the ticket until it's accepted. `details`
/// holds what the sender entered (date of birth, gender, parent details on youth events).
model TicketTransferRequest {
id String @id @default(uuid())
ticketId String
requestedById String
toName String
toEmail String?
toPhone String?
details Json?
tokenHash String @unique
status String @default("pending") // pending | accepted | declined | cancelled | expired
expiresAt DateTime
respondedAt DateTime?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
requestedBy User @relation("TransferRequester", fields: [requestedById], references: [id], onDelete: Cascade)
@@index([ticketId, status])
}
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
/// when one place was split off a shared group ticket, whose code stays valid).
model TicketTransfer {
+7 -3
View File
@@ -24,7 +24,7 @@ const dotenv = require('dotenv');
// Load .env from the backend root
dotenv.config({ path: path.resolve(__dirname, '../.env') });
const { PrismaClient } = require('@prisma/client');
const { createPrismaClient } = require('../src/config/db');
const crypto = require('crypto');
const DRY_RUN = process.argv.includes('--dry-run');
@@ -52,7 +52,7 @@ function encrypt(plaintext) {
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
}
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
// ─── Mapping: env var(s) → DB key ────────────────────────────────────────────
// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool }
@@ -79,6 +79,10 @@ const MAPPINGS = [
{ key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true },
{ key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true },
// Yoco payments
{ key: 'yoco_secret_key', envVars: ['YOCO_SECRET_KEY'], encrypt: true },
{ key: 'yoco_webhook_secret', envVars: ['YOCO_WEBHOOK_SECRET'], encrypt: true },
// App URLs (used in email templates)
{ key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] },
];
@@ -86,7 +90,7 @@ const MAPPINGS = [
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const prisma = new PrismaClient();
const prisma = createPrismaClient();
console.log(`\n${'─'.repeat(60)}`);
console.log(' Settings migration: .env → DB');
+3 -2
View File
@@ -3,10 +3,11 @@
* Run: node scripts/seed-users.js
*/
const { PrismaClient } = require('@prisma/client');
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const { createPrismaClient } = require('../src/config/db');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
const prisma = createPrismaClient();
const firstNames = [
'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia',
+863
View File
@@ -0,0 +1,863 @@
/**
* set-demo.js
*
* Replaces the database contents with demo data for showing the system to other
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics.
*
* Everything is removed first EXCEPT the site settings (AppSetting) and the admin
* account (DEMO_ADMIN_EMAIL, default joshua@crosscode.co.za). Dates are relative to
* today, so the events are always in the future whenever this is run.
*
* No emails or WhatsApp messages are sent. Demo people use @example.com addresses and
* have no phone numbers, so later reminders can't reach a real person.
*
* Usage:
* cd backend
* npm run set-demo (asks for confirmation)
* npm run set-demo -- --yes (no prompt)
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const readline = require('readline');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { createPrismaClient } = require('../src/config/db');
const prisma = createPrismaClient();
const ADMIN_EMAIL = (process.env.DEMO_ADMIN_EMAIL || 'joshua@crosscode.co.za').trim().toLowerCase();
const DEMO_PASSWORD = 'Demo@1234';
const SKIP_PROMPT = process.argv.includes('--yes') || process.argv.includes('-y');
// Not truncated: migrations, site settings, users (all but the admin are deleted
// separately) and the admin's own account-security rows.
const KEEP_TABLES = new Set([
'_prisma_migrations', 'AppSetting', 'User',
'SecurityEvent', 'PasswordReset', 'TwoFactorChallenge', 'TrustedDevice',
]);
const SCHEDULED_EMAILS_PATH = path.resolve(__dirname, '../data/scheduled-emails.json');
// ─── Random helpers (seeded, so every run produces the same-looking demo) ────
let seed = 20261021;
function rand() {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const int = (min, max) => min + Math.floor(rand() * (max - min + 1));
const pick = (arr) => arr[Math.floor(rand() * arr.length)];
const chance = (p) => rand() < p;
const round2 = (n) => Math.round(n * 100) / 100;
/** Picks a key from { key: weight }. */
function weighted(weights) {
const total = Object.values(weights).reduce((s, w) => s + w, 0);
let r = rand() * total;
for (const [key, w] of Object.entries(weights)) {
if ((r -= w) < 0) return key;
}
return Object.keys(weights)[0];
}
function shuffle(arr) {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
// ─── Dates ───────────────────────────────────────────────────────────────────
const NOW = new Date();
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** The first `weekday` (0 = Sunday) at least `minDays` from today, at hour:minute. */
function nextWeekday(minDays, weekday, hour, minute = 0) {
const d = new Date(NOW);
d.setDate(d.getDate() + minDays);
while (d.getDay() !== weekday) d.setDate(d.getDate() + 1);
d.setHours(hour, minute, 0, 0);
return d;
}
const addTime = (date, ms) => new Date(date.getTime() + ms);
const daysFromNow = (days) => addTime(NOW, days * DAY);
/** A random moment between minDaysAgo and maxDaysAgo, during waking hours, never in the future. */
function pastDate(maxDaysAgo, minDaysAgo = 0) {
const d = new Date(NOW.getTime() - (minDaysAgo + rand() * (maxDaysAgo - minDaysAgo)) * DAY);
d.setHours(int(7, 21), int(0, 59), int(0, 59), 0);
return d < NOW ? d : new Date(NOW.getTime() - int(5, 120) * MINUTE);
}
/** A moment a little after `from`, clamped to the past. */
function after(from, minMinutes, maxMinutes) {
const t = from.getTime() + int(minMinutes, maxMinutes) * MINUTE;
return new Date(Math.min(t, NOW.getTime() - MINUTE));
}
// ─── People ──────────────────────────────────────────────────────────────────
const FEMALE_FIRST = [
'Thandi', 'Nomsa', 'Lerato', 'Zanele', 'Palesa', 'Ayanda', 'Naledi', 'Busisiwe', 'Precious',
'Refilwe', 'Anri', 'Elmarie', 'Liezl', 'Marike', 'Annelie', 'Charlene', 'Michelle', 'Nicole',
'Sarah', 'Rachel', 'Hannah', 'Grace', 'Megan', 'Jessica', 'Lauren', 'Kelly', 'Tamryn', 'Candice',
'Fatima', 'Priya', 'Kavitha', 'Nadia', 'Bianca', 'Chantelle', 'Tanya', 'Mpho', 'Keabetswe',
'Lindiwe', 'Sibongile', 'Yolanda', 'Ruth', 'Esther', 'Joanne', 'Karen', 'Debbie', 'Nompumelelo',
'Carmen', 'Simone', 'Natasha', 'Ilse', 'Rochelle', 'Tumi', 'Dineo', 'Hlengiwe', 'Abigail',
];
const MALE_FIRST = [
'Sipho', 'Thabo', 'Bongani', 'Mandla', 'Kagiso', 'Tshepo', 'Lwazi', 'Sizwe', 'Themba', 'Musa',
'Pieter', 'Johan', 'Francois', 'Hendrik', 'Marius', 'Wikus', 'Riaan', 'Gerhard', 'David',
'Michael', 'Daniel', 'Matthew', 'Andrew', 'Jonathan', 'Ryan', 'Craig', 'Brandon', 'Shaun',
'Grant', 'Kyle', 'Ashwin', 'Rajesh', 'Yusuf', 'Tumelo', 'Neo', 'Karabo', 'Peter', 'Mark',
'Paul', 'Simon', 'Brian', 'Trevor', 'Wayne', 'Chris', 'Luyanda', 'Jaco', 'Stefan', 'Ernest',
'Vusi', 'Kabelo', 'Dumisani', 'Graham', 'Clive', 'Nathan', 'Ruben', 'Emmanuel', 'Isaac',
];
const LAST = [
'Dlamini', 'Nkosi', 'Mokoena', 'Ndlovu', 'Mthembu', 'Zulu', 'Nxumalo', 'Khumalo', 'Molefe',
'Mahlangu', 'Sithole', 'Mabaso', 'Radebe', 'Baloyi', 'Maseko', 'Ntuli', 'du Plessis',
'van der Merwe', 'Botha', 'Steyn', 'Fourie', 'Pretorius', 'Venter', 'Coetzee', 'van Niekerk',
'Lombard', 'Nel', 'Kruger', 'Smit', 'Jacobs', 'Smith', 'Williams', 'Brown', 'Johnson', 'Adams',
'Petersen', 'Daniels', 'Hendricks', 'Abrahams', 'Isaacs', 'Naidoo', 'Pillay', 'Govender',
'Reddy', 'Moodley', 'Patel', 'Campbell', 'Taylor', 'Thomas', 'Wilson', 'Clarke', 'Morgan',
];
const GIRL_FIRST = [
'Amahle', 'Lesedi', 'Isabella', 'Mia', 'Zoe', 'Ava', 'Leah', 'Kayla', 'Nandi', 'Olwethu', 'Jade',
'Chloe', 'Emma', 'Lisa', 'Anika', 'Karabo', 'Lilitha', 'Bella', 'Gabriella', 'Imaan',
];
const BOY_FIRST = [
'Liam', 'Noah', 'Ethan', 'Luke', 'Josh', 'Siyabonga', 'Lethabo', 'Caleb', 'Aiden', 'Jayden',
'Ruan', 'Tiaan', 'Bandile', 'Ofentse', 'Zaid', 'Reece', 'Keegan', 'Asanda', 'Jordan', 'Kian',
];
const emailSlug = (s) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
// ─── Row collection ──────────────────────────────────────────────────────────
// Everything is built in memory with pre-generated ids, then written with createMany
// in dependency order inside one transaction.
const rows = {};
const add = (model, row) => {
(rows[model] = rows[model] || []).push(row);
return row;
};
const INSERT_ORDER = [
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'registration', 'registrationOption',
'registrationOptionTranche', 'attendee', 'payment', 'ticket', 'couponRedemption',
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
'allocationGroup', 'allocationAssignment', 'eventCost', 'analyticsEvent',
];
// Extra facts about demo people that aren't columns (kept out of the inserted rows).
const userMeta = new Map();
let admin;
let passwordHash;
const staffTeam = [];
function addUser({ first, last, gender, role = 'user', email }) {
const usedEmails = new Set((rows.user || []).map(u => u.email).concat(ADMIN_EMAIL));
let address = email || `${emailSlug(first)}.${emailSlug(last)}@example.com`;
for (let n = 2; usedEmails.has(address); n++) address = `${emailSlug(first)}.${emailSlug(last)}${n}@example.com`;
const user = add('user', {
id: uuidv4(),
name: `${first} ${last}`,
email: address,
password: passwordHash,
role,
isActive: true,
notificationPreference: 'email',
createdAt: pastDate(420, 40),
});
userMeta.set(user.id, { first, last, gender });
return user;
}
function buildPeople() {
staffTeam.push(
addUser({ first: 'Lerato', last: 'Khumalo', gender: 'female', role: 'supervisor', email: 'lerato.khumalo@example.com' }),
addUser({ first: 'Sipho', last: 'Dlamini', gender: 'male', role: 'staff', email: 'sipho.dlamini@example.com' }),
addUser({ first: 'Anri', last: 'Botha', gender: 'female', role: 'staff', email: 'anri.botha@example.com' }),
addUser({ first: 'Daniel', last: 'Pillay', gender: 'male', role: 'staff', email: 'daniel.pillay@example.com' }),
);
const women = [];
const men = [];
for (let i = 0; i < 62; i++) women.push(addUser({ first: pick(FEMALE_FIRST), last: pick(LAST), gender: 'female' }));
for (let i = 0; i < 64; i++) men.push(addUser({ first: pick(MALE_FIRST), last: pick(LAST), gender: 'male' }));
return { women, men, everyone: women.concat(men) };
}
// ─── Event building blocks ───────────────────────────────────────────────────
function addEvent(data) {
return add('event', {
id: uuidv4(),
createdById: admin.id,
goLiveAt: addTime(NOW, -35 * DAY),
createdAt: addTime(NOW, -38 * DAY),
isActive: true,
contactName: 'Lerato Khumalo',
contactEmail: 'events@example.com',
contactPhone: '021 555 0142',
...data,
});
}
function addOption(event, data) {
return add('eventOption', { id: uuidv4(), eventId: event.id, isMainTicket: true, stockLimit: 0, ...data });
}
function addVariants(option, variants) {
return variants.map((v, order) => add('optionVariant', { id: uuidv4(), eventOptionId: option.id, order, stockLimit: 0, price: null, ...v }));
}
function addTier(option, data) {
return add('earlyBirdTier', { id: uuidv4(), eventOptionId: option.id, variantId: null, order: 0, stockLimit: 0, ...data });
}
function addSection(event, name, options) {
const section = add('section', { id: uuidv4(), eventId: event.id, name });
for (const o of options) add('sectionOption', { id: uuidv4(), sectionId: section.id, eventOptionId: o.id });
return section;
}
function addCost(event, label, costType, amount, option = null, notes = null) {
add('eventCost', { id: uuidv4(), eventId: event.id, eventOptionId: option ? option.id : null, label, costType, amount, paidFromMethod: null, notes });
}
// Early-bird tiers already sold, for stock limits.
const tierSold = new Map();
/** The price a line bought at `when` gets: cheapest open tier with stock left, else the base price. */
function priceAt(option, variant, when, quantity) {
const tiers = (rows.earlyBirdTier || [])
.filter(t => t.eventOptionId === option.id && t.variantId === (variant ? variant.id : null))
.sort((a, b) => a.price - b.price);
for (const tier of tiers) {
if (when >= tier.deadline) continue;
const sold = tierSold.get(tier.id) || 0;
if (tier.stockLimit > 0 && sold + quantity > tier.stockLimit) continue;
tierSold.set(tier.id, sold + quantity);
return { price: tier.price, tier };
}
const base = variant && variant.price !== null ? variant.price : option.price;
return { price: base, tier: null };
}
const line = (option, quantity, extra = {}) => ({ option, quantity, ...extra });
// ─── Registrations ───────────────────────────────────────────────────────────
/**
* One registration with its lines, payments and (once paid) tickets.
* pay: 'full' | 'split' (deposit then balance) | 'deposit' | 'none' | 'cancelled'
*/
function addRegistration(event, user, lines, { createdAt, pay = 'full', method = 'card', deposit = 0, coupon = null }) {
const registration = add('registration', {
id: uuidv4(),
userId: user.id,
eventId: event.id,
status: 'pending',
discountTotal: 0,
ticketDelivery: 'payer',
analyticsSessionId: null,
createdAt,
});
let subtotal = 0;
const regLines = lines.filter(l => l.quantity > 0).map(l => {
const { price, tier } = priceAt(l.option, l.variant || null, createdAt, l.quantity);
const ro = add('registrationOption', {
id: uuidv4(),
registrationId: registration.id,
eventOptionId: l.option.id,
variantId: l.variant ? l.variant.id : null,
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
});
add('registrationOptionTranche', {
id: uuidv4(),
registrationOptionId: ro.id,
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
createdAt,
});
subtotal += l.quantity * price;
const attendees = (l.attendees || []).map((a, order) => add('attendee', {
id: uuidv4(),
registrationId: registration.id,
registrationOptionId: ro.id,
eventOptionId: l.option.id,
variantId: l.variant ? l.variant.id : null,
order,
createdAt,
...a,
}));
return { ro, option: l.option, attendees };
});
if (coupon && pay !== 'cancelled') {
const discount = coupon.type === 'percent' ? round2(subtotal * coupon.value / 100) : Math.min(subtotal, coupon.value);
registration.discountTotal = discount;
add('couponRedemption', { id: uuidv4(), couponId: coupon.id, registrationId: registration.id, userId: user.id, discountAmount: discount, createdAt });
}
const total = round2(subtotal - registration.discountTotal);
const payments = [];
const pay1 = (amount, at, how = method) => {
payments.push(add('payment', {
id: uuidv4(),
amount: round2(amount),
method: how,
userId: user.id,
recordedById: how === 'card' ? null : pick(staffTeam).id,
registrationId: registration.id,
eventId: event.id,
status: how === 'card' ? 'completed' : null,
externalId: how === 'card' ? `demo_${uuidv4().replace(/-/g, '')}` : null,
createdAt: at,
}));
};
const firstPaidAt = after(createdAt, method === 'card' ? 2 : 600, method === 'card' ? 15 : 3 * 24 * 60);
if (total > 0) {
if (pay === 'full') pay1(total, firstPaidAt);
if (pay === 'deposit' || pay === 'split') pay1(Math.min(deposit, total), firstPaidAt);
if (pay === 'split') pay1(total - Math.min(deposit, total), after(firstPaidAt, 3 * 24 * 60, 20 * 24 * 60));
}
const paid = round2(payments.reduce((s, p) => s + p.amount, 0));
if (pay === 'cancelled') registration.status = 'cancelled';
else if (paid >= total) registration.status = 'paid';
else if (paid > 0) registration.status = 'partial_paid';
if (registration.status === 'paid') {
const issuedAt = payments.length ? payments[payments.length - 1].createdAt : after(createdAt, 0, 1);
for (const { ro, attendees } of regLines) {
for (const a of attendees) {
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: event.id, quantity: 1, attendeeId: a.id, holderUserId: a.userId || null, emailSent: true, createdAt: issuedAt });
}
const unnamed = ro.quantity - attendees.length;
if (unnamed > 0) {
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: event.id, quantity: unnamed, emailSent: true, createdAt: issuedAt });
}
}
}
return { registration, regLines };
}
/** Picks a payment plan from weights like { card: 0.7, eft: 0.1, none: 0.2 }. */
function payPlan(weights) {
const choice = weighted(weights);
return choice === 'none' ? { pay: 'none', method: 'card' } : { pay: 'full', method: choice };
}
function addForm(event, fields) {
const form = add('eventForm', { id: uuidv4(), eventId: event.id, isRequired: true });
return fields.map((f, order) => add('eventFormField', { id: uuidv4(), formId: form.id, order, isRequired: false, helpText: null, options: null, ...f }));
}
function addFormResponse(registration, answers) {
const response = add('formResponse', { id: uuidv4(), registrationId: registration.id, createdAt: registration.createdAt });
for (const [field, value] of answers) {
if (value !== null && value !== '') add('formAnswer', { id: uuidv4(), responseId: response.id, fieldId: field.id, value: String(value) });
}
}
// ─── The six events ──────────────────────────────────────────────────────────
const IMG = (id) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=1600&q=80`;
function buildFamilyFunDay(people) {
const start = nextWeekday(15, 6, 10);
const event = addEvent({
title: 'Family Fun Day',
description: 'A full day of fun for the whole family on the church grounds!\n\n'
+ 'Jumping castles, a petting zoo, face painting, a five-a-side soccer tournament and a '
+ 'treasure hunt for the kids — plus coffee, boerewors rolls and a quiet shady spot for the grown-ups.\n\n'
+ 'Bring a picnic blanket and your camp chairs. Children under 3 get in free.',
startDate: start,
endDate: addTime(start, 5 * HOUR),
registrationDeadline: addTime(start, -DAY),
price: 80,
picture: IMG('photo-1529156069898-49953e39b3ac'),
location: 'Church grounds & sports field',
});
const adult = addOption(event, { name: 'Adult', price: 80 });
const child = addOption(event, { name: 'Child (3–12)', price: 40 });
const family = addOption(event, { name: 'Family pass (2 adults + 3 kids)', price: 220 });
const lunch = addOption(event, { name: 'Lunch pack', price: 65, isMainTicket: false });
add('coupon', {
id: uuidv4(), code: 'FUNDAY20', description: 'R20 off for first-time visitors', type: 'fixed', value: 20,
stackEarlyBird: false, validFrom: addTime(NOW, -30 * DAY), validUntil: start, maxUses: 50, maxUsesPerUser: 1,
restrictRoles: [], active: true, createdById: admin.id, createdAt: addTime(NOW, -30 * DAY),
});
add('couponEvent', { id: uuidv4(), couponId: rows.coupon[rows.coupon.length - 1].id, eventId: event.id });
addSection(event, 'Main gate', [adult, child, family]);
addSection(event, 'Food stall', [lunch]);
addCost(event, 'Jumping castles & games hire', 'once_off', 4500);
addCost(event, 'Face painter', 'once_off', 1200);
addCost(event, 'Lunch packs', 'per_item', 38, lunch, 'Supplied by the catering team');
for (const user of shuffle(people.everyone).slice(0, 30)) {
const lines = chance(0.35)
? [line(family, 1)]
: [line(adult, int(1, 2)), line(child, Number(weighted({ 0: 2, 1: 3, 2: 3, 3: 1 })))];
lines.push(line(lunch, chance(0.6) ? int(1, 4) : 0));
addRegistration(event, user, lines, { createdAt: pastDate(30), ...payPlan({ card: 0.72, eft: 0.12, none: 0.16 }) });
}
return event;
}
function buildMensBreakfast(people) {
const start = nextWeekday(8, 6, 7, 30);
const event = addEvent({
title: "Men's Breakfast: Built to Last",
description: 'Join the men of the church for a full English breakfast and an honest conversation '
+ 'about faith, work and family.\n\n'
+ 'Our guest speaker is Pastor Ernest Molefe, who has spent twenty years mentoring young fathers. '
+ 'Bring a friend, a neighbour or a colleague — there is a seat for everyone.',
startDate: start,
endDate: addTime(start, 2.5 * HOUR),
registrationDeadline: addTime(start, -2 * DAY),
price: 150,
picture: IMG('photo-1414235077428-338989a2e8c0'),
location: 'Church hall',
waitlistEnabled: true,
contactName: 'Sipho Dlamini',
});
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
addSection(event, 'Hall entrance', [ticket]);
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
addCost(event, 'Guest speaker gift', 'once_off', 750);
const men = shuffle(people.men);
let sold = 0;
while (sold < 60) {
const user = men.shift();
const quantity = Math.min(chance(0.2) ? 2 : 1, 60 - sold);
sold += quantity;
addRegistration(event, user, [line(ticket, quantity)], { createdAt: pastDate(28), ...payPlan({ card: 0.65, cash: 0.15, eft: 0.08, none: 0.12 }) });
}
for (let i = 0; i < 2; i++) {
addRegistration(event, men.shift(), [line(ticket, 1)], { createdAt: pastDate(25, 5), pay: 'cancelled' });
}
for (let position = 1; position <= 5; position++) {
add('waitlistEntry', {
id: uuidv4(), eventId: event.id, eventOptionId: ticket.id, userId: men.shift().id,
quantity: position === 3 ? 2 : 1, position, status: 'waiting', createdAt: pastDate(4),
});
}
return event;
}
function buildWomensConference(people) {
const start = nextWeekday(34, 5, 18);
const event = addEvent({
title: 'Rooted Women’s Conference',
description: 'Two days of worship, teaching and real connection for women of every age and season.\n\n'
+ 'Friday evening opens with a worship night. Saturday brings two main sessions, your choice of '
+ 'workshop and a long lunch together in the garden.\n\n'
+ 'Early-bird prices are available for a limited time — gather your small group and register together.',
startDate: start,
endDate: addTime(start, 22 * HOUR),
registrationDeadline: addTime(start, -3 * DAY),
price: 450,
picture: IMG('photo-1511632765486-a01980e01a18'),
location: 'Main auditorium',
contactName: 'Anri Botha',
});
const full = addOption(event, { name: 'Full conference (Fri + Sat)', price: 450 });
const saturday = addOption(event, { name: 'Saturday only', price: 280 });
const shirt = addOption(event, { name: 'Conference T-shirt', price: 180, isMainTicket: false });
const sizes = addVariants(shirt, [{ name: 'S' }, { name: 'M' }, { name: 'L' }, { name: 'XL' }, { name: 'XXL' }]);
addTier(full, { deadline: addTime(NOW, -8 * DAY), price: 300, stockLimit: 25, order: 0 });
addTier(full, { deadline: addTime(NOW, 10 * DAY), price: 380, stockLimit: 0, order: 1 });
const coupon = add('coupon', {
id: uuidv4(), code: 'ROOTED20', description: '20% off for small groups registering together', type: 'percent', value: 20,
stackEarlyBird: true, validFrom: addTime(NOW, -30 * DAY), validUntil: start, maxUses: 30, maxUsesPerUser: 1,
restrictRoles: [], active: true, createdById: admin.id, createdAt: addTime(NOW, -30 * DAY),
});
add('couponEvent', { id: uuidv4(), couponId: coupon.id, eventId: event.id });
addSection(event, 'Main hall', [full, saturday]);
addSection(event, 'Merch table', [shirt]);
addCost(event, 'Venue, sound & lighting', 'once_off', 12500);
addCost(event, 'Catering (full conference)', 'per_item', 120, full);
addCost(event, 'Catering (Saturday)', 'per_item', 70, saturday);
addCost(event, 'T-shirt printing', 'per_item', 95, shirt);
const [diet, workshop, firstTime, prayer] = addForm(event, [
{ type: 'multiple_choice', label: 'Dietary requirements', isRequired: true, options: ['None', 'Vegetarian', 'Vegan', 'Halaal', 'Gluten-free'] },
{ type: 'multiple_choice', label: 'Which Saturday workshop would you like to attend?', isRequired: true, options: ['Prayer & intercession', 'Marriage & family', 'Women in leadership', 'Finding rest'] },
{ type: 'yes_no', label: 'Is this your first Rooted conference?', isRequired: true },
{ type: 'text', label: 'Is there anything we can pray with you about?', helpText: 'Only our prayer team will see this.' },
]);
const PRAYERS = ['My mom’s health', 'A new job', 'Wisdom for my family', 'My teenage kids', 'Healing', 'Peace in a tough season'];
for (const user of shuffle(people.women).slice(0, 50)) {
const quantity = Number(weighted({ 1: 70, 2: 20, 3: 6, 4: 4 }));
const main = chance(0.75) ? full : saturday;
const lines = [line(main, quantity)];
if (chance(0.4)) lines.push(line(shirt, 1, { variant: pick(sizes) }));
const plan = payPlan({ card: 0.8, eft: 0.08, none: 0.12 });
const { registration } = addRegistration(event, user, lines, {
createdAt: pastDate(30),
...plan,
coupon: quantity >= 2 && plan.pay === 'full' && chance(0.5) ? coupon : null,
});
for (let i = 0; i < quantity; i++) {
addFormResponse(registration, [
[diet, weighted({ None: 70, Vegetarian: 12, Vegan: 4, Halaal: 8, 'Gluten-free': 6 })],
[workshop, pick(workshop.options)],
[firstTime, chance(0.35) ? 'yes' : 'no'],
[prayer, chance(0.25) ? pick(PRAYERS) : null],
]);
}
}
return event;
}
function buildYouthCamp(people) {
const start = nextWeekday(46, 1, 9);
const event = addEvent({
title: 'Ignite Summer Camp',
description: 'Four days in the mountains for Grade 7–12s: hiking, river tubing, campfires, '
+ 'night games and powerful evening services.\n\n'
+ 'The bus leaves the church at 09:00 on Monday and returns on Thursday afternoon. All meals, '
+ 'activities and accommodation are included. A R500 deposit per camper secures a place, with '
+ 'the balance due two weeks before camp.\n\n'
+ 'Want to help a teenager get to camp? Use “Sponsor a spot” to give towards our bursary fund.',
startDate: start,
endDate: addTime(start, 3 * DAY + 5 * HOUR),
registrationDeadline: addTime(start, -14 * DAY),
price: 1650,
picture: IMG('photo-1478131143081-80f7f84ca84d'),
location: 'Bergview Camp, Drakensberg',
allowNamedAttendees: true,
requiresParentalConsent: true,
consentText: 'I am the parent or legal guardian of this camper and give permission for them to attend '
+ 'Ignite Summer Camp, take part in all supervised activities (including hiking and water activities) '
+ 'and travel on the camp bus. In an emergency I authorise the camp leaders to obtain medical treatment.',
consentCollectPhoto: true,
collectGender: true,
allowSponsorships: true,
sponsorshipGoalSpots: 10,
contactName: 'Daniel Pillay',
});
const camper = addOption(event, { name: 'Camper (Grade 7–12)', price: 1650, stockLimit: 80 });
const hoodie = addOption(event, { name: 'Camp hoodie', price: 380, isMainTicket: false });
const sizes = addVariants(hoodie, [{ name: 'Youth L' }, { name: 'S' }, { name: 'M' }, { name: 'L' }, { name: 'XL' }]);
addSection(event, 'Bus check-in', [camper]);
addCost(event, 'Campsite (per camper)', 'per_item', 820, camper);
addCost(event, 'Bus hire', 'once_off', 16800);
addCost(event, 'Camp hoodies', 'per_item', 210, hoodie);
const campers = [];
for (const parent of shuffle(people.everyone).slice(0, 30)) {
const surname = userMeta.get(parent.id).last;
const count = Number(weighted({ 1: 55, 2: 35, 3: 10 }));
const attendees = [];
for (let i = 0; i < count; i++) {
const gender = chance(0.5) ? 'female' : 'male';
// 12–17 on the first day of camp.
const dateOfBirth = new Date(start.getTime() - (int(12, 17) * 365.25 + int(10, 350)) * DAY);
dateOfBirth.setUTCHours(0, 0, 0, 0);
attendees.push({
name: `${pick(gender === 'female' ? GIRL_FIRST : BOY_FIRST)} ${surname}`,
gender,
dateOfBirth,
parentName: parent.name,
parentEmail: parent.email,
});
}
const lines = [line(camper, count, { attendees })];
if (chance(0.4)) lines.push(line(hoodie, int(1, count), { variant: pick(sizes) }));
const plan = weighted({ full: 30, split: 20, deposit: 35, none: 15 });
const { registration, regLines } = addRegistration(event, parent, lines, {
createdAt: pastDate(32),
pay: plan,
method: chance(0.5) ? 'card' : 'eft',
deposit: 500 * count,
});
if (registration.status === 'pending') continue;
for (const attendee of regLines[0].attendees) {
campers.push(attendee);
add('consentRequest', {
id: uuidv4(),
attendeeId: attendee.id,
eventId: event.id,
tokenHash: crypto.createHash('sha256').update(uuidv4()).digest('hex'),
status: 'given',
textVersion: 1,
consentText: event.consentText,
parentName: parent.name,
parentEmail: parent.email,
signedName: parent.name,
photoConsent: chance(0.9),
respondedAt: after(registration.createdAt, 30, 2 * 24 * 60),
lastSentAt: registration.createdAt,
expiresAt: event.startDate,
createdAt: registration.createdAt,
});
}
}
// Cabins, with most campers already placed.
const cabins = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Cabins', showOnTicket: true, order: 0 });
const groups = [
['Eagle cabin', 'male', staffTeam[1]], ['Lion cabin', 'male', staffTeam[3]],
['Dove cabin', 'female', staffTeam[0]], ['Fawn cabin', 'female', staffTeam[2]],
].map(([name, gender, leader], order) => add('allocationGroup', { id: uuidv4(), setId: cabins.id, name, capacity: 14, gender, leaderUserId: leader.id, order }));
const placed = new Map(groups.map(g => [g.id, 0]));
for (const attendee of campers) {
if (!chance(0.8)) continue;
const group = groups.find(g => g.gender === attendee.gender && placed.get(g.id) < g.capacity);
if (!group) continue;
placed.set(group.id, placed.get(group.id) + 1);
add('allocationAssignment', { id: uuidv4(), setId: cabins.id, groupId: group.id, attendeeId: attendee.id });
}
// Sponsor-a-spot donations into the bursary pool.
const MESSAGES = ['Every child deserves camp!', 'Camp changed my life — pass it on.', 'Praying for a great week.', null, null];
for (const sponsor of shuffle(people.everyone).slice(0, 9)) {
const anonymous = chance(0.3);
add('payment', {
id: uuidv4(), amount: pick([500, 825, 1000, 1650, 1650, 3300]), method: 'card', userId: sponsor.id,
eventId: event.id, isDonation: true, isSponsorship: true, sponsorName: anonymous ? null : sponsor.name,
sponsorMessage: pick(MESSAGES), sponsorAnonymous: anonymous, status: 'completed',
externalId: `demo_${uuidv4().replace(/-/g, '')}`, createdAt: pastDate(30),
});
}
return event;
}
function buildCarols(people) {
const start = nextWeekday(55, 0, 19);
const event = addEvent({
title: 'Carols by Candlelight',
description: 'Our much-loved Christmas evening under the stars.\n\n'
+ 'Bring a blanket and the whole family for carols, the Christmas story told by our children’s '
+ 'ministry, and hot chocolate afterwards. Entry is free, but please register so we know how '
+ 'many candles and chairs to set out.\n\n'
+ 'A collection will be taken for the local children’s home.',
startDate: start,
endDate: addTime(start, 2 * HOUR),
price: 0,
picture: IMG('photo-1543589077-47d81606c1bf'),
location: 'Church lawn (hall if it rains)',
});
const entry = addOption(event, { name: 'Free entry', price: 0, stockLimit: 500 });
addSection(event, 'Lawn entrance', [entry]);
addCost(event, 'Candles & songbooks', 'once_off', 1800);
addCost(event, 'Sound & lighting', 'once_off', 3500);
for (const user of shuffle(people.everyone).slice(0, 48)) {
const quantity = Number(weighted({ 1: 15, 2: 30, 3: 15, 4: 25, 5: 10, 6: 5 }));
addRegistration(event, user, [line(entry, quantity)], { createdAt: pastDate(30) });
}
for (const donor of shuffle(people.everyone).slice(0, 14)) {
add('payment', {
id: uuidv4(), amount: pick([100, 150, 200, 250, 500, 500, 1000]), method: 'card', userId: donor.id,
eventId: event.id, isDonation: true, status: 'completed',
externalId: `demo_${uuidv4().replace(/-/g, '')}`, createdAt: pastDate(30),
});
}
return event;
}
function buildMarriageWeekend(people) {
const start = nextWeekday(98, 5, 17);
const event = addEvent({
title: 'Marriage Enrichment Weekend',
description: 'Take a weekend away just for the two of you.\n\n'
+ 'Three relaxed sessions on communication, conflict and dreaming together, led by Pastor Johan '
+ '& Elmarie Venter — with plenty of free time to walk, rest and enjoy the lodge.\n\n'
+ 'Your package covers two nights’ accommodation, all meals and the course material. A R1 000 '
+ 'deposit secures your room; rooms are limited.',
startDate: start,
endDate: addTime(start, 43 * HOUR),
registrationDeadline: addTime(start, -21 * DAY),
price: 3400,
picture: IMG('photo-1606800052052-a08af7148866'),
location: 'Kloofzicht Lodge, Muldersdrift',
contactName: 'Lerato Khumalo',
});
const couple = addOption(event, { name: 'Couple package (2 nights)', price: 3400 });
const [garden, mountain] = addVariants(couple, [
{ name: 'Garden room', price: 3400, stockLimit: 8 },
{ name: 'Mountain-view room', price: 3900, stockLimit: 6 },
]);
addTier(couple, { variantId: garden.id, deadline: addTime(NOW, 45 * DAY), price: 2990 });
addTier(couple, { variantId: mountain.id, deadline: addTime(NOW, 45 * DAY), price: 3450 });
const spa = addOption(event, { name: 'Saturday spa treatment (per person)', price: 650, isMainTicket: false });
addCost(event, 'Lodge accommodation & meals', 'per_item', 1950, couple);
addCost(event, 'Spa treatments', 'per_item', 420, spa);
addCost(event, 'Course workbooks', 'once_off', 1100);
const variants = shuffle([...Array(6).fill(garden), ...Array(5).fill(mountain)]);
for (const user of shuffle(people.everyone).slice(0, variants.length)) {
const lines = [line(couple, 1, { variant: variants.pop() })];
if (chance(0.4)) lines.push(line(spa, 2));
const plan = weighted({ eft: 35, card: 15, deposit: 35, none: 15 });
addRegistration(event, user, lines, {
createdAt: pastDate(30),
pay: plan === 'eft' || plan === 'card' ? 'full' : plan,
method: plan === 'card' ? 'card' : 'eft',
deposit: 1000,
});
}
return event;
}
// ─── Funnel analytics ────────────────────────────────────────────────────────
const SOURCES = { direct: 30, whatsapp: 35, facebook: 18, instagram: 10, email: 7 };
const DEVICES = { mobile: 72, desktop: 22, tablet: 6 };
function buildAnalytics() {
const session = (eventId, stages, at) => {
const sessionId = uuidv4();
const source = weighted(SOURCES);
const device = weighted(DEVICES);
let t = at.getTime() - stages.length * int(2, 15) * MINUTE;
for (const stage of stages) {
add('analyticsEvent', { id: uuidv4(), sessionId, eventId, stage, source, device, createdAt: new Date(Math.min(t, at.getTime())) });
t += int(1, 12) * MINUTE;
}
return sessionId;
};
for (const event of rows.event) {
const registrations = (rows.registration || []).filter(r => r.eventId === event.id);
for (const reg of registrations) {
// Most registrations arrive from a tracked session (the rest declined cookies).
if (chance(0.8)) reg.analyticsSessionId = session(event.id, ['view', 'start', 'submit'], reg.createdAt);
}
const browsers = registrations.length * int(3, 5);
for (let i = 0; i < browsers; i++) {
session(event.id, chance(0.3) ? ['view', 'start'] : ['view'], pastDate(30));
}
}
}
// ─── Clearing ────────────────────────────────────────────────────────────────
async function clearData(tx) {
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
if (toClear.length) {
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
}
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
return { tables: toClear.length, users: count };
}
function clearScheduledEmails() {
if (!fs.existsSync(SCHEDULED_EMAILS_PATH)) return;
fs.writeFileSync(SCHEDULED_EMAILS_PATH, JSON.stringify({ jobs: [] }, null, 2), 'utf-8');
}
// ─── Main ────────────────────────────────────────────────────────────────────
function describeDatabase() {
try {
const u = new URL(process.env.DATABASE_URL);
return `${u.pathname.replace(/^\//, '')} on ${u.hostname}${u.port ? `:${u.port}` : ''}`;
} catch {
return 'the configured database';
}
}
async function confirm(admin) {
if (SKIP_PROMPT) return true;
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise(resolve => rl.question('Type "demo" to continue: ', resolve));
rl.close();
return answer.trim().toLowerCase() === 'demo';
}
async function main() {
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
if (!admin) {
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
console.error('Set DEMO_ADMIN_EMAIL to the account you want to keep.');
process.exitCode = 1;
return;
}
if (admin.role !== 'admin') console.warn(`[warn] ${admin.email} has role "${admin.role}", not admin.`);
if (!(await confirm(admin))) {
console.log('Cancelled — nothing was changed.');
return;
}
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
const people = buildPeople();
const events = [
buildMensBreakfast(people),
buildFamilyFunDay(people),
buildWomensConference(people),
buildYouthCamp(people),
buildCarols(people),
buildMarriageWeekend(people),
];
buildAnalytics();
const cleared = await prisma.$transaction(async (tx) => {
const result = await clearData(tx);
for (const model of INSERT_ORDER) {
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
}
return result;
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
clearScheduledEmails();
const count = (model, where = () => true) => (rows[model] || []).filter(where).length;
console.log(`\nCleared ${cleared.tables} tables and ${cleared.users} users.`);
console.log('\nDemo events:');
for (const event of events) {
const regs = rows.registration.filter(r => r.eventId === event.id);
const places = rows.ticket.filter(t => t.eventId === event.id).reduce((s, t) => s + t.quantity, 0);
const byStatus = ['paid', 'partial_paid', 'pending', 'cancelled']
.map(s => [s, regs.filter(r => r.status === s).length]).filter(([, n]) => n).map(([s, n]) => `${n} ${s}`).join(', ');
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs.length).padStart(3)} registrations (${byStatus}), ${places} ticketed places`);
}
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('attendee')} named campers, ${count('analyticsEvent')} funnel events.`);
console.log(`\nKept: site settings and ${admin.email}.`);
console.log(`Demo staff logins (password ${DEMO_PASSWORD}):`);
for (const s of staffTeam) console.log(` ${s.role.padEnd(10)} ${s.email}`);
console.log(`Every other demo person also uses ${DEMO_PASSWORD}.`);
}
main()
.catch(err => { console.error(err); process.exitCode = 1; })
.finally(() => prisma.$disconnect());
+14 -2
View File
@@ -1,5 +1,17 @@
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const prisma = new PrismaClient();
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 5000,
});
return new PrismaClient({ adapter });
}
module.exports = prisma;
const prisma = createPrismaClient();
module.exports = prisma;
module.exports.createPrismaClient = createPrismaClient;
+5 -4
View File
@@ -6,6 +6,7 @@ const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
@@ -987,7 +988,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } }
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
await prisma.registration.update({
@@ -1090,7 +1091,7 @@ const createYocoCheckout = async (req, res) => {
lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } });
}
const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems };
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } });
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
}
@@ -1151,7 +1152,7 @@ const createYocoCheckout = async (req, res) => {
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } }
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
return res.status(200).json({
@@ -1163,7 +1164,7 @@ const createYocoCheckout = async (req, res) => {
}
} catch (error) {
console.error('Yoco checkout error:', error.response?.data || error.message);
res.status(error.response?.status || 400).json({
res.status(error.statusCode || error.response?.status || 400).json({
message: error.response?.data?.message || error.message
});
}
@@ -2254,7 +2254,7 @@ const updateAttendees = async (req, res) => {
where: { id: registration.id },
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
});
res.json(updated);
res.json({ ...updated, attendees: updated.attendees.map(({ userId, ...a }) => a) });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
@@ -8,6 +8,10 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
// Yoco secrets are only readable (masked) and writable through /api/yoco — never
// through the generic settings endpoints.
const YOCO_PRIVATE_KEYS = new Set(['yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id']);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
@@ -44,6 +48,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (YOCO_PRIVATE_KEYS.has(r.key)) continue;
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
@@ -79,7 +84,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([, v]) => v !== undefined && v !== null)
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -118,7 +123,7 @@ const updateSettings = async (req, res) => {
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !YOCO_PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -195,7 +200,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !YOCO_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
@@ -1,4 +1,5 @@
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
@@ -87,7 +88,7 @@ const createSponsorCheckout = async (req, res) => {
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
};
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
headers: { 'Content-Type': 'application/json', Authorization: await yocoAuthHeader(), 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
});
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
} catch (error) {
+76 -7
View File
@@ -103,6 +103,7 @@ const getUserTickets = async (req, res) => {
},
user: { select: { id: true, name: true } },
event: true,
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
scannedBy: {
@@ -703,20 +704,84 @@ const sendTicketsTo = async (req, res) => {
}
};
// @desc Transfer a ticket to someone else (new QR code; billing stays with the payer)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { transferTicket } = require('../utils/ticketTransfer');
const { requestTransfer } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
res.status(201).json({
message: sent.length
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
requestId: request.id,
expiresAt: request.expiresAt,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Withdraw a transfer that hasn't been accepted yet
// @route DELETE /api/tickets/:id/transfer
// @access Private (payer or holder; supervisors)
const cancelTransferHandler = async (req, res) => {
try {
const { cancelTransfer } = require('../utils/ticketTransfer');
await cancelTransfer(req.params.id, req.user);
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc What's on offer behind a transfer link
// @route GET /api/tickets/transfer-offer/:token
// @access Public (link holder)
const getTransferOffer = async (req, res) => {
try {
const { findRequestByToken } = require('../utils/ticketTransfer');
const r = await findRequestByToken(req.params.token);
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
const t = r.ticket;
res.json({
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
event: t.event,
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
});
} catch (error) {
res.status(400).json({ message: safeErrorMessage(error) });
}
};
// @route POST /api/tickets/transfer-offer/:token/decline
const declineTransferOffer = async (req, res) => {
try {
const { declineTransfer } = require('../utils/ticketTransfer');
await declineTransfer(req.params.token);
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc Accept a transferred ticket — the transfer happens now
// @route POST /api/tickets/transfer-offer/:token/accept
// @access Public (link holder)
const acceptTransferOffer = async (req, res) => {
try {
const { acceptTransfer } = require('../utils/ticketTransfer');
const r = await acceptTransfer(req.params.token);
const actor = r.request.requestedBy;
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode },
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
ip: getClientIp(req),
});
@@ -744,7 +809,7 @@ const transferTicketHandler = async (req, res) => {
}
res.json({
message: `Ticket transferred to ${a.name}. We've sent them the new ticket${r.needsConsent ? ' and a parental consent request' : ''}. The old QR code no longer works.`,
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
ticketId: r.target.id,
});
} catch (error) {
@@ -980,5 +1045,9 @@ module.exports = {
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
};
+4 -3
View File
@@ -4,6 +4,7 @@ const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { getYocoWebhookSecret } = require('../utils/yoco');
// @desc Handle Yoco webhook events
// @route POST /api/webhooks/yoco
@@ -54,10 +55,10 @@ const handleYocoWebhook = async (req, res) => {
// Construct the signed content
const signedContent = `${id}.${timestamp}.${requestBody}`;
// Get the webhook secret from environment variables
const secret = process.env.YOCO_WEBHOOK_SECRET;
// Webhook secret from Site Settings → Yoco (falls back to .env)
const secret = await getYocoWebhookSecret();
if (!secret) {
console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables');
console.error('Yoco webhook secret is not configured (Site Settings → Yoco, or YOCO_WEBHOOK_SECRET)');
const responseObj = {
success: false,
message: 'Server configuration error',
+149
View File
@@ -0,0 +1,149 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { encrypt } = require('../utils/encryption');
const { invalidate: invalidateSettingsCache, warmCache, getSetting } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
} = require('../utils/yoco');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// Write settings (null/'' clears), then refresh the cache so the new values apply immediately.
async function saveSettings(values) {
const ops = Object.entries(values).map(([key, value]) => {
const stored = value ? (key === KEY_WEBHOOK_ID ? value : encrypt(value)) : '';
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
});
await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache();
}
const audit = (req, action, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'AppSetting', metadata, ip: getClientIp(req),
});
// @desc Yoco configuration with secrets masked
// @route GET /api/yoco/config
// @access Admin
const getConfig = async (req, res) => {
try {
const [secretKey, webhookSecret, webhookId, source] = await Promise.all([
getYocoSecretKey(), getYocoWebhookSecret(), getSetting(KEY_WEBHOOK_ID), getYocoConfigSource(),
]);
res.json({
secretKeyMasked: maskSecret(secretKey),
secretKeySource: source.secretKey,
mode: yocoMode(secretKey),
webhookSecretMasked: maskSecret(webhookSecret),
webhookSecretSource: source.webhookSecret,
webhookId: webhookId || '',
webhookUrl: defaultWebhookUrl(req),
configured: !!(secretKey && webhookSecret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Save the secret key and/or webhook secret ('' clears)
// @route PUT /api/yoco/config
// @access Admin
const updateConfig = async (req, res) => {
try {
const { secretKey, webhookSecret } = req.body || {};
const updates = {};
if (typeof secretKey === 'string') {
const key = secretKey.trim();
if (key && !/^sk_(test|live)_\S+$/.test(key)) {
res.status(400); throw new Error('The secret key should start with sk_test_ or sk_live_.');
}
// Check Yoco accepts the key before replacing a working one.
if (key) await listWebhooks(key);
updates[KEY_SECRET] = key;
}
if (typeof webhookSecret === 'string') {
const secret = webhookSecret.trim();
if (secret && !/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) {
res.status(400); throw new Error('The webhook secret should start with whsec_.');
}
updates[KEY_WEBHOOK] = secret;
// A typed-in secret isn't tied to a webhook we registered.
updates[KEY_WEBHOOK_ID] = '';
}
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
await saveSettings(updates);
audit(req, 'yoco_config_updated', { changedKeys: Object.keys(updates) });
res.json({ message: 'Yoco settings saved' });
} catch (e) {
fail(res, e);
}
};
// @desc Webhooks registered on the Yoco account
// @route GET /api/yoco/webhooks
// @access Admin
const getWebhooks = async (req, res) => {
try {
const [hooks, activeId] = await Promise.all([listWebhooks(), getSetting(KEY_WEBHOOK_ID)]);
const here = defaultWebhookUrl(req);
res.json({
webhooks: hooks.map(h => ({ ...h, isActive: !!activeId && h.id === activeId, pointsHere: !!here && h.url === here })),
defaultUrl: here,
});
} catch (e) {
fail(res, e);
}
};
// @desc Register a webhook at Yoco and store the secret it returns
// @route POST /api/yoco/webhooks
// @access Admin
const createWebhook = async (req, res) => {
try {
const name = String(req.body?.name || '').trim() || 'hope-events';
const url = String(req.body?.url || '').trim() || defaultWebhookUrl(req);
const problem = webhookUrlProblem(url);
if (problem) { res.status(400); throw new Error(problem); }
const hook = await registerWebhook(name, url);
if (!hook?.secret) throw new Error('Yoco did not return a webhook secret.');
await saveSettings({ [KEY_WEBHOOK]: hook.secret, [KEY_WEBHOOK_ID]: hook.id });
audit(req, 'yoco_webhook_registered', { webhookId: hook.id, url: hook.url, mode: hook.mode });
res.status(201).json({
message: 'Webhook registered and its secret saved',
webhook: { id: hook.id, name: hook.name, url: hook.url, mode: hook.mode },
webhookSecretMasked: maskSecret(hook.secret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Delete a webhook at Yoco (clears the stored secret if it was the active one)
// @route DELETE /api/yoco/webhooks/:id
// @access Admin
const removeWebhook = async (req, res) => {
try {
const { id } = req.params;
await deleteWebhook(id);
const wasActive = (await getSetting(KEY_WEBHOOK_ID)) === id;
if (wasActive) await saveSettings({ [KEY_WEBHOOK]: '', [KEY_WEBHOOK_ID]: '' });
audit(req, 'yoco_webhook_deleted', { webhookId: id, clearedSecret: wasActive });
res.json({ message: wasActive ? 'Webhook deleted and its secret removed' : 'Webhook deleted', clearedSecret: wasActive });
} catch (e) {
fail(res, e);
}
};
module.exports = { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook };
+67 -4
View File
@@ -24,13 +24,10 @@ const express = require('express');
const { version: API_VERSION } = require('../package.json');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const prisma = require('./config/db');
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
const getRawBody = require('raw-body');
// Initialize Prisma client
const prisma = new PrismaClient();
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
@@ -149,6 +146,7 @@ const cashupRoutes = require('./routes/cashupRoutes');
const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -188,6 +186,7 @@ app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
app.use('/api/feedback', require('./routes/feedbackRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
app.use('/api/yoco', yocoRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
@@ -392,6 +391,7 @@ app.get('/docs', async (req, res) => {
// ── Endpoint data ────────────────────────────────────────────────────────
const GROUPS = [
{ title: 'Users', base: '/api/users', endpoints: [
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
responses:[
@@ -515,6 +515,14 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Events', base: '/api/events', endpoints: [
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
queryParams:{ limit:'Max results (default 20)' },
responses:[
@@ -592,6 +600,12 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Registrations', base: '/api/registrations', endpoints: [
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
@@ -644,6 +658,7 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
@@ -688,6 +703,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
@@ -748,6 +767,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
responses:[
@@ -787,6 +810,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Tickets', base: '/api/tickets', endpoints: [
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
@@ -866,6 +893,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Reports', base: '/api/reports', endpoints: [
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
@@ -890,6 +919,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Cashups', base: '/api/cashups', endpoints: [
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
@@ -911,6 +942,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Uploads', base: '/api/uploads', endpoints: [
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
responses:[
@@ -967,6 +1000,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
@@ -987,6 +1024,17 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
]},
{ title: 'Dashboard stats', base: '/api/stats', endpoints: [
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
]},
{ title: 'Backups', base: '/api/backups', endpoints: [
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
@@ -996,6 +1044,21 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
]},
{ title: 'Yoco', base: '/api/yoco', endpoints: [
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
{ method:'PUT', path:'/api/yoco/config', auth:'admin', desc:'Save the secret key and/or webhook secret (stored AES-256-GCM encrypted). A new secret key is checked against Yoco first. An empty string clears the value (the .env fallback then applies).',
request:{ body:{ secretKey:'sk_test_...', webhookSecret:'whsec_...' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Yoco settings saved' }}]},
{ method:'GET', path:'/api/yoco/webhooks', auth:'admin', desc:'Webhooks registered on the Yoco account, flagged isActive (its secret is the stored one) and pointsHere (URL is this server).',
responses:[{ status:200, desc:'Success', body:{ webhooks:[{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test', isActive:true, pointsHere:true }], defaultUrl:'https://api.example.com/api/webhooks/yoco' }}]},
{ method:'POST', path:'/api/yoco/webhooks', auth:'admin', desc:'Register a webhook at Yoco (defaults: name "hope-events", URL = BACKEND_URL/api/webhooks/yoco) and store the secret Yoco returns. The URL must be public https.',
request:{ body:{ name:'hope-events', url:'https://api.example.com/api/webhooks/yoco' }},
responses:[{ status:201, desc:'Registered', body:{ message:'Webhook registered and its secret saved', webhook:{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test' }, webhookSecretMasked:'whsec_*****************1Xz' }}]},
{ method:'DELETE', path:'/api/yoco/webhooks/:id', auth:'admin', desc:'Delete a webhook at Yoco. If it was the active one, the stored webhook secret is cleared too.',
responses:[{ status:200, desc:'Deleted', body:{ message:'Webhook deleted', clearedSecret:false }}]},
]},
{ title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
+9
View File
@@ -15,6 +15,10 @@ const {
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
@@ -24,6 +28,11 @@ router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.delete('/:id/transfer', protect, cancelTransferHandler);
// Transfer links (public: the person the ticket is offered to)
router.get('/transfer-offer/:token', getTransferOffer);
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
router.post('/transfer-offer/:token/decline', declineTransferOffer);
router.get('/:id/transfers', protect, getTicketTransfers);
// Staff routes
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.get('/webhooks', protect, admin, getWebhooks);
router.post('/webhooks', protect, admin, createWebhook);
router.delete('/webhooks/:id', protect, admin, removeWebhook);
module.exports = router;
+3 -5
View File
@@ -40,11 +40,9 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
*/
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
const match = await findAccountByContact({ email, phone }, db);
if (match.conflict) {
const err = new Error('That email address and phone number belong to two different accounts. Use just one of them.');
err.statusCode = 400;
throw err;
}
// Email and phone pointing at two different accounts: go with the email's account,
// without saying so — the person typing the details mustn't learn who has an account.
if (match.conflict) return { user: match.emailUser, created: false };
if (match.user) return { user: match.user, created: false };
const { v4: uuidv4 } = require('uuid');
const { hashPassword } = require('../config/auth');
+5
View File
@@ -346,6 +346,11 @@ function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
*/
function attachComputedTotals(registration) {
if (!registration) return registration;
// Never send which named attendees have an account: the person who typed their email or
// phone mustn't be able to tell (the link is used server-side only).
if (Array.isArray(registration.attendees)) {
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
}
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
+2 -2
View File
@@ -4,14 +4,14 @@
* take effect without a server restart.
* Falls back to environment variables when a key is not found in the DB.
*
* Encrypted keys (smtp_user, smtp_pass) are automatically decrypted on read.
* Encrypted keys (SMTP credentials, Yoco secrets) are automatically decrypted on read.
*/
const prisma = require('../config/db');
const { decrypt, isEncrypted } = require('./encryption');
// Keys stored encrypted in the DB — decrypted transparently on read.
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
let _cache = {};
let _lastFetch = 0;
+153 -2
View File
@@ -1,6 +1,12 @@
/**
* Ticket transfer — handing a ticket to someone else before the event.
*
* Privacy first: the sender only types the new holder's name and email/phone — there is
* no user search, and nothing tells the sender whether those details belong to an
* account. A TicketTransferRequest is created and a link is sent to the details typed;
* nothing changes on the ticket until the new holder accepts (requestTransfer →
* acceptTransfer). Accepting runs the transfer itself:
*
* - The new holder becomes the ticket's attendee (name, contact details, account). The
* payer stays responsible for the registration; billing doesn't move.
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
@@ -17,6 +23,13 @@ const { validateAttendees } = require('./attendees');
const { isMinor } = require('./consent');
const SUPERVISORS = ['admin', 'supervisor'];
const OFFER_DAYS = 7;
const crypto = require('crypto');
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`transfer:${token}`).digest('hex');
function transferLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/transfer/${token}`;
}
function transferError(message, statusCode = 400) {
const err = new Error(message);
@@ -71,7 +84,7 @@ async function transferTicket({ ticketId, actor, ...holder }) {
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
if (currentHolderId && currentHolderId === toUser.id) throw transferError(`${clean.name} already holds this ticket.`);
if (currentHolderId && currentHolderId === toUser.id) throw transferError('This ticket is already yours.');
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
const attendeeData = {
@@ -126,6 +139,141 @@ async function transferTicket({ ticketId, actor, ...holder }) {
};
}
/** Checks a transfer the way transferTicket will, without touching anything. */
async function checkTransfer({ ticketId, actor, ...holder }) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true, holder: { select: { name: true } }, user: { select: { name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { eventOption: { select: { name: true } }, registration: { select: { id: true, status: true } } } },
},
});
if (!ticket) throw transferError('Ticket not found', 404);
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
if (blocker) throw transferError(blocker.message, blocker.status);
const ro = ticket.registrationOption;
const [clean] = validateAttendees({ ...ticket.event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
}]);
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
return { ticket, clean };
}
async function sendTransferOffer(request, ticket, token, fromName) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const link = transferLink(token);
const event = ticket.event;
const when = new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
const subject = `${fromName} wants to give you a ticket for ${event.title}`;
const sent = [];
if (request.toEmail) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">You've been offered a ticket</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.toName}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${fromName} would like to give you their ticket for <strong>${event.title}</strong> on <strong>${when}</strong>. Accept it to get your own ticket and QR code.</p>
${ctaButton('View and accept the ticket', link)}
${fallbackLink(link)}
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">If you weren't expecting this, you can ignore this email or decline from the link.</p>
`, { preheader: subject });
try {
await sendMail({ to: request.toEmail, subject, html, text: `Hi ${request.toName},\n\n${fromName} would like to give you their ticket for ${event.title} on ${when}. View and accept it here: ${link}` });
sent.push('email');
} catch (e) {
console.warn('[transfer] offer email failed:', e?.message || e);
}
}
if (request.toPhone && isValidZAPhone(request.toPhone)) {
await waTextAny({ name: request.toName, phoneNumber: request.toPhone }, `Hi ${request.toName}, ${fromName} would like to give you their ticket for *${event.title}* on ${when}. View and accept it here: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/**
* Starts a transfer: checks it, then sends the new holder a link to accept. Nothing on
* the ticket changes yet, and no account is looked up.
*/
async function requestTransfer({ ticketId, actor, ...holder }) {
const { ticket, clean } = await checkTransfer({ ticketId, actor, ...holder });
const token = crypto.randomBytes(24).toString('hex');
const expiresAt = new Date(Math.min(new Date(ticket.event.startDate).getTime(), Date.now() + OFFER_DAYS * 864e5));
const request = await prisma.$transaction(async (tx) => {
// One open offer per ticket: a new one replaces the old.
await tx.ticketTransferRequest.updateMany({ where: { ticketId: ticket.id, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
return tx.ticketTransferRequest.create({
data: {
ticketId: ticket.id, requestedById: actor.id, toName: clean.name, toEmail: clean.email, toPhone: clean.phone,
details: { dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone },
tokenHash: hashToken(token), expiresAt,
},
});
});
const fromName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || actor.name || 'Someone';
const sent = await sendTransferOffer(request, ticket, token, fromName);
return { request, sent };
}
async function findRequestByToken(token) {
if (!token || typeof token !== 'string' || token.length > 200) return null;
const request = await prisma.ticketTransferRequest.findUnique({
where: { tokenHash: hashToken(token) },
include: {
requestedBy: { select: { id: true, name: true, role: true } },
ticket: { include: { event: { select: { id: true, title: true, startDate: true, location: true } }, registrationOption: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } }, attendee: { select: { name: true } }, user: { select: { name: true } } } },
},
});
if (request && request.status === 'pending' && request.expiresAt <= new Date()) {
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'expired' } });
request.status = 'expired';
}
return request;
}
/** The new holder accepts: the transfer happens now, with every check run again. */
async function acceptTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
// Claim it first so a double-click can't run the transfer twice.
const claimed = await prisma.ticketTransferRequest.updateMany({ where: { id: request.id, status: 'pending' }, data: { status: 'accepted', respondedAt: new Date() } });
if (claimed.count !== 1) throw transferError('This transfer has already been answered.', 409);
try {
const d = request.details || {};
const result = await transferTicket({
ticketId: request.ticketId, actor: request.requestedBy,
name: request.toName, email: request.toEmail, phone: request.toPhone,
dateOfBirth: d.dateOfBirth, gender: d.gender, parentName: d.parentName, parentEmail: d.parentEmail, parentPhone: d.parentPhone,
});
return { ...result, request };
} catch (e) {
// Couldn't go ahead (e.g. the ticket was scanned meanwhile): the offer is void.
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'cancelled' } }).catch(() => {});
throw e;
}
}
async function declineTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'declined', respondedAt: new Date() } });
return request;
}
/** The sender (or a supervisor) withdraws an offer that hasn't been accepted yet. */
async function cancelTransfer(ticketId, actor) {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId }, select: { userId: true, holderUserId: true } });
if (!ticket) throw transferError('Ticket not found', 404);
if (!SUPERVISORS.includes(actor.role) && ticket.userId !== actor.id && ticket.holderUserId !== actor.id) throw transferError('Not authorized', 403);
const { count } = await prisma.ticketTransferRequest.updateMany({ where: { ticketId, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
if (!count) throw transferError('There is no transfer waiting for this ticket.', 404);
}
/** The transfer that retired this QR code, if any. */
async function findTransferByOldCode(qrCode) {
if (!qrCode) return null;
@@ -137,4 +285,7 @@ function transferredMessage(transfer) {
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
}
module.exports = { transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage };
module.exports = {
transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage,
checkTransfer, requestTransfer, findRequestByToken, acceptTransfer, declineTransfer, cancelTransfer,
};
+134
View File
@@ -0,0 +1,134 @@
/**
* Yoco configuration and API helpers.
*
* The secret key and webhook secret are stored AES-256-GCM encrypted in AppSetting
* (yoco_secret_key, yoco_webhook_secret) and managed from Admin → Site Settings → Yoco.
* YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in .env are only used as fallbacks when the
* database has no value, so older deployments keep working until an admin saves them.
*/
const axios = require('axios');
const prisma = require('../config/db');
const { getSetting } = require('./settingsCache');
const YOCO_API = 'https://payments.yoco.com/api';
const KEY_SECRET = 'yoco_secret_key';
const KEY_WEBHOOK = 'yoco_webhook_secret';
const KEY_WEBHOOK_ID = 'yoco_webhook_id';
const getYocoSecretKey = () => getSetting(KEY_SECRET, process.env.YOCO_SECRET_KEY);
const getYocoWebhookSecret = () => getSetting(KEY_WEBHOOK, process.env.YOCO_WEBHOOK_SECRET);
/** Where each value currently comes from: 'db' | 'env' | 'none'. */
async function getYocoConfigSource() {
const rows = await prisma.appSetting.findMany({ where: { key: { in: [KEY_SECRET, KEY_WEBHOOK] } } });
const inDb = new Set(rows.filter(r => r.value).map(r => r.key));
const source = (key, envVar) => (inDb.has(key) ? 'db' : process.env[envVar] ? 'env' : 'none');
return {
secretKey: source(KEY_SECRET, 'YOCO_SECRET_KEY'),
webhookSecret: source(KEY_WEBHOOK, 'YOCO_WEBHOOK_SECRET'),
};
}
const KNOWN_PREFIXES = ['sk_test_', 'sk_live_', 'whsec_'];
/**
* Mask a secret like a card number: keep the prefix and the last 3 characters.
* maskSecret('sk_test_abcdefghijklmnaba') → 'sk_test_**************aba'
*/
function maskSecret(value) {
if (!value) return '';
const prefix = KNOWN_PREFIXES.find(p => value.startsWith(p)) || value.slice(0, 4);
const rest = value.slice(prefix.length);
if (rest.length <= 6) return prefix + '*'.repeat(rest.length);
return prefix + '*'.repeat(rest.length - 3) + rest.slice(-3);
}
function yocoMode(key) {
if (!key) return 'unknown';
if (key.startsWith('sk_test_')) return 'test';
if (key.startsWith('sk_live_')) return 'live';
return 'unknown';
}
function notConfiguredError() {
const err = new Error('Online payments are not configured. An admin needs to add the Yoco secret key in Site Settings → Yoco.');
err.statusCode = 503;
return err;
}
/** `Bearer <key>` for Yoco API calls; throws a 503-style error when no key is set. */
async function yocoAuthHeader() {
const key = await getYocoSecretKey();
if (!key) throw notConfiguredError();
return `Bearer ${key}`;
}
/** Turn a Yoco API error into a message an admin can act on. */
function friendlyYocoError(e) {
const status = e.response?.status;
const detail = e.response?.data?.message || e.response?.data?.description || e.response?.data?.errorMessage;
let message;
if (status === 401) message = 'Yoco rejected the secret key. Check that it was copied correctly.';
else if (status === 403) message = 'This Yoco account is not allowed to manage webhooks.';
else if (status === 400) message = `Yoco rejected the request${detail ? `: ${detail}` : '.'}`;
else if (status) message = `Yoco returned an error (${status})${detail ? `: ${detail}` : '.'}`;
else message = e.message || 'Could not reach Yoco.';
const err = new Error(message);
err.statusCode = e.statusCode || (status === 401 || status === 403 || status === 400 ? 400 : 502);
return err;
}
/** Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it). */
async function yocoRequest(method, path, body, key) {
const auth = key ? `Bearer ${key}` : await yocoAuthHeader();
try {
const res = await axios({
method,
url: `${YOCO_API}${path}`,
data: body,
headers: { 'Content-Type': 'application/json', Authorization: auth },
timeout: 15000,
});
return res.data;
} catch (e) {
throw friendlyYocoError(e);
}
}
async function listWebhooks(key) {
const data = await yocoRequest('get', '/webhooks', undefined, key);
return data?.subscriptions || [];
}
const registerWebhook = (name, url) => yocoRequest('post', '/webhooks', { name, url });
const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComponent(id)}`);
/**
* The URL Yoco should post events to: BACKEND_URL, or the public origin of the
* current request (correct behind a proxy when TRUST_PROXY is set).
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
function webhookUrlProblem(url) {
let u;
try { u = new URL(url); } catch { return 'Enter a full webhook URL, e.g. https://api.example.com/api/webhooks/yoco'; }
if (u.protocol !== 'https:') return 'The webhook URL must use https:// so Yoco can reach it.';
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
return 'The webhook URL must be a public address, not localhost.';
}
return null;
}
module.exports = {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, yocoAuthHeader,
yocoRequest, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
};
+99
View File
@@ -0,0 +1,99 @@
jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
}));
const prisma = require('../src/config/db');
const { encrypt } = require('../src/utils/encryption');
const settingsCache = require('../src/utils/settingsCache');
const {
maskSecret, yocoMode, getYocoSecretKey, getYocoWebhookSecret, yocoAuthHeader,
defaultWebhookUrl, webhookUrlProblem,
} = require('../src/utils/yoco');
const ENV = { ...process.env };
afterEach(() => { process.env = { ...ENV }; });
function dbHas(rows) {
prisma.appSetting.findMany.mockResolvedValue(rows);
settingsCache.invalidate();
}
describe('maskSecret', () => {
test('keeps the prefix and the last 3 characters', () => {
expect(maskSecret('sk_test_0123456789abcdefaba')).toBe('sk_test_****************aba');
expect(maskSecret('sk_live_0123456789xyz')).toBe('sk_live_**********xyz');
expect(maskSecret('whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw')).toBe(`whsec_${'*'.repeat(29)}aSw`);
});
test('masks the length exactly, like a card number', () => {
const v = 'sk_test_0123456789abcdefaba';
expect(maskSecret(v)).toHaveLength(v.length);
});
test('short values show nothing after the prefix', () => {
expect(maskSecret('sk_test_abc')).toBe('sk_test_***');
expect(maskSecret('whsec_')).toBe('whsec_');
});
test('unknown formats keep the first 4 characters', () => {
expect(maskSecret('abcd0123456789')).toBe('abcd*******789');
});
test('empty stays empty', () => {
expect(maskSecret('')).toBe('');
expect(maskSecret(undefined)).toBe('');
});
});
test('yocoMode reads the key prefix', () => {
expect(yocoMode('sk_test_x')).toBe('test');
expect(yocoMode('sk_live_x')).toBe('live');
expect(yocoMode('pk_test_x')).toBe('unknown');
expect(yocoMode('')).toBe('unknown');
});
describe('stored secrets', () => {
test('the database wins over .env, and is decrypted', async () => {
process.env.YOCO_SECRET_KEY = 'sk_test_fromenv';
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_test_fromdb') }]);
expect(await getYocoSecretKey()).toBe('sk_test_fromdb');
});
test('.env is the fallback when the database has no value', async () => {
process.env.YOCO_WEBHOOK_SECRET = 'whsec_ZW52';
dbHas([{ key: 'yoco_webhook_secret', value: '' }]);
expect(await getYocoWebhookSecret()).toBe('whsec_ZW52');
});
test('checkouts fail clearly when no key is configured', async () => {
delete process.env.YOCO_SECRET_KEY;
dbHas([]);
await expect(yocoAuthHeader()).rejects.toThrow(/not configured/);
});
test('the auth header uses the stored key', async () => {
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_live_abc') }]);
expect(await yocoAuthHeader()).toBe('Bearer sk_live_abc');
});
});
describe('webhook URL', () => {
const req = { protocol: 'https', get: () => 'events-demo-api.crosscode.co.za' };
test('built from BACKEND_URL', () => {
process.env.BACKEND_URL = 'https://api.example.com/';
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
});
test('falls back to the request origin', () => {
delete process.env.BACKEND_URL;
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
});
test('must be public https', () => {
expect(webhookUrlProblem('https://api.example.com/api/webhooks/yoco')).toBeNull();
expect(webhookUrlProblem('http://api.example.com/api/webhooks/yoco')).toMatch(/https/);
expect(webhookUrlProblem('https://localhost:5000/api/webhooks/yoco')).toMatch(/localhost/);
expect(webhookUrlProblem('not a url')).toMatch(/full webhook URL/);
});
});
-3
View File
@@ -5,9 +5,6 @@ const nextConfig: NextConfig = {
images: {
domains: ['images.unsplash.com'],
},
eslint: {
ignoreDuringBuilds: true,
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.23.0",
"version": "1.26.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.23.0",
"version": "1.26.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
@@ -8478,9 +8478,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz",
"integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.23.0",
"version": "1.26.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -62,7 +62,8 @@
"overrides": {
"next": {
"postcss": "^8.5.23"
}
},
"postcss-selector-parser": "^7.1.7"
},
"allowScripts": {
"@sentry/cli@2.58.6": true,
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
@@ -16,14 +17,14 @@ export default function ForgotPasswordPage() {
try {
setLoading(true);
setStatus(null);
const res: any = await apiFetch("/api/users/forgot", { method: "POST", body: { email } });
const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } });
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
} catch (err: any) {
} catch (err) {
try {
const parsed = JSON.parse(err?.message || "");
const parsed = JSON.parse(errorMessage(err) || "");
setStatus(parsed?.message || "Something went wrong");
} catch {
setStatus(err?.message || "Something went wrong");
setStatus(errorMessage(err) || "Something went wrong");
}
} finally {
setLoading(false);
@@ -41,7 +42,7 @@ export default function ForgotPasswordPage() {
</div>
<h1 className="text-xl font-semibold text-gray-900">Forgot your password?</h1>
</div>
<p className="text-gray-600 text-sm mb-6">Enter your account email and we'll send you a link to reset your password.</p>
<p className="text-gray-600 text-sm mb-6">Enter your account email and we&apos;ll send you a link to reset your password.</p>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
+8 -6
View File
@@ -1,5 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { apiFetch } from "@/lib/api";
import type { Event } from "@/types";
export const revalidate = 60;
@@ -14,11 +16,11 @@ const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
const { redirectUrl } = await params;
let event: any = null;
let event: (Event & { message?: string }) | null = null;
if (VALID_ALIAS.test(redirectUrl)) {
try {
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
event = await apiFetch<Event & { message?: string }>(`/api/events/by-alias/${redirectUrl}`);
} catch (error) {
console.error("Failed to fetch event:", error);
}
@@ -32,9 +34,9 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
<p className="text-gray-500 mb-6">
The page you&apos;re looking for doesn&apos;t exist.
</p>
<a href="/" className="text-brand-600 hover:underline transition-all duration-200">
<Link href="/" className="text-brand-600 hover:underline transition-all duration-200">
Back to Home &rarr;
</a>
</Link>
</main>
);
}
@@ -47,9 +49,9 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
<main className="flex flex-col items-center justify-center min-h-screen text-center">
<h1 className="text-3xl font-bold mb-2">This event has ended</h1>
<p className="text-gray-600">Thanks for joining us! Stay tuned for the next one.</p>
<a href="/" className="mt-4 text-brand-600 hover:underline">
<Link href="/" className="mt-4 text-brand-600 hover:underline">
Back to Home &rarr;
</a>
</Link>
</main>
);
}
+10 -10
View File
@@ -3,13 +3,14 @@ import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { saveToken } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import { appName } from "@/lib/siteConfig";
import { ShieldCheck } from "lucide-react";
import { errorMessage } from "@/lib/utils";
function ActivateAccountContent() {
const search = useSearchParams();
const router = useRouter();
const token = search.get("token") || "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
@@ -24,15 +25,14 @@ function ActivateAccountContent() {
try {
setLoading(true);
setStatus(null);
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
// Auto-login: save token and redirect to dashboard
if (data?.token) {
localStorage.setItem("token", data.token);
}
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password } });
// Auto-login: save the session token (under the key AuthContext reads) and load the
// dashboard fresh so the new session is picked up.
if (data?.token) saveToken(data.token);
setStatus("Your account is now active. Redirecting…");
setTimeout(() => router.push("/dashboard"), 1500);
} catch (err: any) {
setStatus(err?.message || "Activation failed. The link may have expired.");
setTimeout(() => { window.location.href = "/dashboard"; }, 1500);
} catch (err) {
setStatus(errorMessage(err) || "Activation failed. The link may have expired.");
} finally {
setLoading(false);
}
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { History } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
const ACTIONS = [
"refund_created",
@@ -32,6 +33,9 @@ const ACTIONS = [
"ticket_transferred",
"impersonation_started",
"impersonation_ended",
"yoco_config_updated",
"yoco_webhook_registered",
"yoco_webhook_deleted",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -58,6 +62,9 @@ const ACTION_LABELS: Record<string, string> = {
ticket_transferred: "Ticket transferred",
impersonation_started: "Impersonation started",
impersonation_ended: "Impersonation ended",
yoco_config_updated: "Yoco settings changed",
yoco_webhook_registered: "Yoco webhook registered",
yoco_webhook_deleted: "Yoco webhook deleted",
};
interface AuditLogEntry {
@@ -168,14 +175,15 @@ export default function AdminAuditLogPage() {
setEntries(res?.rows || []);
setTotal(res?.total ?? 0);
setPage(p);
} catch (e: any) {
setError(e?.message || "Failed to load audit log");
} catch (e) {
setError(errorMessage(e) || "Failed to load audit log");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
}, [token, buildQuery, setError]);
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
// load changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes.
useEffect(() => { if (token) load(1); }, [token, load]);
return (
<div className="max-w-6xl mx-auto w-full p-6">
@@ -1,12 +1,13 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
import type { EventCost, EventCostType, EventFinancials, CashupMethod, Event } from "@/types";
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
import { errorMessage } from "@/lib/utils";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
@@ -36,25 +37,25 @@ export default function EventCashupPage() {
const isClosed = data?.event?.cashupStatus === "closed";
const load = async () => {
const load = useCallback(async () => {
if (!token || !eventId) return;
setLoading(true);
setError(null);
try {
const [financials, event] = await Promise.all([
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
apiFetch<any>(`/api/events/${eventId}`, { authToken: token })
apiFetch<Event>(`/api/events/${eventId}`, { authToken: token })
]);
setData(financials);
setEventOptions((event.eventOptions || []).map((o: any) => ({ id: o.id, name: o.name })));
} catch (e: any) {
setError(e?.message || "Failed to load cashup data");
setEventOptions((event.eventOptions || []).map((o) => ({ id: o.id, name: o.name })));
} catch (e) {
setError(errorMessage(e) || "Failed to load cashup data");
} finally {
setLoading(false);
}
};
}, [token, eventId, setError]);
useEffect(() => { load(); }, [token, eventId]);
useEffect(() => { load(); }, [load]);
return (
<div className="max-w-4xl mx-auto space-y-4">
@@ -151,8 +152,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
}
setEditingId(null);
onChanged();
} catch (e: any) {
setErr(e?.message || "Failed to save cost");
} catch (e) {
setErr(errorMessage(e) || "Failed to save cost");
} finally {
setSaving(false);
}
@@ -163,8 +164,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
try {
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
onChanged();
} catch (e: any) {
alert(e?.message || "Failed to delete cost");
} catch (e) {
alert(errorMessage(e) || "Failed to delete cost");
}
};
@@ -179,7 +180,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
)}
</div>
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can&apos;t be changed until it&apos;s reopened.</div>}
<div className="overflow-auto">
<table className="w-full text-sm min-w-[600px]">
@@ -257,11 +258,11 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Paid from (optional)</label>
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as any)}>
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as "" | CashupMethod)}>
<option value="">Not from event takings</option>
{METHODS.map(m => <option key={m} value={m}>{METHOD_LABELS[m]}</option>)}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.</p>
<p className="text-[10px] text-gray-400 mt-0.5">If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method&apos;s expected amount.</p>
</div>
<div className="col-span-2">
<label className="block text-xs text-gray-600 mb-1">Notes (optional)</label>
@@ -306,16 +307,17 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
const [open, setOpen] = useState(true);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const load = () => {
const load = useCallback(() => {
if (!token || !eventId) return;
setLoading(true);
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
.finally(() => setLoading(false));
};
}, [eventId, token, onCashSummaryChange]);
useEffect(load, [eventId, token]);
// onCashSummaryChange is a state setter in the parent, so this only reruns when the event/token change.
useEffect(load, [load]);
const rows = data?.rows || [];
const sumOf = (m: "cash" | "card" | "eft" | "other", key: "total" | "count") =>
@@ -461,8 +463,8 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
});
onSaved();
} catch (e: any) {
setErr(e?.message || "Failed to save count");
} catch (e) {
setErr(errorMessage(e) || "Failed to save count");
} finally {
setSaving(false);
}
@@ -568,7 +570,7 @@ function ReportTab({ eventId, token, data, isClosed }: {
<div className="text-sm font-medium">Cash denomination count</div>
<table className="text-sm">
<tbody>
{reconciled.byMethod.cash.denominations.map((d: any) => (
{reconciled.byMethod.cash.denominations.map((d) => (
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
))}
</tbody>
@@ -590,7 +592,7 @@ function ReportTab({ eventId, token, data, isClosed }: {
</tr>
</thead>
<tbody>
{data.costs.map((c: any) => (
{data.costs.map((c) => (
<tr key={c.id} className="border-b last:border-0">
<td className="py-1.5">{c.label}</td>
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
@@ -625,7 +627,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => void;
}) {
const isClosed = data.event.cashupStatus === "closed";
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
const draftLines = data.event.cashupDraft?.lines;
const initialLines: Record<CashupMethod, LineInput> = useMemo(() => {
const base: Record<CashupMethod, LineInput> = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } };
@@ -659,8 +661,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
try {
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
onChanged();
} catch (e: any) {
setError(e?.message || "Failed to save draft");
} catch (e) {
setError(errorMessage(e) || "Failed to save draft");
} finally {
setBusy(false);
}
@@ -673,8 +675,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} catch (e) {
setError(errorMessage(e) || "Failed to close event");
} finally {
setBusy(false);
}
@@ -687,8 +689,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} catch (e) {
setError(errorMessage(e) || "Failed to close event");
} finally {
setBusy(false);
}
@@ -700,8 +702,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
try {
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
onChanged();
} catch (e: any) {
setError(e?.message || "Failed to reopen event");
} catch (e) {
setError(errorMessage(e) || "Failed to reopen event");
} finally {
setBusy(false);
}
@@ -761,7 +763,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
</tbody>
</table>
{!isClosed && (
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below — there's no separate event-wide entry.</div>
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below — there&apos;s no separate event-wide entry.</div>
)}
</div>
@@ -774,7 +776,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
return denoms.length > 0 ? (
<table className="text-sm">
<tbody>
{denoms.map((d: any) => (
{denoms.map((d: PersonDenom & { id?: string }) => (
<tr key={d.id || d.value}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
))}
</tbody>
@@ -6,12 +6,14 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Wallet } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
export default function CashupLandingPage() {
const { token } = useAuth();
const router = useRouter();
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [search, setSearch] = useState("");
@@ -25,15 +27,15 @@ export default function CashupLandingPage() {
setLoading(true);
setError(null);
try {
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e: any) {
setError(e?.message || "Failed to load events");
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
} finally {
setLoading(false);
}
})();
}, [token]);
}, [token, setError]);
const filtered = useMemo(() => {
const now = new Date();
+9 -8
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
@@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats } from "@/types";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -67,35 +68,35 @@ export default function AdminDashboardPage() {
// /api/stats/admin covers today/week/month payment totals (unchanged from before);
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint. Both are
// computed server-side — no full payments/events list ever ships to the client.
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
const [overview, setOverview] = useStableState<Overview | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
const loadStats = async () => {
const loadStats = useCallback(async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const [dash, overviewData] = await Promise.all([
apiFetch<any>("/api/stats/admin", { authToken: token }),
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
]);
setPaymentStats(dash.paymentStats);
setOverview(overviewData);
} catch (e) {
} catch {
// ignore errors for dashboard summaries
} finally {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
};
}, [token, setPaymentStats, setOverview]);
useEffect(() => {
if (!token) return;
loadStats();
}, [token]);
}, [token, loadStats]);
// Poll every 15s while the tab is visible; pause in the background and refetch
// immediately on return instead of leaving stale numbers up.
@@ -105,7 +106,7 @@ export default function AdminDashboardPage() {
}, 15000, !!token);
const paymentsTabValue = paymentStats
? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth
? (paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth) ?? 0
: 0;
return (
@@ -1,11 +1,13 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, FormResponse, Registration } from "@/types";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -17,6 +19,16 @@ function fuzzyMatch(query: string, target: string): boolean {
return tokens.every(tok => t.includes(tok));
}
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
const totalDueFor = (r: Registration) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
return sum + unit * (opt.quantity || 0);
}, 0);
const totalPaidFor = (r: Registration) => (r.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
export default function AdminRegistrationsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
@@ -28,8 +40,8 @@ export default function AdminRegistrationsPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [registrations, setRegistrations] = useState<any[]>([]);
const [events, setEvents] = useState<any[]>([]);
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
@@ -43,48 +55,48 @@ export default function AdminRegistrationsPage() {
// Expanded rows + form responses cache
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [formResponses, setFormResponses] = useState<Record<string, any[]>>({});
const [formResponses, setFormResponses] = useState<Record<string, FormResponse[]>>({});
const [loadingForms, setLoadingForms] = useState<Set<string>>(new Set());
const loadRegistrations = async () => {
const loadRegistrations = useCallback(async () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
const list = Array.isArray(regs) ? regs.sort((a, b) => {
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
if (eventCompare !== 0) return eventCompare;
return (a.user?.name || a.userId).localeCompare(b.user?.name || b.userId);
}) : [];
setRegistrations(list);
} catch (e: any) {
setError(e?.message || "Failed to load registrations");
} catch (e) {
setError(errorMessage(e) || "Failed to load registrations");
} finally {
setLoadingRegs(false);
}
};
}, [token, setError]);
const loadEvents = async () => {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const params = new URLSearchParams();
if (includePastEvents) params.set("includePast", "true");
if (includeInactiveEvents) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
setEvents(Array.isArray(evs) ? evs.sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch {}
};
}, [token, includePastEvents, includeInactiveEvents]);
useEffect(() => {
loadRegistrations();
}, [token]);
}, [loadRegistrations]);
useEffect(() => {
loadEvents();
}, [token, includePastEvents, includeInactiveEvents]);
}, [loadEvents]);
const toggleExpand = async (reg: any) => {
const toggleExpand = async (reg: Registration) => {
const id = reg.id;
const next = new Set(expanded);
if (next.has(id)) {
@@ -98,8 +110,8 @@ export default function AdminRegistrationsPage() {
if (!formResponses[id] && !loadingForms.has(id)) {
setLoadingForms(prev => new Set(prev).add(id));
try {
const res = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
const items = Array.isArray(res?.items) ? res.items : (Array.isArray(res) ? res : []);
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
setFormResponses(prev => ({ ...prev, [id]: items }));
} catch {
setFormResponses(prev => ({ ...prev, [id]: [] }));
@@ -109,7 +121,7 @@ export default function AdminRegistrationsPage() {
}
};
const cancelRegistration = async (reg: any) => {
const cancelRegistration = async (reg: Registration) => {
if (!token) return;
setError(null); setInfo(null);
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
@@ -117,27 +129,27 @@ export default function AdminRegistrationsPage() {
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
setInfo("Registration cancelled");
await loadRegistrations();
} catch (e: any) {
setError(e?.message || "Failed to cancel registration");
} catch (e) {
setError(errorMessage(e) || "Failed to cancel registration");
}
};
const updateStatus = async (reg: any, status: string) => {
const updateStatus = async (reg: Registration, status: string) => {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
setInfo("Status updated");
await loadRegistrations();
} catch (e: any) {
setError(e?.message || "Failed to update status");
} catch (e) {
setError(errorMessage(e) || "Failed to update status");
}
};
const eventIds = useMemo(() => new Set(events.map((ev: any) => ev.id)), [events]);
const eventIds = useMemo(() => new Set(events.map((ev) => ev.id)), [events]);
const filtered = useMemo(() => {
return registrations.filter((r: any) => {
return registrations.filter((r) => {
if (!eventIds.has(r.eventId)) return false;
if (eventFilter && r.eventId !== eventFilter) return false;
if (statusFilter && r.status !== statusFilter) return false;
@@ -145,10 +157,10 @@ export default function AdminRegistrationsPage() {
const haystack = [
r.id, r.user?.name, r.user?.email, r.user?.phoneNumber,
r.userId, r.event?.title, r.eventId, r.status,
].map((x: any) => String(x || "")).join(" ");
].map((x) => String(x || "")).join(" ");
return fuzzyMatch(query.trim(), haystack);
});
}, [registrations, query, eventFilter, statusFilter]);
}, [registrations, query, eventFilter, statusFilter, eventIds]);
const statusColor = (s: string) => {
if (s === "paid") return "text-green-700 bg-green-50";
@@ -158,15 +170,6 @@ export default function AdminRegistrationsPage() {
return "text-gray-700 bg-gray-50";
};
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
const totalDueFor = (r: any) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
return sum + unit * (opt.quantity || 0);
}, 0);
const totalPaidFor = (r: any) => (r.payments || []).reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
// Aggregate stats across the currently filtered registrations — counts by status, plus
// revenue/outstanding totals (cancelled registrations are excluded from the money totals
@@ -175,7 +178,7 @@ export default function AdminRegistrationsPage() {
const counts: Record<string, number> = {};
let totalRevenue = 0;
let totalOutstanding = 0;
filtered.forEach((r: any) => {
filtered.forEach((r) => {
counts[r.status] = (counts[r.status] || 0) + 1;
if (r.status === "cancelled") return;
const due = totalDueFor(r);
@@ -275,7 +278,7 @@ export default function AdminRegistrationsPage() {
<div className="border rounded-xl bg-white shadow-sm">
<ul className="divide-y text-sm">
{filtered.map((r: any) => {
{filtered.map((r) => {
const totalDue = totalDueFor(r);
const totalPaid = totalPaidFor(r);
const outstanding = Math.max(totalDue - totalPaid, 0);
@@ -336,7 +339,7 @@ export default function AdminRegistrationsPage() {
<div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
<div className="grid sm:grid-cols-2 gap-2">
{r.registrationOptions.map((opt: any) => {
{(r.registrationOptions || []).map((opt) => {
// A line can span multiple price tranches (e.g. tickets bought
// before and after an early-bird tier expired) — show one row per
// tranche so its own price/tier status is accurate, not blended.
@@ -356,7 +359,7 @@ export default function AdminRegistrationsPage() {
{opt.eventOption?.name || opt.eventOptionId}
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
</div>
{tranches.map((t: any, idx: number) => {
{tranches.map((t, idx) => {
const unit = Number(t.priceSnapshot || 0);
return (
<div key={t.id || idx} className="text-gray-500">
@@ -382,7 +385,7 @@ export default function AdminRegistrationsPage() {
<div className="text-xs text-gray-400">No payments recorded.</div>
) : (
<div className="grid sm:grid-cols-2 gap-2">
{r.payments.map((p: any) => (
{(r.payments || []).map((p) => (
<div key={p.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
@@ -409,11 +412,11 @@ export default function AdminRegistrationsPage() {
<div className="text-xs text-gray-400">No form responses submitted.</div>
) : (
<div className="space-y-2">
{responses.map((resp: any, idx: number) => (
{responses.map((resp, idx) => (
<div key={resp.id || idx} className="bg-white border rounded p-2">
<div className="text-xs font-medium text-gray-600 mb-1">Response #{idx + 1}</div>
<div className="grid sm:grid-cols-2 gap-1.5">
{(resp.answers || []).map((a: any) => (
{(resp.answers || []).map((a) => (
<div key={a.id} className="bg-gray-50 border rounded p-1.5 text-xs">
<div className="text-[10px] text-gray-500">{a.field?.label || a.fieldId}</div>
<div className="font-medium">{a.value}</div>
@@ -3,17 +3,19 @@
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, DatabaseBackup, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { YocoTab } from "@/components/admin/YocoTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "backups";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -22,6 +24,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "email", label: "Email", icon: Mail },
{ id: "legal", label: "Legal", icon: Scale },
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "yoco", label: "Yoco", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
];
@@ -176,9 +179,9 @@ function SiteSettingsPageInner() {
setConsentRetentionDays(s.consent_medical_retention_days || "30");
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
})
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.catch((e: unknown) => setResult({ ok: false, message: errorMessage(e) || "Failed to load settings" }))
.finally(() => setLoadingInitial(false));
}, [token]);
}, [token, setResult]);
// Clear result when switching tabs
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
@@ -192,8 +195,8 @@ function SiteSettingsPageInner() {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} catch (e) {
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
} finally {
setSaving(false);
}
@@ -251,8 +254,8 @@ function SiteSettingsPageInner() {
setSuggestedColors([]);
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} catch (e) {
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
} finally {
setSaving(false);
}
@@ -276,8 +279,8 @@ function SiteSettingsPageInner() {
setSuggestedColors([]);
setResult({ ok: true, message: "Reset to defaults." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to reset" });
} catch (e) {
setResult({ ok: false, message: errorMessage(e) || "Failed to reset" });
} finally {
setSaving(false);
}
@@ -310,8 +313,8 @@ function SiteSettingsPageInner() {
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} catch (e) {
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
} finally {
setSaving(false);
}
@@ -345,8 +348,9 @@ function SiteSettingsPageInner() {
},
});
setSmtpTestResult({ ok: true, message: "Connection successful — check your inbox for a test email." });
} catch (e: any) {
setSmtpTestResult({ ok: false, message: e?.message || "SMTP test failed", raw: e?.data?.raw });
} catch (e) {
const raw = e instanceof ApiError && typeof e.data?.raw === "string" ? e.data.raw : undefined;
setSmtpTestResult({ ok: false, message: errorMessage(e) || "SMTP test failed", raw });
} finally {
setSmtpTesting(false);
}
@@ -688,6 +692,9 @@ function SiteSettingsPageInner() {
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
{/* ── Yoco ──────────────────────────────────────────────────────────── */}
{activeTab === "yoco" && <YocoTab active={activeTab === "yoco"} />}
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
</div>
@@ -870,7 +877,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
setStatus(res.status ?? null);
setStatusMsg(res.message ?? null);
} catch (e: any) {
} catch {
await fetchConfig();
setStatus("FAILED");
setStatusMsg(null);
@@ -914,12 +921,12 @@ function WhatsAppTab({ active }: { active: boolean }) {
setBusy(action);
setActionMsg(null);
try {
const res = await apiFetch<any>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
const res = await apiFetch<{ message?: string }>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
await fetchStatus();
await fetchConfig();
} catch (e: any) {
let msg = e?.message || `${action} failed.`;
} catch (e) {
let msg = errorMessage(e) || `${action} failed.`;
try { msg = JSON.parse(msg)?.message || msg; } catch {}
await fetchConfig();
if (!msg.includes("SESSION_NOT_FOUND")) {
@@ -942,8 +949,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: inputToken.trim(), instanceId: "" } });
setInputToken("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
} catch (e) {
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to save token." });
} finally {
setSavingToken(false);
}
@@ -960,8 +967,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
} catch (e) {
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to save Instance ID." });
} finally {
setSavingInstance(false);
}
@@ -971,11 +978,11 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingInstance(true);
setActionMsg(null);
try {
const res = await apiFetch<any>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
const res = await apiFetch<{ message?: string }>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
} catch (e) {
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to create instance." });
} finally {
setSavingInstance(false);
}
@@ -1219,8 +1226,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
setInputToken("");
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
} catch (e) {
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to update." });
} finally {
setSavingToken(false);
}
+27 -34
View File
@@ -5,9 +5,11 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { beginImpersonation } from "@/lib/impersonation";
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
import { Users as UsersIcon } from "lucide-react";
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
import { errorMessage } from "@/lib/utils";
import type { Paginated } from "@/types";
interface UserItem {
id: string;
@@ -27,15 +29,6 @@ type Role = typeof roleOptions[number];
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
function fuzzyMatch(query: string, target: string): boolean {
const q = query.toLowerCase();
const t = target.toLowerCase();
if (t.includes(q)) return true;
// token-based: all tokens must appear somewhere
const tokens = q.split(/\s+/).filter(Boolean);
return tokens.every(tok => t.includes(tok));
}
export default function AdminUsersPage() {
const { user, loading, token } = useAuth();
@@ -98,26 +91,26 @@ export default function AdminUsersPage() {
setFetching(true);
try {
const url = buildQuery(p, ps);
const res = await apiFetch<any>(url, { authToken: token });
const res = await apiFetch<Paginated<UserItem>>(url, { authToken: token });
setUsers(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 users");
} catch (e) {
setError(errorMessage(e) || "Failed to load users");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
}, [token, buildQuery, pageSize, setError]);
// Initial load + reload on filter change
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
// Initial load, and back to page 1 whenever a filter or the page size changes
// (loadUsers is recreated when any of them do).
useEffect(() => { if (token) loadUsers(1); }, [token, loadUsers]);
const goToPage = (p: number) => loadUsers(p);
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
loadUsers(1, newSize);
};
const resetCreateForm = () => {
@@ -129,7 +122,7 @@ export default function AdminUsersPage() {
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
try {
setCreating(true);
const created = await apiFetch<any>("/api/users", {
const created = await apiFetch<{ id?: string }>("/api/users", {
method: "POST",
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
});
@@ -141,8 +134,8 @@ export default function AdminUsersPage() {
resetCreateForm();
setCreateOpen(false);
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to create user");
} catch (e) {
setError(errorMessage(e) || "Failed to create user");
} finally {
setCreating(false);
}
@@ -155,7 +148,7 @@ export default function AdminUsersPage() {
if (!editingId) return;
try {
setSaving(true);
const payload: any = {
const payload: Partial<UserItem> & { password?: string } = {
name: editData.name,
email: editData.email,
role: editData.role,
@@ -172,8 +165,8 @@ export default function AdminUsersPage() {
setEditingId(null);
setEditData({});
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to save user");
} catch (e) {
setError(errorMessage(e) || "Failed to save user");
} finally {
setSaving(false);
}
@@ -183,8 +176,8 @@ export default function AdminUsersPage() {
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
} catch (e: any) {
setError(e?.message || "Failed to revoke sessions");
} catch (e) {
setError(errorMessage(e) || "Failed to revoke sessions");
}
};
@@ -197,10 +190,10 @@ export default function AdminUsersPage() {
setImpBusy(true);
setImpError(null);
try {
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
beginImpersonation(res);
} catch (e: any) {
setImpError(e?.message || "Couldn't start impersonation");
} catch (e) {
setImpError(errorMessage(e) || "Couldn't start impersonation");
setImpBusy(false);
}
};
@@ -215,8 +208,8 @@ export default function AdminUsersPage() {
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to reset two-factor authentication");
} catch (e) {
setError(errorMessage(e) || "Failed to reset two-factor authentication");
}
};
@@ -225,8 +218,8 @@ export default function AdminUsersPage() {
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to deactivate user");
} catch (e) {
setError(errorMessage(e) || "Failed to deactivate user");
}
};
@@ -235,8 +228,8 @@ export default function AdminUsersPage() {
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to delete user data");
} catch (e) {
setError(errorMessage(e) || "Failed to delete user data");
}
};
@@ -8,6 +8,8 @@ import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage";
import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, Ticket as TicketRecord } from "@/types";
function EventTicketsContent() {
const { token, user } = useAuth();
@@ -17,9 +19,9 @@ function EventTicketsContent() {
const paramEventId = params.get("eventId") || "";
const paramUserId = params.get("userId") || "";
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState<string>(paramEventId);
const [tickets, setTickets] = useState<any[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [error, setError] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -36,7 +38,7 @@ function EventTicketsContent() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter((ev) => {
const t = new Date(ev.endDate).getTime();
@@ -47,25 +49,27 @@ function EventTicketsContent() {
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
setEvents(active);
if (!paramEventId && active.length > 0) {
setEventId(active[0].id);
router.replace(
`?eventId=${encodeURIComponent(active[0].id)}${
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
}`
);
}
} catch (e: any) {
setError(e?.message || "Failed to load events");
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
}
})();
}, [token]);
}, [token, setError]);
// Sync params → state
// No event in the URL: default to the first upcoming one once the list arrives.
useEffect(() => {
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
if (paramUserId && paramUserId !== selectedUserId)
setSelectedUserId(paramUserId);
if (paramEventId || events.length === 0) return;
setEventId(events[0].id);
router.replace(
`?eventId=${encodeURIComponent(events[0].id)}${
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
}`
);
}, [events, paramEventId, router, selectedUserId]);
// Sync params → state (setting the value it already has is a no-op)
useEffect(() => {
if (paramEventId) setEventId(paramEventId);
if (paramUserId) setSelectedUserId(paramUserId);
}, [paramEventId, paramUserId]);
// Load tickets when event changes
@@ -75,18 +79,18 @@ function EventTicketsContent() {
setLoading(true);
setError(null);
try {
const list = await apiFetch<any[]>(
const list = await apiFetch<TicketRecord[]>(
`/api/tickets/event/${encodeURIComponent(eventId)}`,
{ authToken: token }
);
setTickets(list);
} catch (e: any) {
setError(e?.message || "Failed to load tickets");
} catch (e) {
setError(errorMessage(e) || "Failed to load tickets");
} finally {
setLoading(false);
}
})();
}, [token, eventId]);
}, [token, eventId, setError]);
// Build variants list for filter (only variants present in current event's tickets)
const variantsList = useMemo(() => {
@@ -138,7 +142,7 @@ function EventTicketsContent() {
}, [tickets, eventId, selectedUserId, selectedVariantId]);
// Printing helpers
const buildTicketHtmlCard = (t: any) => {
const buildTicketHtmlCard = (t: TicketRecord) => {
const eventTitle = t.event?.title || t.eventId || "Event";
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
@@ -216,7 +220,7 @@ function EventTicketsContent() {
w.focus();
};
const printTickets = (ticketList: any[]) => {
const printTickets = (ticketList: TicketRecord[]) => {
if (!ticketList || ticketList.length === 0) return;
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
const PAGE_SIZE = 8;
+12 -11
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
@@ -9,6 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
@@ -35,31 +36,31 @@ export default function StaffDashboardPage() {
// (/api/stats/staff) instead of two separate calls. useStableState skips re-renders when
// a poll returns identical data, and hasLoadedOnce below means "Refreshing…" only shows
// on the very first load — together these stop the stats panel from flickering.
const [stats, setStats] = useStableState<any | null>(null);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [stats, setStats] = useStableState<ScanStats | null>(null);
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const loadStats = async () => {
const loadStats = useCallback(async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/staff", { authToken: token });
const data = await apiFetch<DashboardStats>("/api/stats/staff", { authToken: token });
setStats(data.scanStats);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
} catch (e) {
} catch {
// ignore
} finally {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
};
}, [token, setStats, setRecentScans]);
useEffect(() => {
if (!token) return;
loadStats();
}, [token]);
}, [token, loadStats]);
// Poll every 10s while the tab is visible; pause in the background and refetch
// immediately on return instead of leaving stale numbers up.
@@ -104,7 +105,7 @@ export default function StaffDashboardPage() {
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
</div>
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
{recentScans.map((u: any) => (
{recentScans.map((u) => (
<li key={u.id} className="border rounded p-2">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
@@ -122,9 +123,9 @@ export default function StaffDashboardPage() {
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
{stats?.byStaff?.length > 0 ? (
{stats && stats.byStaff?.length > 0 ? (
<ul className="text-sm text-gray-700 space-y-1">
{stats.byStaff.map((s: any) => (
{stats.byStaff.map((s) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
@@ -10,19 +10,39 @@ import { QrCode } from "lucide-react";
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
import type { SnapshotTicket } from "@/lib/offlineScan";
import { errorMessage } from "@/lib/utils";
import type { Event, RecentScan, ScanStats, Section } from "@/types";
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
// scans build the same shape from the downloaded ticket list.
type ScanTicket = {
id: string;
eventId?: string;
eventOptionId?: string;
quantity?: number;
consentStatus?: string;
event?: { id?: string; title?: string } | null;
registrationOption?: {
eventOption?: { id?: string; name?: string } | null;
variant?: { name?: string } | null;
registration?: { user?: { name?: string } | null } | null;
} | null;
attendee?: { name: string } | null;
user?: { name?: string; email?: string } | null;
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
};
export default function TicketScanningPage() {
const router = useRouter();
const scannerRef = useRef<QRScannerHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useDismissingState<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?: ScanTicket } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth();
const offline = useOfflineScanning(token);
const [recentScans, setRecentScans] = useState<any[]>([]);
const [stats, setStats] = useState<any | null>(null);
const [recentScans, setRecentScans] = useState<RecentScan[]>([]);
const [stats, setStats] = useState<ScanStats | null>(null);
const [loadingStats, setLoadingStats] = useState<boolean>(false);
const [successModal, setSuccessModal] = useState<{
optionName: string;
@@ -36,7 +56,7 @@ export default function TicketScanningPage() {
// Confirm step state
const [confirmModal, setConfirmModal] = useState<{
qrCode: string;
ticket: any;
ticket: ScanTicket;
remaining: number;
offlineTicket?: SnapshotTicket; // scanned against the downloaded list (offline mode)
} | null>(null);
@@ -47,11 +67,11 @@ export default function TicketScanningPage() {
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
// Event selection state
const [allEvents, setAllEvents] = useState<any[]>([]);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [includePastEvents, setIncludePastEvents] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<string>("all");
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
const [sections, setSections] = useState<any[]>([]);
const [sections, setSections] = useState<Section[]>([]);
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
const [loadingSections, setLoadingSections] = useState(false);
@@ -61,10 +81,10 @@ export default function TicketScanningPage() {
try {
if (!token) return;
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
setAllEvents(sorted);
} catch (e: any) {
} catch {
// non-fatal
} finally {
setLoadingEvents(false);
@@ -90,7 +110,7 @@ export default function TicketScanningPage() {
}
try {
setLoadingSections(true);
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
const list = await apiFetch<Section[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
setSections(Array.isArray(list) ? list : []);
setSelectedSectionId("all");
} catch {
@@ -120,7 +140,6 @@ export default function TicketScanningPage() {
}, [selectedEventId, selectedSectionId]);
async function handleResult(text: string) {
setLastResult(text);
setError(null);
setScanInfo(null);
try {
@@ -157,11 +176,11 @@ export default function TicketScanningPage() {
// Always preview first to validate the ticket before showing confirm step
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: any;
let preview: ScanTicket;
try {
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
} catch (pe: any) {
setErrorModal(pe?.message || 'Failed to validate ticket');
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
} catch (pe) {
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
return;
}
@@ -182,7 +201,7 @@ export default function TicketScanningPage() {
return;
}
const section = sections.find(s => s.id === selectedSectionId);
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
const allowedOptionIds = (section?.allowedOptions || []).map((o) => o.eventOptionId);
if (!allowedOptionIds.includes(ticketOptionId)) {
setErrorModal("This ticket is not valid for this section");
return;
@@ -192,7 +211,7 @@ export default function TicketScanningPage() {
// Compute remaining qty
const ticketQty = preview?.quantity || 1;
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = ticketQty - totalRedeemed;
if (remaining <= 0) {
@@ -204,8 +223,8 @@ export default function TicketScanningPage() {
setConfirmQty(remaining);
setConfirmQtyRaw(String(remaining));
setConfirmModal({ qrCode: text, ticket: preview, remaining });
} catch (e: any) {
setErrorModal(e?.message || "Failed to scan ticket");
} catch (e) {
setErrorModal(errorMessage(e) || "Failed to scan ticket");
}
}
@@ -239,9 +258,9 @@ export default function TicketScanningPage() {
if (offlineTicket) {
try {
await offline.scanLocally(offlineTicket, qty);
} catch (e: any) {
} catch (e) {
setSuccessModal(null);
setErrorModal(e?.message || "Couldn't save the scan on this device");
setErrorModal(errorMessage(e) || "Couldn't save the scan on this device");
}
return;
}
@@ -251,7 +270,7 @@ export default function TicketScanningPage() {
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
}`;
await apiFetch<any>(url, {
await apiFetch(url, {
method: "POST",
authToken: token,
body: {
@@ -262,10 +281,10 @@ export default function TicketScanningPage() {
},
});
refreshStatsAndRecent();
} catch (e: any) {
} catch (e) {
setSuccessModal(null);
setScanInfo(null);
const raw = e?.message || "Failed to scan ticket";
const raw = errorMessage(e) || "Failed to scan ticket";
try {
const parsed = JSON.parse(raw);
const msg: string | undefined = parsed?.message;
@@ -280,28 +299,27 @@ export default function TicketScanningPage() {
}
}
const loadRecent = async () => {
const loadRecent = useCallback(async () => {
if (!token) return;
try {
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
const list = await apiFetch<RecentScan[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
setRecentScans(list);
} catch {}
};
const loadStats = async () => {
}, [token]);
const loadStats = useCallback(async () => {
if (!token) return;
try {
setLoadingStats(true);
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
const s = await apiFetch<ScanStats>(`/api/tickets/scans/stats`, { authToken: token });
setStats(s);
} catch {} finally {
setLoadingStats(false);
}
};
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
}, [token]);
const refreshStatsAndRecent = useCallback(() => { loadRecent(); loadStats(); }, [loadRecent, loadStats]);
useEffect(() => {
refreshStatsAndRecent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
}, [refreshStatsAndRecent]);
return (
<div className="max-w-6xl mx-auto w-full p-4">
@@ -403,11 +421,11 @@ export default function TicketScanningPage() {
</div>
)}
{stats?.byStaff?.length > 0 && (
{stats && stats.byStaff?.length > 0 && (
<div className="mb-4">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{stats.byStaff.map((s: any) => (
{stats.byStaff.map((s) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
@@ -419,7 +437,7 @@ export default function TicketScanningPage() {
<h3 className="text-lg font-semibold mb-2">Last 10 scans</h3>
<ul className="text-sm space-y-2 max-h-80 overflow-auto pr-2">
{recentScans.map((u: any) => (
{recentScans.map((u) => (
<li key={u.id} className="border rounded p-2 bg-white">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || 'Event'}</div>
@@ -533,8 +551,8 @@ export default function TicketScanningPage() {
<div className="mt-2">
<div className="font-medium">Usages:</div>
<ul className="list-disc ml-5 mt-1 max-h-32 overflow-auto">
{alreadyUsedModal.ticket.usages.map((u: any) => (
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
{alreadyUsedModal.ticket.usages.map((u) => (
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{(u.quantityRedeemed ?? 1) > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
))}
</ul>
</div>
@@ -8,10 +8,23 @@ import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
type Person = { id: string; name: string; gender: string | null; dateOfBirth: string | null; registrationId: string; bookedBy: string };
// GET /api/allocations/sets/:id/lists — medical/emergency only when ?medical=true and consent was given.
type ListMember = {
groupId: string; id: string; name: string; gender: string | null; dateOfBirth: string | null;
phone: string | null; parentName: string | null; parentPhone: string | null; bookedBy: string; bookedByPhone: string | null;
medical?: Record<string, unknown> | null; emergency?: Record<string, unknown> | null;
};
type SetLists = {
set: { id: string; name: string };
event: { id: string; title: string; startDate: string };
groups: { id: string; name: string; capacity: number | null; gender: string | null; leader: string | null; members: ListMember[] }[];
};
type Board = { event: { id: string; title: string; startDate: string; collectGender: boolean; requiresParentalConsent: boolean }; sets: AllocSet[]; attendees: Person[]; leaders: { id: string; name: string }[]; unnamedPlaces: number };
const UNASSIGNED = "unassigned";
@@ -67,7 +80,7 @@ export default function AllocationsPage() {
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState("");
const [board, setBoard] = useState<Board | null>(null);
const [setId, setSetId] = useState("");
@@ -80,7 +93,7 @@ export default function AllocationsPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
.then(evs => {
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
@@ -96,8 +109,8 @@ export default function AllocationsPage() {
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
setBoard(b);
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
} catch (e: any) {
setError(e?.message || "Failed to load allocations");
} catch (e) {
setError(errorMessage(e) || "Failed to load allocations");
}
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
@@ -126,8 +139,8 @@ export default function AllocationsPage() {
}));
try {
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
} catch (err: any) {
setError(err?.message || "Couldn't move them");
} catch (err) {
setError(errorMessage(err) || "Couldn't move them");
load();
}
};
@@ -138,8 +151,8 @@ export default function AllocationsPage() {
const msg = await fn();
if (msg) setInfo(msg);
await load();
} catch (e: any) {
setError(e?.message || "Something went wrong");
} catch (e) {
setError(errorMessage(e) || "Something went wrong");
} finally {
setBusy(false);
}
@@ -160,19 +173,19 @@ export default function AllocationsPage() {
const printLists = async (master: boolean, medical: boolean) => {
if (!current || !board) return;
try {
const data = await apiFetch<any>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
const data = await apiFetch<SetLists>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
const w = window.open("", "_blank");
if (!w) return;
const row = (m: any) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
const row = (m: ListMember) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
+ `<td>${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}</td><td>${esc(m.parentName || m.bookedBy || "")}</td>`
+ (medical ? `<td>${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}</td><td>${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}</td>` : "") + `</tr>`;
const head = `<tr><th>Name</th><th>Gender</th><th>Age</th><th>Phone</th><th>Parent / booked by</th>${medical ? "<th>Medical</th><th>Emergency contact</th>" : ""}</tr>`;
let body = "";
if (master) {
const all = data.groups.flatMap((g: any) => g.members.map((m: any) => ({ ...m, group: g.name }))).sort((a: any, b: any) => a.name.localeCompare(b.name));
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m: any) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
const all = data.groups.flatMap((g) => g.members.map((m) => ({ ...m, group: g.name }))).sort((a, b) => a.name.localeCompare(b.name));
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
} else {
body = data.groups.map((g: any) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
body = data.groups.map((g) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
+ `<p>${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people</p>`
+ `<table>${head}${g.members.map(row).join("")}</table></section>`).join("");
}
@@ -182,8 +195,8 @@ export default function AllocationsPage() {
section{page-break-after:always} section:last-child{page-break-after:auto}
</style></head><body>${body}<script>window.onload=function(){window.print()}</script></body></html>`);
w.document.close();
} catch (e: any) {
setError(e?.message || "Couldn't load the lists");
} catch (e) {
setError(errorMessage(e) || "Couldn't load the lists");
}
};
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
@@ -10,6 +10,16 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import type { Event, EventOption, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
// GET /api/tickets/event/:id — a bare list (older builds wrapped it in `tickets` or `data`).
type EventTicketsResponse = TicketRecord[] | { tickets?: TicketRecord[]; data?: TicketRecord[] };
const ticketsFrom = (res: EventTicketsResponse | null | undefined): TicketRecord[] =>
Array.isArray(res) ? res : (res?.tickets || res?.data || []);
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
@@ -31,7 +41,7 @@ const MODE_ICONS: Record<Mode, LucideIcon> = {
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
function fuzzyFilterRegs(allRegs: Registration[], search: string): Registration[] {
if (!search) return allRegs;
if (search.length === 1) {
const q = search.toLowerCase();
@@ -50,37 +60,24 @@ function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
// ─── Pricing helpers ─────────────────────────────────────────────────────────
function effectiveOptionUnit(opt: any): number {
const base = opt.price || 0;
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId);
if (!tiers.length) return base;
const now = new Date();
const hit = tiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => now < t.deadline)
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
return hit.length ? hit[0].price : base;
// Total due on a registration: the backend's tranche-aware total when present, else
// the sum of its lines at their snapshot (or current list) prices.
function registrationTotal(r: Registration): number {
return r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? 0);
return sum + price * (opt.quantity || 0);
}, 0);
}
function effectiveVariantUnit(opt: any, variant: any): number {
const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0;
const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : [];
const vtiers = allTiers.filter((t: any) => t.variantId === variant.id);
const tiers = vtiers.length ? vtiers : allTiers.filter((t: any) => !t.variantId);
if (!tiers.length) return base;
const now = new Date();
const hit = tiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => now < t.deadline)
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
return hit.length ? hit[0].price : base;
}
const totalPaidOn = (r: Registration) => (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
function initQuantities(eventOptions: any[]): Record<string, number> {
function initQuantities(eventOptions: EventOption[]): Record<string, number> {
const map: Record<string, number> = {};
(eventOptions || []).forEach((o: any) => {
(eventOptions || []).forEach((o) => {
if ((o.variants || []).length > 0) {
(o.variants as any[]).forEach((v: any) => { map[`${o.id}::${v.id}`] = 0; });
(o.variants || []).forEach((v) => { map[`${o.id}::${v.id}`] = 0; });
} else {
map[o.id] = 0;
}
@@ -88,7 +85,7 @@ function initQuantities(eventOptions: any[]): Record<string, number> {
return map;
}
function ticketLabel(t: any): string {
function ticketLabel(t: TicketRecord): string {
const opt = t.registrationOption?.eventOption?.name || "Ticket";
const variant = t.registrationOption?.variant?.name;
return variant ? `${opt} — ${variant}` : opt;
@@ -97,12 +94,12 @@ function ticketLabel(t: any): string {
export default function AtTheDoorPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState("");
const [eventOptions, setEventOptions] = useState<any[]>([]);
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<any | null>(null);
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
@@ -110,7 +107,7 @@ export default function AtTheDoorPage() {
const [doorNaming, setDoorNaming] = useState(false);
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
const [pendingEditReg, setPendingEditReg] = useState<Registration | null>(null);
const [confirming, setConfirming] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
// Already-issued ticket quantity per option/variant, keyed like `quantities` — a
@@ -119,7 +116,7 @@ export default function AtTheDoorPage() {
const [minQuantities, setMinQuantities] = useState<Record<string, number>>({});
const [showDonationModal, setShowDonationModal] = useState(false);
const [donationUser, setDonationUser] = useState<any | null>(null);
const [donationUser, setDonationUser] = useState<UserSummary | null>(null);
const [showNewAttendeeModal, setShowNewAttendeeModal] = useState(false);
const [newAttendeeSeed, setNewAttendeeSeed] = useState("");
@@ -128,7 +125,7 @@ export default function AtTheDoorPage() {
if (!token) return;
(async () => {
try {
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter(ev => {
@@ -158,7 +155,7 @@ export default function AtTheDoorPage() {
(async () => {
try {
const ev = await apiFetch(`/api/events/${eventId}`, {
const ev = await apiFetch<Event>(`/api/events/${eventId}`, {
authToken: token
});
@@ -180,19 +177,19 @@ export default function AtTheDoorPage() {
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading]);
}, [user, loading, router]);
const [mode, setMode] = useState<Mode>("registration");
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
const [activeRegistration, setActiveRegistration] = useState<Registration | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const handleRegistrationCreated = (registration: any) => {
const handleRegistrationCreated = (registration: Registration) => {
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
};
const handleRegistrationSelected = (registration: any) => {
const handleRegistrationSelected = (registration: Registration) => {
setActiveRegistration(registration);
};
@@ -227,8 +224,8 @@ export default function AtTheDoorPage() {
for (const [key, minQty] of Object.entries(minQuantities)) {
if (minQty > 0 && (quantities[key] || 0) < minQty) {
const [eventOptionId, variantId] = key.split("::");
const opt = eventOptions.find((o: any) => o.id === eventOptionId);
const variant = variantId ? (opt?.variants || []).find((v: any) => v.id === variantId) : null;
const opt = eventOptions.find((o) => o.id === eventOptionId);
const variant = variantId ? (opt?.variants || []).find((v) => v.id === variantId) : null;
const label = variant ? `${opt?.name || "item"} (${variant.name})` : (opt?.name || "item");
setError(`Cannot reduce "${label}" below the ${minQty} already issued`);
return;
@@ -244,7 +241,7 @@ export default function AtTheDoorPage() {
try {
if (pendingEditReg) {
// Edit existing registration — replace options via PUT
let res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
let res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/options`, {
method: "PUT",
authToken: token,
body: { options: opts }
@@ -253,14 +250,14 @@ export default function AtTheDoorPage() {
if (doorCoupon) {
// Applied after the items are saved, so it's worked out on the final items.
try {
res = await apiFetch(`/api/registrations/${pendingEditReg.id}/coupon`, {
res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/coupon`, {
method: "POST",
authToken: token,
body: { code: doorCoupon.code },
});
message = `Registration updated — code ${doorCoupon.code} applied`;
} catch (ce: any) {
message = `Registration updated, but the code wasn't applied: ${ce?.message || "unknown error"}`;
} catch (ce) {
message = `Registration updated, but the code wasn't applied: ${errorMessage(ce) || "unknown error"}`;
}
}
setShowOptionsModal(false);
@@ -269,7 +266,7 @@ export default function AtTheDoorPage() {
handleRegistrationSelected(res);
} else if (pendingUser) {
// New attendee
const res = await apiFetch("/api/registrations/manual", {
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
@@ -291,8 +288,8 @@ export default function AtTheDoorPage() {
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
handleRegistrationCreated(res);
}
} catch (e: any) {
setError(e?.message || "Failed");
} catch (e) {
setError(errorMessage(e) || "Failed");
} finally {
setConfirming(false);
}
@@ -311,7 +308,7 @@ export default function AtTheDoorPage() {
eventOptions.forEach(o => {
if (!o.isMainTicket) return;
if ((o.variants || []).length > 0) {
qtyMap[`${o.id}::${(o.variants as any[])[0].id}`] = 1;
qtyMap[`${o.id}::${(o.variants || [])[0].id}`] = 1;
} else {
qtyMap[o.id] = 1;
}
@@ -332,27 +329,27 @@ export default function AtTheDoorPage() {
// Re-fetches the registration fresh rather than trusting the (possibly up to 5-minutes-stale,
// see DoorRegistrationPanel's polling interval) cached search-result snapshot, so the
// already-issued-ticket floor below is always computed from current data.
const handleEditRegistration = async (reg: any) => {
const handleEditRegistration = async (reg: Registration) => {
let freshReg = reg;
try {
freshReg = await apiFetch<any>(`/api/registrations/${reg.id}`, { authToken: token });
} catch (e: any) {
setError(e?.message || "Failed to load latest registration data");
freshReg = await apiFetch<Registration>(`/api/registrations/${reg.id}`, { authToken: token });
} catch (e) {
setError(errorMessage(e) || "Failed to load latest registration data");
return;
}
const qtyMap = initQuantities(eventOptions);
const regOptions = freshReg.registrationOptions || freshReg.options || [];
regOptions.forEach((ro: any) => {
const regOptions = freshReg.registrationOptions || [];
regOptions.forEach((ro) => {
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
if (key in qtyMap) qtyMap[key] = ro.quantity || 0;
});
// Already-issued ticket quantity per option/variant — floor for the edit below
const minQtyMap: Record<string, number> = {};
regOptions.forEach((ro: any) => {
regOptions.forEach((ro) => {
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
const issuedQty = (ro.tickets || []).reduce((s: number, t: any) => s + (t.quantity || 0), 0);
const issuedQty = (ro.tickets || []).reduce((s, t) => s + (t.quantity || 0), 0);
if (issuedQty > 0) minQtyMap[key] = (minQtyMap[key] || 0) + issuedQty;
});
@@ -364,7 +361,7 @@ export default function AtTheDoorPage() {
setShowOptionsModal(true);
};
const handlePaymentCaptured = async (registration?: any, partial?: boolean) => {
const handlePaymentCaptured = async (registration?: Registration, partial?: boolean) => {
if (partial && registration) {
setActiveRegistration(registration);
@@ -384,7 +381,7 @@ export default function AtTheDoorPage() {
setMode("checkin");
};
const handleDonation = (user?: any) => {
const handleDonation = (user?: UserSummary) => {
setDonationUser(user || null); // null = anonymous
setShowDonationModal(true);
};
@@ -465,12 +462,10 @@ export default function AtTheDoorPage() {
<DoorRegistrationPanel
token={token}
eventId={eventId}
onCreated={handleRegistrationCreated}
onSelected={handleRegistrationSelected}
onEditRegistration={handleEditRegistration}
onNewAttendee={handleShowNewAttendee}
onDonation={handleDonation}
setError={setError}
/>
)}
@@ -492,7 +487,7 @@ export default function AtTheDoorPage() {
)}
{mode === "refund" && (
<DoorRefundPanel token={token} eventId={eventId} setError={setError} setInfo={setInfo} />
<DoorRefundPanel token={token} eventId={eventId} setInfo={setInfo} />
)}
<OptionsModal
open={showOptionsModal}
@@ -504,7 +499,7 @@ export default function AtTheDoorPage() {
onConfirm={confirmRegistration}
confirming={confirming}
isEdit={!!pendingEditReg}
totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)}
totalPaid={pendingEditReg ? totalPaidOn(pendingEditReg) : 0}
eventId={eventId}
token={token}
coupon={doorCoupon}
@@ -537,9 +532,16 @@ export default function AtTheDoorPage() {
);
}
function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRegistration, onNewAttendee, onDonation, setError }: any) {
function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration, onNewAttendee, onDonation }: {
token: string | null;
eventId: string;
onSelected: (r: Registration) => void;
onEditRegistration: (r: Registration) => void;
onNewAttendee: (seed: string) => void;
onDonation: (user?: UserSummary) => void;
}) {
const [search, setSearch] = useState("");
const [allRegs, setAllRegs] = useState<any[]>([]);
const [allRegs, setAllRegs] = useState<Registration[]>([]);
const [loading, setLoading] = useState(false);
// Load registrations for this event — once on mount/eventId change, then every 5 min
@@ -550,7 +552,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
const load = async () => {
try {
setLoading(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
if (!cancelled) setAllRegs(regs || []);
} catch {
if (!cancelled) setAllRegs([]);
@@ -576,7 +578,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
if (!token || !eventId) return;
try {
setLoading(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
setAllRegs(regs || []);
} catch {} finally { setLoading(false); }
}}
@@ -644,7 +646,12 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
);
}
function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
function DoorPaymentPanel({ token, registration, onSuccess, setError }: {
token: string | null;
registration: Registration | null;
onSuccess: (registration?: Registration, partial?: boolean) => void;
setError: (e: string | null) => void;
}) {
const [amount, setAmount] = useState("");
const [method, setMethod] = useState("card");
const [saving, setSaving] = useState(false);
@@ -659,23 +666,10 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
);
}
const options = registration.options || registration.registrationOptions || [];
const payments = registration.payments || [];
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0);
const qty = opt.quantity || 0;
return sum + price * qty;
}, 0);
const paidValue = payments.reduce(
(sum: number, p: any) => sum + (p.amount || 0),
0
);
const totalValue = registrationTotal(registration);
const paidValue = totalPaidOn(registration);
const balance = Math.max(0, totalValue - paidValue);
@@ -698,26 +692,11 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
}
});
const updated = await apiFetch(`/api/registrations/${registration.id}`, {
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, {
authToken: token
});
const updatedOptions = updated.options || updated.registrationOptions || [];
const updatedPayments = updated.payments || [];
const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0);
return sum + price * (opt.quantity || 0);
}, 0);
const paidValue = updatedPayments.reduce(
(sum: number, p: any) => sum + (p.amount || 0),
0
);
const updatedBalance = Math.max(0, totalValue - paidValue);
const updatedBalance = Math.max(0, registrationTotal(updated) - totalPaidOn(updated));
if (updatedBalance === 0) {
onSuccess(updated); // 🎯 ONLY NOW generate tickets
@@ -725,8 +704,8 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
onSuccess(updated, true); // 🎯 partial payment flow
}
} catch (e: any) {
setError(e?.message || "Payment failed");
} catch (e) {
setError(errorMessage(e) || "Payment failed");
} finally {
setSaving(false);
}
@@ -849,11 +828,10 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
token={token}
registration={registration}
maxRefund={paidValue}
onRefunded={(updated: any) => {
onRefunded={(updated) => {
setShowRefund(false);
onSuccess(updated, true);
}}
setError={setError}
/>
<SendTicketsModal
@@ -861,20 +839,23 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
onClose={() => setShowSendTickets(false)}
token={token}
registration={registration}
setError={setError}
setInfo={() => {}}
/>
</div>
);
}
function DoorTicketsPanel({ token, eventId }: any) {
function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: string }) {
const [search, setSearch] = useState("");
const [allTickets, setAllTickets] = useState<any[]>([]);
const [allTickets, setAllTickets] = useState<TicketRecord[]>([]);
const [loading, setLoading] = useState(false);
const [sendTarget, setSendTarget] = useState<any | null>(null);
const [transferTarget, setTransferTarget] = useState<any | null>(null);
const [sendTarget, setSendTarget] = useState<TicketRecord | null>(null);
const [transferTarget, setTransferTarget] = useState<TicketRecord | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Memoised so the send modal's reset effect doesn't rerun on every render of this panel.
const sendRegistration = useMemo(() => sendTarget ? {
id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId || "",
user: sendTarget.user,
} : null, [sendTarget]);
// Load all tickets for the event once
useEffect(() => {
@@ -882,8 +863,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
(async () => {
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : []));
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
setAllTickets(ticketsFrom(res));
} catch {
setAllTickets([]);
} finally {
@@ -916,8 +897,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : []));
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
setAllTickets(ticketsFrom(res));
} catch {
setAllTickets([]);
} finally {
@@ -925,7 +906,7 @@ function DoorTicketsPanel({ token, eventId }: any) {
}
};
const print = (ticket: any) => {
const print = (ticket: TicketRecord) => {
const w = window.open("", "_blank");
if (!w) return;
const eventTitle = ticket.event?.title || ticket.eventId || "Event";
@@ -1061,49 +1042,50 @@ function DoorTicketsPanel({ token, eventId }: any) {
<TransferTicketModal
ticket={transferTarget}
event={transferTarget.event}
token={token}
token={token ?? ""}
onClose={() => setTransferTarget(null)}
onDone={(msg) => { setTransferTarget(null); setNotice(msg); load(); }}
/>
)}
{sendTarget && (
{sendRegistration && (
<SendTicketsModal
open={true}
onClose={() => setSendTarget(null)}
token={token}
registration={{
id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId,
user: sendTarget.user,
}}
setError={() => {}}
setInfo={() => {}}
registration={sendRegistration}
/>
)}
</div>
);
}
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
token: string | null;
eventId: string;
registration: Registration | null;
setError: (e: string | null) => void;
setInfo: (s: string | null) => void;
}) {
const [ticketsForEvent, setTicketsForEvent] = useState<TicketRecord[]>([]);
const [loading, setLoading] = useState(false);
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const load = async () => {
const load = useCallback(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));
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
const tickets = ticketsFrom(res);
setTicketsForEvent(tickets.filter((t) => t.registrationOption?.eventOption?.isMainTicket));
} catch {
setTicketsForEvent([]);
} finally {
setLoading(false);
}
};
}, [token, eventId]);
useEffect(() => { load(); }, [token, eventId]);
useEffect(() => { load(); }, [load]);
if (!registration) {
return (
@@ -1116,29 +1098,30 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
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 totalRedeemed = (t.usages || []).reduce((s, u) => 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) =>
type CheckInTicket = (typeof myTickets)[number];
const qtyFor = (t: CheckInTicket) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
const setQtyFor = (t: CheckInTicket, n: number) =>
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
const commit = async (ticket: any) => {
const commit = async (ticket: CheckInTicket) => {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<any>(
const res = await apiFetch<{ qtyRedeemed?: number; remaining?: number }>(
`/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"
(res?.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e: any) {
setError(e?.message || "Check-in failed");
} catch (e) {
setError(errorMessage(e) || "Check-in failed");
} finally {
setSubmittingId(null);
}
@@ -1225,33 +1208,54 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: any) {
type OptionsModalProps = {
open: boolean;
onClose: () => void;
options: EventOption[];
quantities: Record<string, number>;
setQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>;
minQuantities?: Record<string, number>;
onConfirm: () => void;
confirming: boolean;
isEdit: boolean;
totalPaid?: number;
eventId: string;
token: string | null;
coupon: AppliedCoupon | null;
setCoupon: (c: AppliedCoupon | null) => void;
couponForUserId: string | null;
existingCode: string | null;
// Named-attendee editor settings, or null when the event doesn't take names.
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
};
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: OptionsModalProps) {
if (!open) return null;
const basket = Object.entries(quantities as Record<string, number>)
const basket = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
});
const safeOptions: any[] = options || [];
const safeOptions: EventOption[] = options || [];
const total = safeOptions.reduce((sum: number, o: any) => {
const total = safeOptions.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants as any[]).reduce((vs: number, v: any) =>
vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
return sum + (o.variants || []).reduce((vs, v) =>
vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
}
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
}, 0);
const belowPaid = isEdit && total < totalPaid;
const belowIssued = isEdit && Object.entries(minQuantities).some(
([key, minQty]: [string, any]) => minQty > 0 && (quantities[key] || 0) < minQty
([key, minQty]) => minQty > 0 && (quantities[key] || 0) < minQty
);
const stepper = (key: string, delta: number) =>
setQuantities((q: Record<string, number>) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) }));
setQuantities((q) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) }));
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
@@ -1271,7 +1275,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
{safeOptions.map((opt: any) => {
{safeOptions.map((opt) => {
const hasVariants = (opt.variants || []).length > 0;
if (hasVariants) {
return (
@@ -1280,8 +1284,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{opt.name}
{opt.isMainTicket && <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span>}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
{(opt.variants || []).map((v) => {
const unit = variantUnitNow(opt, v);
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
const minQty = minQuantities[key] || 0;
@@ -1306,7 +1310,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
</div>
);
}
const unit = effectiveOptionUnit(opt);
const unit = optionUnitNow(opt);
const minQty = minQuantities[opt.id] || 0;
return (
<div key={opt.id} className="border rounded-xl p-3">
@@ -1386,7 +1390,15 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
);
}
function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: any) {
function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: {
open: boolean;
onClose: () => void;
user: UserSummary | null;
token: string | null;
eventId: string;
onSuccess?: () => void;
setError: (e: string | null) => void;
}) {
const [amount, setAmount] = useState("");
const [method, setMethod] = useState("cash");
const [saving, setSaving] = useState(false);
@@ -1421,8 +1433,8 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
setAmount("");
onClose();
} catch (e: any) {
setError(e?.message || "Donation failed");
} catch (e) {
setError(errorMessage(e) || "Donation failed");
} finally {
setSaving(false);
}
@@ -1480,7 +1492,12 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
);
}
function SendTicketsModal({ open, onClose, token, registration, setError, setInfo }: any) {
function SendTicketsModal({ open, onClose, token, registration }: {
open: boolean;
onClose: () => void;
token: string | null;
registration: Pick<Registration, "id" | "user"> | null;
}) {
const [channel, setChannel] = useState<"email" | "whatsapp" | "both">("email");
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
@@ -1495,7 +1512,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
setLocalError("");
setLocalInfo("");
}
}, [open, registration]);
}, [open, registration, setLocalError]);
if (!open) return null;
@@ -1515,7 +1532,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
method: "POST",
authToken: token,
body: {
registrationId: registration.id,
registrationId: registration?.id,
channel,
overrideEmail: email.trim() || undefined,
overridePhone: phone.trim() || undefined,
@@ -1523,8 +1540,8 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
});
setLocalInfo("Tickets sent successfully.");
setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
} catch (e: any) {
setLocalError(e?.message || "Failed to send tickets");
} catch (e) {
setLocalError(errorMessage(e) || "Failed to send tickets");
} finally {
setSending(false);
}
@@ -1609,7 +1626,14 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
);
}
function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded, setError }: any) {
function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded }: {
open: boolean;
onClose: () => void;
token: string | null;
registration: Registration | null;
maxRefund: number;
onRefunded: (updated: Registration) => void;
}) {
const [amount, setAmount] = useState("");
const [method, setMethod] = useState("cash");
const [reason, setReason] = useState("");
@@ -1618,9 +1642,9 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
useEffect(() => {
if (open) { setAmount(""); setReason(""); setLocalError(""); }
}, [open]);
}, [open, setLocalError]);
if (!open) return null;
if (!open || !registration) return null;
const save = async (e: React.FormEvent) => {
e.preventDefault();
@@ -1643,10 +1667,10 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
}
});
// Re-fetch registration to get updated status & payments
const updated = await apiFetch<any>(`/api/registrations/${registration.id}`, { authToken: token });
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, { authToken: token });
onRefunded(updated);
} catch (e: any) {
setLocalError(e?.message || "Refund failed.");
} catch (e) {
setLocalError(errorMessage(e) || "Refund failed.");
} finally {
setSaving(false);
}
@@ -1729,48 +1753,48 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
);
}
function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
function DoorRefundPanel({ token, eventId, setInfo }: {
token: string | null;
eventId: string;
setInfo: (s: string | null) => void;
}) {
const [search, setSearch] = useState("");
const [allRegs, setAllRegs] = useState<any[]>([]);
const [allRegs, setAllRegs] = useState<Registration[]>([]);
const [loading, setLoading] = useState(false);
const [selectedReg, setSelectedReg] = useState<any | null>(null);
const [selectedReg, setSelectedReg] = useState<Registration | null>(null);
const [showRefund, setShowRefund] = useState(false);
const load = async () => {
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
setAllRegs(regs || []);
} catch {
setAllRegs([]);
} finally {
setLoading(false);
}
};
}, [token, eventId]);
useEffect(() => {
load();
}, [token, eventId]);
}, [load]);
// Only show registrations that have at least one payment
const paidRegs = allRegs.filter(r =>
(r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0) > 0
);
const paidRegs = allRegs.filter(r => totalPaidOn(r) > 0);
const results = fuzzyFilterRegs(paidRegs, search);
const handleRefunded = async (updated: any) => {
const handleRefunded = async (updated: Registration) => {
setShowRefund(false);
setSelectedReg(updated);
setInfo("Refund recorded");
// Refresh list
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
if (regs) setAllRegs(regs);
};
const maxRefund = selectedReg
? (selectedReg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)
: 0;
const maxRefund = selectedReg ? totalPaidOn(selectedReg) : 0;
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
@@ -1796,7 +1820,7 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
<div className="space-y-2 max-h-[32rem] overflow-auto">
{results.map(r => {
const paidValue = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
const paidValue = totalPaidOn(r);
return (
<div key={r.id} className="border rounded p-3 flex justify-between items-center">
<div>
@@ -1845,12 +1869,7 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
<div className="border rounded p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="font-semibold">
R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined
? Number(o.priceSnapshot)
: (o.eventOption?.price || o.price || 0);
return s + price * (o.quantity || 0);
}, 0)).toFixed(2)}
R {registrationTotal(selectedReg).toFixed(2)}
</div>
</div>
<div className="border rounded p-3 text-center bg-red-50">
@@ -1863,11 +1882,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
<div className="mt-3">
<div className="text-xs font-medium text-gray-500 mb-1">Payment history</div>
<div className="space-y-1">
{selectedReg.payments.map((p: any, i: number) => (
{(selectedReg.payments || []).map((p, i) => (
<div key={i} className="flex justify-between text-sm">
<span className={p.amount < 0 ? "text-red-600" : ""}>
{p.amount < 0 ? "Refund" : "Payment"} — {p.method || "—"}
{p.reason ? ` (${p.reason})` : ""}
{/* A refund's reason is stored in its status field. */}
{p.amount < 0 && p.status ? ` (${p.status})` : ""}
</span>
<span className={p.amount < 0 ? "text-red-600 font-medium" : ""}>
R {Number(p.amount).toFixed(2)}
@@ -1901,7 +1921,6 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
registration={selectedReg}
maxRefund={maxRefund}
onRefunded={handleRefunded}
setError={setError}
/>
</div>
);
@@ -8,26 +8,9 @@ import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
import type { Event, ConsentRow, MedicalRow } from "@/types";
type ConsentRow = {
attendeeId: string;
name: string;
dateOfBirth: string | null;
minor: boolean;
parentName: string | null;
parentEmail: string | null;
parentPhone: string | null;
registrationStatus: string;
bookedBy: { name: string; email: string };
status: "not_required" | "missing" | "pending" | "given" | "declined";
request: { id: string; status: string; textVersion: number; signedName: string | null; respondedAt: string | null; lastSentAt: string; remindersSent: number; photoConsent: boolean | null; outdated: boolean } | null;
};
type MedicalRow = {
name: string; dateOfBirth: string | null; parentName: string | null; parentPhone: string | null; parentEmail: string | null;
medical: { allergies: string; medication: string; conditions: string; medicalAid: string } | null;
emergency: { name: string; phone: string; relationship: string } | null;
photoConsent: boolean | null; purged: boolean;
};
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
not_required: { label: "Not needed (18+)", cls: "bg-gray-100 text-gray-600" },
@@ -51,9 +34,9 @@ export default function ConsentTrackerPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
const [data, setData] = useState<{ event: any; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
const [data, setData] = useState<{ event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
const [busy, setBusy] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
@@ -61,7 +44,7 @@ export default function ConsentTrackerPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
.then(evs => {
const youth = (evs || []).filter(e => e.requiresParentalConsent);
setEvents(youth);
@@ -76,8 +59,8 @@ export default function ConsentTrackerPage() {
setBusy(true);
try {
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
} catch (e: any) {
setError(e?.message || "Failed to load consent status");
} catch (e) {
setError(errorMessage(e) || "Failed to load consent status");
} finally {
setBusy(false);
}
@@ -100,8 +83,8 @@ export default function ConsentTrackerPage() {
const res = await fn();
setInfo(res.message);
await load();
} catch (e: any) {
setError(e?.message || "Something went wrong");
} catch (e) {
setError(errorMessage(e) || "Something went wrong");
} finally {
setBusy(false);
}
@@ -114,7 +97,7 @@ export default function ConsentTrackerPage() {
// Printable list for camp leaders. Opening it is recorded in the audit log.
const printMedical = async () => {
try {
const res = await apiFetch<{ event: any; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
const res = await apiFetch<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
const w = window.open("", "_blank");
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
const rowsHtml = res.rows.map(r => `
@@ -139,8 +122,8 @@ export default function ConsentTrackerPage() {
<tbody>${rowsHtml || '<tr><td colspan="5">No consent given yet.</td></tr>'}</tbody></table>
<script>window.onload=()=>window.print()</script></body></html>`);
w.document.close();
} catch (e: any) {
setError(e?.message || "Failed to load the medical list");
} catch (e) {
setError(errorMessage(e) || "Failed to load the medical list");
}
};
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TicketPercent, Plus, X, Search } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
type CouponType = "percent" | "fixed" | "comp";
type Role = "user" | "staff" | "supervisor" | "admin";
@@ -164,8 +165,8 @@ export default function CouponsPage() {
]);
setCoupons(c || []);
setEvents(ev || []);
} catch (e: any) {
setError(e?.message || "Failed to load coupons");
} catch (e) {
setError(errorMessage(e) || "Failed to load coupons");
} finally {
setFetching(false);
}
@@ -183,8 +184,8 @@ export default function CouponsPage() {
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
load();
} catch (e: any) {
setError(e?.message || "Failed to update coupon");
} catch (e) {
setError(errorMessage(e) || "Failed to update coupon");
}
};
@@ -192,8 +193,8 @@ export default function CouponsPage() {
try {
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
setViewing({ coupon: c, redemptions: full.redemptions || [] });
} catch (e: any) {
setError(e?.message || "Failed to load uses");
} catch (e) {
setError(errorMessage(e) || "Failed to load uses");
}
};
@@ -406,8 +407,8 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
onSaved(`${body.code.toUpperCase()} created.`);
}
} catch (err: any) {
setError(err?.message || "Failed to save");
} catch (err) {
setError(errorMessage(err) || "Failed to save");
} finally {
setSaving(false);
}
@@ -1,14 +1,22 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail, Users, Zap, Clock } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
import { errorMessage } from "@/lib/utils";
import type { Event, MessageSendResult, Registration, User } from "@/types";
type Attendee = { id: string; name: string; email: string; pref: string };
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
type StatusFilter = 'any'|'paid'|'unpaid'|'partial_paid'|'cancelled';
type MessageFilter = { status?: Exclude<StatusFilter, 'any'>; attendeeIds?: string[]; allocationGroupId?: string };
// A message body goes as `html` when it looks like markup, otherwise as plain `text`.
type EmailContent = { html?: string; text?: string };
type EmailSendResult = MessageSendResult<{ email: string; name?: string }>;
export default function EmailAttendeesPage() {
return (
@@ -135,16 +143,14 @@ function AttendeesCheckboxDropdown({
}
function PrefWarning({ attendees, selectedIds, channel }: { attendees: { id: string; pref: string }[]; selectedIds: string[]; channel: "email" | "whatsapp" }) {
const prefMatch = (pref: string) =>
channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || pref === "both";
const mismatched = useMemo(
() => selectedIds.filter(id => {
const mismatched = useMemo(() => {
const prefMatch = (pref: string) =>
channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || pref === "both";
return selectedIds.filter(id => {
const a = attendees.find(x => x.id === id);
return a && !prefMatch(a.pref);
}),
[attendees, selectedIds, channel]
);
});
}, [attendees, selectedIds, channel]);
if (mismatched.length === 0) return null;
return (
@@ -180,9 +186,7 @@ function EmailAttendeesPageInner() {
// Tabs: attendees (current), automations (coming soon), broadcasts
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
const isAdmin = user?.role === "admin";
const [allEvents, setAllEvents] = useState<any[]>([]);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
@@ -194,20 +198,21 @@ function EmailAttendeesPageInner() {
});
}, [allEvents, evIncludePast]);
const loadEvents = async () => {
const loadEvents = useCallback(async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
const sorted = (evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
const sorted = (evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
setAllEvents(sorted);
} catch (e: any) {
setError(e?.message || "Failed to load events");
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
} finally {
setLoadingEvents(false);
}
};
}, [token, setError]);
useEffect(() => { loadEvents(); }, [user, token]);
// Reload when the signed-in user changes too (e.g. after an impersonation switch).
useEffect(() => { loadEvents(); }, [user, loadEvents]);
// Load users for broadcasts
useEffect(() => {
@@ -217,11 +222,11 @@ function EmailAttendeesPageInner() {
setLoadingUsers(true);
const allUsers = await fetchAllUsers(token);
const mapped = allUsers
.filter((u: any) => u.isActive !== false && !u.email?.endsWith('@guest.local'))
.map((u: any) => ({ id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' }))
.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
.filter((u) => u.isActive !== false && !u.email?.endsWith('@guest.local'))
.map((u) => ({ id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' }))
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
setUsers(mapped);
} catch (e) {
} catch {
// ignore for now
} finally {
setLoadingUsers(false);
@@ -236,7 +241,7 @@ function EmailAttendeesPageInner() {
if (preselectEventId) setEventId(preselectEventId);
}, [preselectEventId]);
const [templateKey, setTemplateKey] = useState<'custom'|'payment_reminder'|'event_reminder'|'tickets'>('custom');
const [templateKey, setTemplateKey] = useState<TemplateKey>('custom');
const [showInfo, setShowInfo] = useState(false);
// Broadcasts state
@@ -271,20 +276,20 @@ function EmailAttendeesPageInner() {
const [editWhen, setEditWhen] = useState<string>('');
const [savingEdit, setSavingEdit] = useState(false);
const loadScheduled = async () => {
const loadScheduled = useCallback(async () => {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch (e) {
} catch {
// ignore here; surfaces via UI when tab open
} finally {
setLoadingScheduled(false);
}
};
}, [token]);
useEffect(() => { if (tab === 'scheduled') loadScheduled(); }, [tab, token]);
useEffect(() => { if (tab === 'scheduled') loadScheduled(); }, [tab, loadScheduled]);
const openEdit = (job: ScheduledJob) => {
setEditing(job);
@@ -298,7 +303,7 @@ function EmailAttendeesPageInner() {
try {
setSavingEdit(true);
if (!token) { setError('Not authenticated'); return; }
const body: any = {};
const body: { scheduledAt?: string; subject?: string } & EmailContent = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editSubject.trim().length || editBody.trim().length) {
body.subject = editSubject;
@@ -314,8 +319,8 @@ function EmailAttendeesPageInner() {
setInfo('Scheduled email updated.');
setEditing(null);
loadScheduled();
} catch (e:any) {
setError(e?.message || 'Failed to update scheduled email');
} catch (e) {
setError(errorMessage(e) || 'Failed to update scheduled email');
} finally {
setSavingEdit(false);
}
@@ -327,8 +332,8 @@ function EmailAttendeesPageInner() {
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: 'DELETE', authToken: token });
setInfo('Scheduled email removed.');
loadScheduled();
} catch (e:any) {
setError(e?.message || 'Failed to remove scheduled email');
} catch (e) {
setError(errorMessage(e) || 'Failed to remove scheduled email');
}
};
@@ -336,7 +341,7 @@ function EmailAttendeesPageInner() {
const [body, setBody] = useState("");
const [subjectDirty, setSubjectDirty] = useState(false);
const [bodyDirty, setBodyDirty] = useState(false);
const [status, setStatus] = useState<'any'|'paid'|'unpaid'|'partial_paid'|'cancelled'>("any");
const [status, setStatus] = useState<StatusFilter>("any");
const [groupId, setGroupId] = useState("");
// Attendees list and selection
const [attendees, setAttendees] = useState<Attendee[]>([]);
@@ -360,11 +365,8 @@ function EmailAttendeesPageInner() {
setBodyDirty(false);
setSubject('');
setBody('');
} else {
// custom: do not overwrite user content unless not dirty and we have event change
if (!subjectDirty && subject) setSubject(subject); // keep as-is
if (!bodyDirty && body) setBody(body);
}
// custom: leave whatever the supervisor typed alone.
// When event changes, refresh defaults if not dirty
}, [templateKey, currentEvent, subjectDirty, bodyDirty]);
@@ -389,10 +391,10 @@ function EmailAttendeesPageInner() {
setAttendees([]);
setSelectedAttendeeIds([]);
if (!eventId || !token) return;
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const uniq = new Map<string, Attendee>();
(regs || []).forEach((r: any) => {
const u = r?.user;
(regs || []).forEach((r) => {
const u: User | undefined = r?.user;
if (u?.id && u?.email && !u.email.endsWith('@guest.local') && u.isActive !== false) {
uniq.set(u.id, { id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' });
}
@@ -402,7 +404,7 @@ function EmailAttendeesPageInner() {
// Default: select those with email/both preference; fall back to all
const matching = list.filter(a => a.pref === 'email' || a.pref === 'both').map(a => a.id);
setSelectedAttendeeIds(matching.length > 0 ? matching : list.map(a => a.id));
} catch (e) {
} catch {
// ignore
} finally {
setLoadingAttendees(false);
@@ -420,7 +422,7 @@ function EmailAttendeesPageInner() {
if (!subject.trim()) { setError("Subject is required"); return; }
if (!body.trim()) { setError("Message is required"); return; }
}
const payload: any = {
const payload: { subject: string; filter: MessageFilter; dryRun: boolean; template: TemplateKey } & EmailContent = {
subject: subject || "(no subject)",
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
dryRun: true,
@@ -429,12 +431,12 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body;
}
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
} catch (e: any) {
setError(e?.message || 'Failed to preview recipients');
} catch (e) {
setError(errorMessage(e) || 'Failed to preview recipients');
}
};
@@ -460,7 +462,7 @@ function EmailAttendeesPageInner() {
if (!body.trim()) { setError("Message is required"); return; }
}
setSending(true);
const payload: any = {
const payload: { subject: string; filter: MessageFilter; template: TemplateKey } & EmailContent = {
subject,
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey,
@@ -468,13 +470,13 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const queued = res?.queued ?? res?.matched ?? 0;
setInfo(`Queued ${queued} recipient(s) for sending.`);
// Reset form to default state
resetAttendeesForm();
} catch (e: any) {
setError(e?.message || 'Failed to send emails');
} catch (e) {
setError(errorMessage(e) || 'Failed to send emails');
} finally {
setSending(false);
}
@@ -556,7 +558,7 @@ function EmailAttendeesPageInner() {
Info
</button>
</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={e => setTemplateKey(e.target.value as any)}>
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={e => setTemplateKey(e.target.value as TemplateKey)}>
<option value="custom">Custom message</option>
<option value="payment_reminder">Payment reminder (with outstanding)</option>
<option value="event_reminder">Event reminder (date & time)</option>
@@ -584,7 +586,7 @@ function EmailAttendeesPageInner() {
<li><code>{'{{event.title}}'}</code> — the event title.</li>
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
<li><code>{'{{balance}}'}</code> — outstanding amount across the attendee’s registrations for the selected event.</li>
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee&apos;s outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
@@ -601,7 +603,7 @@ function EmailAttendeesPageInner() {
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Payment status filter</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={e => setStatus(e.target.value as any)}>
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={e => setStatus(e.target.value as StatusFilter)}>
<option value="any">Any</option>
<option value="paid">Paid</option>
<option value="unpaid">Unpaid (pending or partial)</option>
@@ -660,16 +662,16 @@ function EmailAttendeesPageInner() {
if (!body.trim()) { setError('Message is required'); return; }
}
const whenIso = new Date(scheduledAtLocal).toISOString();
const payload = {
const payload: { subject: string; filter: MessageFilter; template: TemplateKey; scheduledAt: string } & EmailContent = {
subject,
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey,
scheduledAt: whenIso,
} as any;
};
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
if (res?.job?.id) {
setInfo('Email scheduled. It will be sent around the specified time.');
} else {
@@ -677,8 +679,8 @@ function EmailAttendeesPageInner() {
}
// Reset form to default state
resetAttendeesForm();
} catch (e:any) {
setError(e?.message || 'Failed to schedule email');
} catch (e) {
setError(errorMessage(e) || 'Failed to schedule email');
}
}}>Schedule send</button>
{previewCount != null && (
@@ -779,13 +781,13 @@ Jane Doe <jane@example.com>
if (!token) { setError('Not authenticated'); return; }
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const body: any = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined };
const res = await apiFetch(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined };
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
} catch (e:any) {
setError(e?.message || 'Failed to preview recipients');
} catch (e) {
setError(errorMessage(e) || 'Failed to preview recipients');
}
}}>Preview recipients</button>
@@ -795,14 +797,14 @@ Jane Doe <jane@example.com>
if (!token) { setError('Not authenticated'); return; }
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const payload: any = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
const payload: { subject: string; eventId?: string; userIds: string[]; emails: string } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
// Reset broadcast form
resetBroadcastForm();
} catch (e:any) {
setError(e?.message || 'Failed to send broadcast');
} catch (e) {
setError(errorMessage(e) || 'Failed to send broadcast');
}
}}>Send now</button>
@@ -813,14 +815,14 @@ Jane Doe <jane@example.com>
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const payload: any = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
// Reset broadcast form
resetBroadcastForm();
} catch (e:any) {
setError(e?.message || 'Failed to schedule broadcast');
} catch (e) {
setError(errorMessage(e) || 'Failed to schedule broadcast');
}
}}>Schedule send</button>
@@ -963,9 +965,9 @@ function toLocalInputValue(d: Date) {
return `${y}-${m}-${day}T${hh}:${mm}`;
}
function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
function AutomationsPanel({ events, token, onInfo, onError }:{ events: Event[]; token?: string | null; onInfo: (s: string | null) => void; onError: (s: string | null) => void }) {
const [autoEventId, setAutoEventId] = React.useState<string>('');
const currentEvent = React.useMemo(() => (events || []).find((e:any) => e.id === autoEventId), [events, autoEventId]);
const currentEvent = React.useMemo(() => (events || []).find((e) => e.id === autoEventId), [events, autoEventId]);
// Defaults builders
const preWeekDefault = React.useMemo(() => ({
@@ -1055,37 +1057,38 @@ With gratitude`);
const onSchedule = async () => {
try {
onError(null as any); onInfo(null as any);
onError(null); onInfo(null);
if (!token) { onError('Not authenticated'); return; }
if (!autoEventId) { onError('Please select an event'); return; }
const jobs: any[] = [];
type AutomationJob = { subject: string; scheduledAt: string; audience?: 'sponsors'; promoEventId?: string } & EmailContent;
const jobs: AutomationJob[] = [];
if (enablePre && preSubject.trim() && preBody.trim() && preWhen) {
const payload: any = { subject: preSubject, scheduledAt: new Date(preWhen).toISOString() };
const payload: AutomationJob = { subject: preSubject, scheduledAt: new Date(preWhen).toISOString() };
if (preBody.trim().startsWith('<')) payload.html = preBody; else payload.text = preBody;
jobs.push(payload);
}
if (enableFinal && finalSubject.trim() && finalBody.trim() && finalWhen) {
const payload: any = { subject: finalSubject, scheduledAt: new Date(finalWhen).toISOString() };
const payload: AutomationJob = { subject: finalSubject, scheduledAt: new Date(finalWhen).toISOString() };
if (finalBody.trim().startsWith('<')) payload.html = finalBody; else payload.text = finalBody;
jobs.push(payload);
}
if (enableThanks && thanksSubject.trim() && thanksBody.trim() && thanksWhen) {
const payload: any = { subject: thanksSubject, scheduledAt: new Date(thanksWhen).toISOString() };
const payload: AutomationJob = { subject: thanksSubject, scheduledAt: new Date(thanksWhen).toISOString() };
if (thanksBody.trim().startsWith('<')) payload.html = thanksBody; else payload.text = thanksBody;
jobs.push(payload);
}
if (currentEvent?.allowSponsorships && enableSponsors && sponsorSubject.trim() && sponsorBody.trim() && thanksWhen) {
const payload: any = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
const payload: AutomationJob = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
if (sponsorBody.trim().startsWith('<')) payload.html = sponsorBody; else payload.text = sponsorBody;
jobs.push(payload);
}
if (enablePromo && promoSubject.trim() && promoBody.trim() && promoWhen) {
const payload: any = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
const payload: AutomationJob = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
if (promoBody.trim().startsWith('<')) payload.html = promoBody; else payload.text = promoBody;
jobs.push(payload);
}
if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; }
const res = await apiFetch(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
const res = await apiFetch<{ message?: string }>(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`);
// Reset to defaults (keep event selection)
setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false);
@@ -1093,8 +1096,8 @@ With gratitude`);
setFinalSubject(finalDefault.subject); setFinalBody(finalDefault.body); setFinalHours('24');
setThanksSubject(thanksDefault.subject); setThanksBody(thanksDefault.body);
setPromoSubject(promoDefault.subject); setPromoBody(promoDefault.body); setPromoEventId('');
} catch (e:any) {
onError(e?.message || 'Failed to schedule automations');
} catch (e) {
onError(errorMessage(e) || 'Failed to schedule automations');
}
};
@@ -1104,7 +1107,7 @@ With gratitude`);
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={e => setAutoEventId(e.target.value)}>
<option value="">Select an event…</option>
{(events||[]).map((ev:any) => (
{(events||[]).map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
@@ -1137,7 +1140,7 @@ With gratitude`);
<input type="checkbox" checked={enableFinal} onChange={e => setEnableFinal(e.target.checked)} /> Enable
</label>
<label className="text-xs text-gray-600">Hours before: </label>
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={e => setFinalHours(e.target.value as any)}>
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={e => setFinalHours(e.target.value as '24'|'48')}>
<option value="24">24</option>
<option value="48">48</option>
</select>
@@ -1201,7 +1204,7 @@ With gratitude`);
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={e => setPromoEventId(e.target.value)}>
<option value="">Select event to promote…</option>
{(events||[]).map((ev:any) => (
{(events||[]).map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
@@ -1,11 +1,13 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventOption } from "@/types";
// 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
@@ -21,7 +23,10 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
return `${y}-${m}-${day}T${hh}:${mm}`;
}
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
type TierInput = { id?: string; deadline: string; price: number; order?: number };
type OptionPatch = Partial<Pick<EventOption, "name" | "price" | "isMainTicket">> & { earlyBirdTiers?: TierInput[] };
function EarlyBirdTiersEditor({ option, onSave }: { option: EventOption; onSave: (tiers: TierInput[]) => void }) {
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
const [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
@@ -30,8 +35,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
const tiers = Array.isArray(option?.earlyBirdTiers) ? option.earlyBirdTiers : [];
const normalized = tiers
.slice()
.sort((a: any, b: any) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
.map((t: any, i: number) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
.sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
.map((t, i) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
setRows(normalized);
}, [option?.id, option?.earlyBirdTiers]);
@@ -45,7 +50,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
};
const updateRow = (idx: number, patch: Partial<{ deadline: string; price: string; order?: number }>) => {
const copy = rows.slice();
copy[idx] = { ...copy[idx], ...patch } as any;
copy[idx] = { ...copy[idx], ...patch };
setRows(copy);
};
@@ -110,27 +115,23 @@ function EventOptionsContent() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false);
const [options, setOptions] = useState<EventOption[]>([]);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
setLoadingEv(true);
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
setEvents(evs || []);
} catch (e) {
} catch {
// ignore
} finally {
setLoadingEv(false);
}
};
}, [token]);
useEffect(() => { loadEvents(); }, [token]);
useEffect(() => { loadEvents(); }, [loadEvents]);
// Preselect event from query (?eventId=)
useEffect(() => {
@@ -165,12 +166,12 @@ function EventOptionsContent() {
setNewOpt({ name: "", price: "", isMainTicket: false });
setInfo("Option created");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to create option");
} catch (e) {
setError(errorMessage(e) || "Failed to create option");
}
};
const updateOption = async (opt: any, patch: any) => {
const updateOption = async (opt: EventOption, patch: OptionPatch) => {
if (!token) return;
setError(null); setInfo(null);
try {
@@ -181,12 +182,12 @@ function EventOptionsContent() {
});
setInfo("Option updated");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to update option");
} catch (e) {
setError(errorMessage(e) || "Failed to update option");
}
};
const deleteOption = async (opt: any) => {
const deleteOption = async (opt: EventOption) => {
if (!token) return;
setError(null); setInfo(null);
try {
@@ -196,8 +197,8 @@ function EventOptionsContent() {
});
setInfo("Option deleted");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to delete option");
} catch (e) {
setError(errorMessage(e) || "Failed to delete option");
}
};
@@ -1,14 +1,17 @@
"use client";
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { apiFetch } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { Calendar } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -44,7 +47,26 @@ function DTInput({ label, value, onChange, hint, required }: { label: string; va
// ─── types ───────────────────────────────────────────────────────────────────
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph" | "rating" | "multiple_choice";
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: any }[] };
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: unknown }[] };
type FieldDef = EventFormDef["fields"][number];
// What PUT /api/events/options/:id takes as earlyBirdTiers (variantId null = option-level).
type TierPayload = { id?: string; deadline: string; price: number; stockLimit: number; order: number; variantId: string | null };
// GET /api/feedback/event/:id — the event's post-event feedback form, if it has one.
type FeedbackFormResponse = {
fields?: EventFormField[];
sendAt?: string | null;
audience?: string;
anonymous?: boolean;
intro?: string | null;
sentAt: string | null;
invited: number;
responses: number;
};
const toFieldDefs = (fields: EventFormField[] | undefined): FieldDef[] =>
(fields || []).map((x) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined }));
type Tier = { id?: string; deadline: string; price: string; stockLimit: string; order?: number };
@@ -88,8 +110,9 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
try {
const fd = new FormData();
fd.append("image", file);
const res: any = await apiFetch("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
if (res?.url || res?.path) onUploaded(res.url || res.path);
const res = await apiFetch<{ url?: string; path?: string }>("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
const uploaded = res?.url || res?.path;
if (uploaded) onUploaded(uploaded);
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
}} />
<button type="button" onClick={() => ref.current?.click()} disabled={uploading}
@@ -109,7 +132,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
}) {
const fields = value.fields || [];
const add = (type: FormFieldType) => onChange({ ...value, fields: [...fields, { type, label: "", isRequired: false, helpText: "" }] });
const upd = (idx: number, patch: any) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
const upd = (idx: number, patch: Partial<FieldDef>) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
const rem = (idx: number) => { const c = fields.slice(); c.splice(idx, 1); onChange({ ...value, fields: c }); };
const mov = (idx: number, dir: -1 | 1) => {
const c = fields.slice(); const t = idx + dir;
@@ -143,7 +166,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
<button type="button" className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => rem(idx)}>Remove</button>
</div>
<div className="flex flex-wrap gap-2 mb-1.5">
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value })}>
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value as FormFieldType })}>
<option value="text">Text</option><option value="numeric">Number</option><option value="date">Date</option>
<option value="yes_no">Yes/No</option><option value="rating">Rating (1–5 stars)</option><option value="multiple_choice">Multiple choice</option>
<option value="statement">Statement</option><option value="paragraph">Heading+para</option>
@@ -452,24 +475,24 @@ function SectionsDraftEditor({ sections, options, onChange }: {
/** Edit-mode: live sections manager (DB-backed) */
function SectionsManager({ eventId }: { eventId: string }) {
const { token } = useAuth();
const [sections, setSections] = useState<any[]>([]);
const [eventOptions, setEventOptions] = useState<any[]>([]);
const [sections, setSections] = useState<Section[]>([]);
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState("");
const [newOptionIds, setNewOptionIds] = useState<string[]>([]);
const load = async () => {
const load = useCallback(async () => {
setLoading(true);
try {
const [secList, evFull] = await Promise.all([
apiFetch<any[]>("/api/sections", { authToken: token || undefined }),
apiFetch<any>(`/api/events/${eventId}`)
apiFetch<Section[]>("/api/sections", { authToken: token || undefined }),
apiFetch<Event>(`/api/events/${eventId}`)
]);
setSections((secList || []).filter(s => s.eventId === eventId));
setEventOptions(evFull?.eventOptions || []);
} catch {} finally { setLoading(false); }
};
useEffect(() => { if (eventId) load(); }, [eventId]);
}, [eventId, token]);
useEffect(() => { if (eventId) load(); }, [eventId, load]);
const create = async () => {
if (!newName.trim() || !token) return;
@@ -514,14 +537,19 @@ function SectionsManager({ eventId }: { eventId: string }) {
);
}
function SectionRow({ sec, options, onDelete, onUpdate }: any) {
function SectionRow({ sec, options, onDelete, onUpdate }: {
sec: Section;
options: EventOption[];
onDelete: () => void;
onUpdate: (id: string, name: string, optionIds: string[]) => void;
}) {
const [name, setName] = useState(sec.name || "");
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o: any) => o.eventOptionId));
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o) => o.eventOptionId));
return (
<div className="border rounded p-3 bg-white">
<input className="border rounded px-2 py-1 text-sm w-full mb-2" value={name} onChange={e => setName(e.target.value)} />
<div className="flex flex-wrap gap-2 mb-2">
{options.map((opt: any) => (
{options.map((opt) => (
<label key={opt.id} className="text-xs flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={optIds.includes(opt.id)}
onChange={e => setOptIds(e.target.checked ? [...optIds, opt.id] : optIds.filter(id => id !== opt.id))} />
@@ -541,15 +569,15 @@ function SectionRow({ sec, options, onDelete, onUpdate }: any) {
function AttachmentsManager({ eventId }: { eventId: string }) {
const { token } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [items, setItems] = useState<EventAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const ref = useRef<HTMLInputElement | null>(null);
const load = async () => {
const res = await apiFetch<any[]>(`/api/events/${eventId}/attachments`);
const load = useCallback(async () => {
const res = await apiFetch<EventAttachment[]>(`/api/events/${eventId}/attachments`);
setItems(Array.isArray(res) ? res : []);
};
useEffect(() => { load(); }, [eventId]);
}, [eventId]);
useEffect(() => { load(); }, [load]);
const upload: React.ChangeEventHandler<HTMLInputElement> = async e => {
const file = e.target.files?.[0];
@@ -645,7 +673,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
setSearching(true);
const t = setTimeout(async () => {
try {
const res = await apiFetch<any>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
const res = await apiFetch<Paginated<NotifyUser>>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
if (!cancelled) setResults(Array.isArray(res?.data) ? res.data : []);
} catch {
if (!cancelled) setResults([]);
@@ -728,10 +756,10 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
(async () => {
setLoading(true);
try {
const res = await apiFetch<any>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
const res = await apiFetch<NotifyUser[]>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
if (!cancelled) setSelected(Array.isArray(res) ? res : []);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load notification recipients");
} catch (e) {
if (!cancelled) setError(errorMessage(e) || "Failed to load notification recipients");
} finally {
if (!cancelled) setLoading(false);
}
@@ -750,9 +778,9 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
method: "PUT", authToken: token || undefined,
body: { userIds: next.map(u => u.id) },
});
} catch (e: any) {
} catch (e) {
setSelected(prev);
setError(e?.message || "Failed to save notification recipients");
setError(errorMessage(e) || "Failed to save notification recipients");
} finally {
setSaving(false);
}
@@ -813,14 +841,13 @@ const blankOptions = (): OptionDraft[] => [
interface EventModalProps {
mode: "create" | "edit";
event?: any;
event?: Event | null;
onClose: () => void;
onSuccess: () => void;
}
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const { token, user } = useAuth();
const isAdmin = user?.role === "admin";
const { token } = useAuth();
const { settings } = useSiteSettings();
const [step, setStep] = useState<StepIdx>(0);
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
@@ -851,8 +878,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => {
if (!ev?.eventOptions?.length) return blankOptions();
return ev.eventOptions.map((o: any) => {
const allTiers: any[] = o.earlyBirdTiers || [];
return ev.eventOptions.map((o) => {
const allTiers = o.earlyBirdTiers || [];
return {
id: o.id,
name: o.name,
@@ -862,17 +889,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t: any) => !t.variantId)
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
variants: (o.variants || []).map((v: any) => ({
.filter((t) => !t.variantId)
.map((t) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
variants: (o.variants || []).map((v) => ({
id: v.id, name: v.name,
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
stockLimit: String(v.stockLimit ?? "0"),
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
// Variant-level tiers — filter by variantId
earlyBirdTiers: allTiers
.filter((t: any) => t.variantId === v.id)
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
.filter((t) => t.variantId === v.id)
.map((t) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
})),
};
});
@@ -892,9 +919,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const formLoadedRef = useRef(false);
useEffect(() => {
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
apiFetch<any>(`/api/events/${ev.id}`).then(full => {
apiFetch<Event>(`/api/events/${ev.id}`).then(full => {
const f = full?.form;
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
if (f) setFormDef({ isRequired: !!f.isRequired, fields: toFieldDefs(f.fields) });
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
}, [mode, ev?.id]);
@@ -905,10 +932,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
useEffect(() => {
if (mode !== "edit" || !ev?.id || !token) return;
apiFetch<any>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
if (!f) return;
setFbEnabled(true);
setFbDef({ isRequired: false, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
setFbDef({ isRequired: false, fields: toFieldDefs(f.fields) });
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
}).catch(() => {});
@@ -991,7 +1018,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (mode === "edit") {
for (const opt of options) {
// Build flat tier array: option-level + all variant tiers
const allTiers: any[] = [
const allTiers: TierPayload[] = [
...opt.earlyBirdTiers
.filter(t => t.deadline && t.price !== "")
.map((t, i) => ({ id: t.id, deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: i, variantId: null })),
@@ -1006,16 +1033,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (opt.id) {
// Update option + variants first to get/confirm variant IDs
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
const serverVariants: any[] = updated?.variants || [];
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const serverVariant = serverVariants[vi] || serverVariants.find((sv: any) => sv.name === v.name);
const serverVariant = serverVariants[vi] || serverVariants.find((sv) => sv.name === v.name);
if (!serverVariant) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
@@ -1038,19 +1065,19 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
});
} else {
// New option added during edit
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
if (created?.id) {
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
method: "PUT", authToken: token || undefined,
body: { variants: variantsPayload }
});
const serverVariants: any[] = updated?.variants || [];
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
const sv = serverVariants[vi] || serverVariants.find((x) => x.name === v.name);
if (!sv) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
@@ -1068,9 +1095,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
} else {
// Create mode — backend auto-creates a "Main Ticket" option; replace/create from draft
let createdEvent: any = null;
try { createdEvent = await apiFetch(`/api/events/${eventId}`); } catch {}
const existingMain = (createdEvent?.eventOptions || []).find((o: any) => !!o.isMainTicket);
let createdEvent: Event | null = null;
try { createdEvent = await apiFetch<Event>(`/api/events/${eventId}`); } catch {}
const existingMain = (createdEvent?.eventOptions || []).find((o) => !!o.isMainTicket);
const uiMainIdx = options.findIndex(o => o.isMainTicket);
for (let i = 0; i < options.length; i++) {
@@ -1091,7 +1118,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
});
optId = existingMain.id;
} else {
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
@@ -1101,18 +1128,18 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (!optId) continue;
// Save variants, then build tiers with resolved IDs
const allTiers: any[] = opt.earlyBirdTiers
const allTiers: TierPayload[] = opt.earlyBirdTiers
.filter(t => t.deadline && t.price !== "")
.map((t, ti) => ({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: null }));
if (variantsPayload.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
const updated: any = await apiFetch(`/api/events/options/${optId}`, {
const updated = await apiFetch<EventOption>(`/api/events/options/${optId}`, {
method: "PUT", authToken: token || undefined, body: { variants: variantsPayload }
});
const serverVariants: any[] = updated?.variants || [];
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
const sv = serverVariants[vi] || serverVariants.find((x) => x.name === v.name);
if (!sv) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
@@ -1131,7 +1158,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
};
const saveStagedSections = async (eventId: string, createdOptions: any[]) => {
const saveStagedSections = async (eventId: string, createdOptions: EventOption[]) => {
for (const sec of sectionDrafts) {
if (!sec.name.trim()) continue;
const optionIds = sec.optionIndices
@@ -1155,7 +1182,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
setSaving(true); setError(null);
try {
const body: any = {
const body: Record<string, unknown> = {
title: draft.title.trim(), description: draft.description || undefined,
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
@@ -1186,6 +1213,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (mode === "edit") {
body.isActive = draft.isActive;
if (!ev) throw new Error("No event loaded to save");
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call
@@ -1194,12 +1222,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
}
const created: any = await apiFetch("/api/events", { method: "POST", authToken: token || undefined, body });
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
if (created?.id) {
await saveOptions(created.id);
await saveFeedback(created.id);
// Fetch event to get final option IDs for sections
const fullEvent: any = await apiFetch(`/api/events/${created.id}`).catch(() => null);
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
const createdOptions = fullEvent?.eventOptions || [];
if (sectionDrafts.length > 0) await saveStagedSections(created.id, createdOptions);
// Upload staged attachments
@@ -1219,8 +1247,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
onSuccess(); onClose();
} catch (e: any) {
setError(e?.message || "Failed to save event");
} catch (e) {
setError(errorMessage(e) || "Failed to save event");
} finally {
setSaving(false);
}
@@ -1306,7 +1334,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<div className="p-5 space-y-4 max-h-[65vh] overflow-y-auto">
{isClosed && (
<div className="p-3 border rounded bg-rose-50 text-rose-800 text-sm">
This event is closed — it can't be edited until it's reopened from the cashup page (admin only).
This event is closed — it can&apos;t be edited until it&apos;s reopened from the cashup page (admin only).
</div>
)}
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
@@ -1342,7 +1370,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
onChange={e => upd({ requiresRegistration: !e.target.checked, price: e.target.checked ? "0" : draft.price })}
/>
<label htmlFor="contact-only-toggle" className="text-xs text-gray-700">
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) — no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) — no online registration or payment. Shows a &quot;Contact us&quot; button on the public site instead of &quot;Register&quot;.
</label>
</div>
{draft.requiresRegistration ? (
@@ -1369,7 +1397,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Picture</label>
{draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
<ApiImage src={draft.picture} className="h-20 w-20 object-cover rounded border mb-2" />
<div className="flex items-center gap-2">
<input className="flex-1 border rounded px-3 py-2 text-sm" placeholder="/uploads/... or https://" value={draft.picture} onChange={e => upd({ picture: e.target.value })} />
<UploadImageButton onUploaded={url => upd({ picture: url })} label="Upload" />
@@ -1405,7 +1433,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{/* ── Step 2: Sections ── */}
{step === 2 && (
<div>
<p className="text-xs text-gray-500 mb-3">Group ticket options into named sections shown on the registration page (e.g. "Tickets", "Add-ons", "Accommodation").</p>
<p className="text-xs text-gray-500 mb-3">Group ticket options into named sections shown on the registration page (e.g. &quot;Tickets&quot;, &quot;Add-ons&quot;, &quot;Accommodation&quot;).</p>
{mode === "edit" && ev?.id
? <SectionsManager eventId={ev.id} />
: <SectionsDraftEditor sections={sectionDrafts} options={options} onChange={setSectionDrafts} />
@@ -1670,7 +1698,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ─── event card ───────────────────────────────────────────────────────────────
function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
const isPast = ev.endDate && new Date(ev.endDate) < new Date();
const isInactive = ev.isActive === false;
const isClosed = ev.cashupStatus === "closed";
@@ -1710,27 +1738,28 @@ export default function ManageEventsPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const loadEvents = async () => {
const loadEvents = useCallback(async () => {
if (!token) return;
setLoadingEvents(true);
try {
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch (e: any) {
setError(e?.message || "Failed to load events");
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
} finally {
setLoadingEvents(false);
}
};
}, [token, setError]);
useEffect(() => { loadEvents(); }, [user, token]);
// Reload when the signed-in user changes too (e.g. after an impersonation switch).
useEffect(() => { loadEvents(); }, [user, loadEvents]);
const [modalMode, setModalMode] = useState<"create" | "edit" | null>(null);
const [editingEvent, setEditingEvent] = useState<any | null>(null);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [includePast, setIncludePast] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [showClosed, setShowClosed] = useState(false);
@@ -1747,9 +1776,9 @@ export default function ManageEventsPage() {
}), [events, includePast, showInactive, showClosed, search, now]);
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
const openEdit = async (ev: any) => {
const openEdit = async (ev: Event) => {
try {
const full = await apiFetch<any>(`/api/events/${ev.id}`, { authToken: token || undefined });
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
setEditingEvent(full);
} catch { setEditingEvent(ev); }
setModalMode("edit");
@@ -1,16 +1,28 @@
"use client";
import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/shared/FormFieldExtras";
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: any }[] };
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: unknown }[] };
// The form as GET /api/events/:id returns it, mapped into the editor's shape.
const toFormDef = (f: EventForm | null | undefined): EventFormDef =>
f && Array.isArray(f.fields)
? { isRequired: !!f.isRequired, fields: f.fields.map((x) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) }
: { isRequired: false, fields: [] };
// /api/forms/responses returns { items } (older builds returned a bare array).
const responseItems = (res: { items?: FormResponse[] } | FormResponse[] | null | undefined): FormResponse[] =>
Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
const fields = value.fields || [];
@@ -138,36 +150,36 @@ export default function FormsBrowserPage() {
const [mode, setMode] = useState<'view' | 'manage' | 'fill'>('view');
const [allEvents, setAllEvents] = useState<any[]>([]);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const [evIncludeInactive, setEvIncludeInactive] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [userId, setUserId] = useState<string>("");
const [userSearch, setUserSearch] = useState<string>("");
const [allUsers, setAllUsers] = useState<any[]>([]);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
const [registrationId, setRegistrationId] = useState<string>("");
const [items, setItems] = useState<any[]>([]);
const [items, setItems] = useState<FormResponse[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const sorted = (evs || []).sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const sorted = (evs || []).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setAllEvents(Array.isArray(sorted) ? sorted : []);
} catch {}
};
useEffect(() => { loadEvents(); }, [token]);
}, [token]);
useEffect(() => { loadEvents(); }, [loadEvents]);
useEffect(() => {
if (!token) return;
fetchAllUsers(token)
.then(list => {
setAllUsers(list.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "")));
setAllUsers(list.sort((a, b) => (a.name || "").localeCompare(b.name || "")));
})
.catch(() => {});
}, [token]);
@@ -207,32 +219,36 @@ export default function FormsBrowserPage() {
if (registrationId.trim()) qs.set("registrationId", registrationId.trim());
if (append && nextCursor) qs.set("cursor", nextCursor);
const url = "/api/forms/responses" + (qs.toString() ? `?${qs.toString()}` : "");
const res = await apiFetch<{ items: any[]; nextCursor?: string }>(url, { authToken: token });
const res = await apiFetch<{ items: FormResponse[]; nextCursor?: string }>(url, { authToken: token });
const newItems = Array.isArray(res?.items) ? res!.items : [];
setItems(prev => append ? [...prev, ...newItems] : newItems);
setNextCursor(res?.nextCursor || null);
} catch (e: any) {
setError(e?.message || "Failed to load form responses");
} catch (e) {
setError(errorMessage(e) || "Failed to load form responses");
} finally {
setLoadingList(false);
}
};
// Load once the token is available; after that, searching is on demand (the
// filters don't refetch as they're typed). The ref keeps the latest `search`.
const searchRef = useRef(search);
useEffect(() => { searchRef.current = search; });
useEffect(() => {
if (token) search(false);
if (token) searchRef.current(false);
}, [token]);
const onPrint = () => {
if (!items || items.length === 0) return;
const w = window.open("", "_blank");
if (!w) return;
const formPages = items.map((r: any) => {
const formPages = items.map((r) => {
const eventTitle = r.registration?.event?.title || "Event";
const attendeeName = r.registration?.user?.name || "Attendee";
const attendeeEmail = r.registration?.user?.email || "";
const regId = String(r.registrationId || r.id || "").slice(0, 8);
const createdAt = r.createdAt ? new Date(r.createdAt).toLocaleDateString("en-ZA") : "";
const answers = (r.answers || []).map((a: any) => `
const answers = (r.answers || []).map((a) => `
<div class="answer">
<div class="label">${a.field?.label || a.fieldId || "Field"}</div>
<div class="value">${a.value ?? ""}</div>
@@ -285,58 +301,53 @@ export default function FormsBrowserPage() {
const [formLoading, setFormLoading] = useState(false);
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [formLoadedFor, setFormLoadedFor] = useState<string>("");
const loadFormForEvent = async (eventId: string) => {
const loadFormForEvent = useCallback(async (eventId: string) => {
if (!eventId) return;
try {
setFormLoading(true);
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
const f = evFull?.form;
if (f && Array.isArray(f.fields)) {
setFormDef({ isRequired: !!f.isRequired, fields: f.fields.map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) });
} else {
setFormDef({ isRequired: false, fields: [] });
}
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(eventId)}`);
setFormDef(toFormDef(evFull?.form));
setFormLoadedFor(eventId);
} catch (e:any) {
setError(e?.message || 'Failed to load form');
} catch (e) {
setError(errorMessage(e) || 'Failed to load form');
} finally {
setFormLoading(false);
}
};
}, [setError]);
useEffect(() => {
if (mode === 'manage' && selectedEventId && selectedEventId !== formLoadedFor) {
loadFormForEvent(selectedEventId);
}
}, [mode, selectedEventId]);
}, [mode, selectedEventId, formLoadedFor, loadFormForEvent]);
const saveForm = async () => {
if (!token || !selectedEventId) return;
setError(null); setInfo(null);
try {
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o: any) => String(o).trim()).filter(Boolean) : undefined }));
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o) => String(o).trim()).filter(Boolean) : undefined }));
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
setInfo('Form saved');
} catch (e:any) {
setError(e?.message || 'Failed to save form');
} catch (e) {
setError(errorMessage(e) || 'Failed to save form');
}
};
// Fill/edit responses state
const [fillEventId, setFillEventId] = useState<string>("");
const [fillRegistrations, setFillRegistrations] = useState<any[]>([]);
const [fillRegistrations, setFillRegistrations] = useState<Registration[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [fillRegistrationId, setFillRegistrationId] = useState<string>("");
const [fillRegistration, setFillRegistration] = useState<any | null>(null);
const [fillRegistration, setFillRegistration] = useState<Registration | null>(null);
const [fillForm, setFillForm] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [existingResponses, setExistingResponses] = useState<any[]>([]);
const [existingResponses, setExistingResponses] = useState<FormResponse[]>([]);
const [savingFill, setSavingFill] = useState(false);
const loadRegsForEvent = async (evId: string) => {
const loadRegsForEvent = useCallback(async (evId: string) => {
if (!token || !evId) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
const sorted = (Array.isArray(regs) ? regs : []).sort((a, b) => (a.user?.name || '').toLowerCase().localeCompare((b.user?.name || '').toLowerCase()));
setFillRegistrations(sorted);
} catch {
@@ -344,54 +355,48 @@ export default function FormsBrowserPage() {
} finally {
setLoadingRegs(false);
}
};
}, [token]);
useEffect(() => {
if (mode === 'fill') setFillEventId(prev => prev || selectedEventId || "");
}, [mode]);
}, [mode, selectedEventId]);
useEffect(() => {
if (mode !== 'fill') return;
if (fillEventId) loadRegsForEvent(fillEventId);
setFillRegistrationId(""); setFillRegistration(null); setExistingResponses([]); setFillForm({ isRequired: false, fields: [] });
}, [fillEventId, mode]);
}, [fillEventId, mode, loadRegsForEvent]);
useEffect(() => {
(async () => {
if (mode !== 'fill' || !fillRegistrationId) return;
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
setFillRegistration(reg);
if (reg?.event?.id) {
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.event.id)}`);
const f = evFull?.form;
if (f && Array.isArray(f.fields)) {
setFillForm({ isRequired: !!f.isRequired, fields: f.fields.map((x:any)=>({ id:x.id, type:x.type, label:x.label, isRequired:!!x.isRequired, order:x.order, helpText:x.helpText||'', options: x.options || undefined })) });
} else {
setFillForm({ isRequired: false, fields: [] });
}
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(reg.event.id)}`);
setFillForm(toFormDef(evFull?.form));
}
const resList = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const items = Array.isArray(resList?.items) ? resList.items : (Array.isArray(resList) ? resList : []);
setExistingResponses(items);
} catch (e:any) {
setError(e?.message || 'Failed to load registration/form');
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
setExistingResponses(responseItems(resList));
} catch (e) {
setError(errorMessage(e) || 'Failed to load registration/form');
}
})();
}, [fillRegistrationId, mode]);
}, [fillRegistrationId, mode, token, setError]);
const mainTicketCount = useMemo(() => {
const ros = fillRegistration?.registrationOptions || [];
return ros.filter((ro:any)=> ro?.eventOption?.isMainTicket).reduce((s:number, ro:any)=> s + (ro.quantity||0), 0);
return ros.filter((ro)=> ro?.eventOption?.isMainTicket).reduce((s, ro)=> s + (ro.quantity||0), 0);
}, [fillRegistration]);
const [editableResponses, setEditableResponses] = useState<{ answers: Record<string,string> }[]>([]);
useEffect(() => {
if (mode !== 'fill') return;
const fields = (fillForm.fields || []).filter(f => f.type !== 'statement' && f.type !== 'paragraph');
const mapped: { answers: Record<string,string> }[] = (existingResponses || []).map((r:any)=>{
const mapped: { answers: Record<string,string> }[] = (existingResponses || []).map((r)=>{
const ans: Record<string,string> = {};
(r.answers || []).forEach((a:any)=>{ if (a.fieldId) ans[a.fieldId] = String(a.value ?? ''); });
(r.answers || []).forEach((a)=>{ if (a.fieldId) ans[a.fieldId] = String(a.value ?? ''); });
for (const f of fields) if (!(f.id! in ans)) ans[f.id!] = '';
return { answers: ans };
});
@@ -408,7 +413,7 @@ export default function FormsBrowserPage() {
const setAnswer = (respIdx: number, fieldId: string, value: string) => {
setEditableResponses(prev => {
const copy = prev.slice();
if (!copy[respIdx]) copy[respIdx] = { answers: {} } as any;
if (!copy[respIdx]) copy[respIdx] = { answers: {} };
copy[respIdx] = { answers: { ...copy[respIdx].answers, [fieldId]: value } };
return copy;
});
@@ -431,11 +436,10 @@ export default function FormsBrowserPage() {
method: 'PUT', authToken: token, body: { responses: editableResponses }
});
setInfo('Responses saved');
const resList = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const items = Array.isArray(resList?.items) ? resList.items : (Array.isArray(resList) ? resList : []);
setExistingResponses(items);
} catch (e:any) {
setError(e?.message || 'Failed to save responses');
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
setExistingResponses(responseItems(resList));
} catch (e) {
setError(errorMessage(e) || 'Failed to save responses');
} finally {
setSavingFill(false);
}
@@ -573,7 +577,7 @@ export default function FormsBrowserPage() {
</div>
) : (
<ul className="divide-y print-form-wrapper">
{items.map((r: any) => (
{items.map((r) => (
<li key={r.id} className="print-page-break">
<div className="p-4 print-response">
{/* Header */}
@@ -593,7 +597,7 @@ export default function FormsBrowserPage() {
<div className="text-xs text-gray-400 italic">No answers submitted.</div>
) : (
<div className="grid sm:grid-cols-2 gap-2 print-answers">
{r.answers.map((a: any) => (
{r.answers.map((a) => (
<div key={a.id} className="bg-gray-50 border rounded p-2 print-answer">
<div className="text-[10px] text-gray-500 mb-0.5 print-answer-label">{a.field?.label || a.fieldId}</div>
<div className="font-medium text-sm break-words print-answer-value">{a.value}</div>
@@ -680,7 +684,7 @@ export default function FormsBrowserPage() {
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillRegistrationId} onChange={e => setFillRegistrationId(e.target.value)} disabled={!fillEventId || loadingRegs}>
<option value="">{loadingRegs ? 'Loading registrations…' : 'Select registration…'}</option>
{fillRegistrations.map((r:any)=>{
{fillRegistrations.map((r)=>{
const label = `${r.user?.name || r.userId} — ${r.user?.email || ''} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id}>{label}</option>;
})}
@@ -702,7 +706,7 @@ export default function FormsBrowserPage() {
<div key={idx} className="border rounded-lg p-3 bg-white shadow-sm">
<div className="text-sm font-semibold mb-3 text-gray-700">Attendee #{idx+1}</div>
<div className="grid sm:grid-cols-2 gap-3">
{fillForm.fields.filter(f=>f.type!=='statement' && f.type !== 'paragraph').map((f:any)=> (
{fillForm.fields.filter((f): f is typeof f & { id: string } => !!f.id && f.type!=='statement' && f.type !== 'paragraph').map((f)=> (
<div key={f.id}>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'text' && (
@@ -7,6 +7,8 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
@@ -21,7 +23,7 @@ export default function ManualRegistrationPage() {
const [phoneNumber, setPhoneNumber] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null);
const [createdReg, setCreatedReg] = useState<Registration | null>(null);
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
@@ -36,7 +38,7 @@ export default function ManualRegistrationPage() {
setBusy(true);
setError(null);
try {
const res = await apiFetch<any>("/api/registrations/manual", {
const res = await apiFetch<Registration>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
@@ -48,12 +50,12 @@ export default function ManualRegistrationPage() {
setCreatedReg(res);
// Load form definition for this event (if any)
try {
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(eventId)}`);
if (ev?.form) setForm(ev.form);
} catch {}
alert("Manual registration created");
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
} catch (e) {
setError(errorMessage(e) || "Failed to create manual registration");
} finally {
setBusy(false);
}
@@ -110,12 +112,14 @@ 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; }) {
type FormsData = Record<number, Record<string, string>>;
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: Registration; form: { isRequired: boolean; fields: FormField[] }; formsData: FormsData; setFormsData: React.Dispatch<React.SetStateAction<FormsData>>; }) {
const { token } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useDismissingState<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) => o?.eventOption?.isMainTicket).reduce((s, o) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets);
const canSubmit = React.useMemo(() => {
@@ -136,7 +140,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
}, [form, formsData, count]);
const update = (idx: number, fieldId: string, value: string) => {
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
setFormsData((prev) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
};
const submit = async () => {
@@ -144,14 +148,14 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
try {
setSubmitting(true);
setError(null); setInfo(null);
const payload = [] as any[];
const payload: { answers: Record<string, string> }[] = [];
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
method: 'POST', authToken: token, body: { responses: payload }
});
setInfo('Attendee forms submitted successfully.');
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
} catch (e) {
setError(errorMessage(e) || 'Failed to submit forms');
} finally {
setSubmitting(false);
}
@@ -9,35 +9,12 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { UserPlus, CreditCard } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import type { Event, EventOption, Registration, RegistrationOption, User } from "@/types";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
function effectiveOptionUnit(opt: any): number {
const base = opt.price || 0;
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => now < t.deadline)
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
return applicable.length > 0 ? applicable[0].price : base;
}
function effectiveVariantUnit(opt: any, variant: any): number {
const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0;
const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : [];
const variantTiers = allTiers.filter((t: any) => t.variantId === variant.id);
const tiers = variantTiers.length > 0 ? variantTiers : allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => now < t.deadline)
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
return applicable.length > 0 ? applicable[0].price : base;
}
function fmtPrice(n: number) {
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
}
@@ -45,13 +22,14 @@ function fmtPrice(n: number) {
// 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 {
function optionUnitPrice(opt: RegistrationOption): 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);
if (!eo) return 0;
if (opt.variant) return variantUnitNow(eo, opt.variant);
return optionUnitNow(eo);
}
// ─── Component ───────────────────────────────────────────────────────────────
@@ -70,12 +48,12 @@ export default function ManualRegistrationPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
const [options, setOptions] = useState<EventOption[]>([]);
// All system users for fuzzy lookup
const [allUsers, setAllUsers] = useState<any[]>([]);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
@@ -94,7 +72,7 @@ export default function ManualRegistrationPage() {
const [tab, setTab] = useState<"register" | "payment">("register");
// ── Record Payment tab state ─────────────────────────────────────────────
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
const [allRegistrations, setAllRegistrations] = useState<Registration[]>([]);
const [payUserId, setPayUserId] = useState<string>("");
const [payUserQuery, setPayUserQuery] = useState("");
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
@@ -119,7 +97,7 @@ export default function ManualRegistrationPage() {
// balances can be computed without a per-registration fetch loop).
useEffect(() => {
if (!token) return;
apiFetch<any[]>("/api/registrations", { authToken: token })
apiFetch<Registration[]>("/api/registrations", { authToken: token })
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
.catch(() => {});
}, [token]);
@@ -127,8 +105,8 @@ export default function ManualRegistrationPage() {
const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) {
const totalDue = r.totalDueComputed ?? (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);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
return map;
@@ -144,7 +122,7 @@ export default function ManualRegistrationPage() {
.map(x => x.u);
}, [payUserQuery, allUsers]);
const selectPayUser = (u: any) => {
const selectPayUser = (u: User) => {
setPayUserId(u.id);
setPayUserQuery(u.name || "");
setPayDropdownOpen(false);
@@ -165,9 +143,9 @@ export default function ManualRegistrationPage() {
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());
.filter((r) => String(r.userId || r.user?.id) === String(payUserId))
.filter((r) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [allRegistrations, payUserId, regOutstanding]);
const createManualPayment = async () => {
@@ -179,7 +157,7 @@ export default function ManualRegistrationPage() {
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiFetch<any>("/api/payments", {
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
@@ -196,11 +174,11 @@ export default function ManualRegistrationPage() {
setPayPaidAtLocal("");
// Refresh registrations so the displayed outstanding balance updates
try {
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
setAllRegistrations(Array.isArray(regs) ? regs : []);
} catch {}
} catch (e: any) {
setPayError(e?.message || "Failed to record payment");
} catch (e) {
setPayError(errorMessage(e) || "Failed to record payment");
} finally {
setPaySubmitting(false);
}
@@ -229,7 +207,7 @@ export default function ManualRegistrationPage() {
const selectedEventAllowsNames = !!selectedEv?.allowNamedAttendees || selectedIsYouth;
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const selectUser = (u: any) => {
const selectUser = (u: User) => {
setPickedUserId(u.id || null);
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
setUserQuery(u.name || "");
@@ -251,7 +229,7 @@ export default function ManualRegistrationPage() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter(ev => {
const t = new Date(ev.endDate).getTime();
@@ -261,7 +239,7 @@ export default function ManualRegistrationPage() {
});
active.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
setEvents(active);
} catch (e: any) {
} catch {
// ignore
}
})();
@@ -273,9 +251,9 @@ export default function ManualRegistrationPage() {
const opts = ev.options || ev.eventOptions || [];
setOptions(opts);
const map: Record<string, number> = {};
opts.forEach((o: any) => {
opts.forEach((o) => {
if ((o.variants || []).length > 0) {
(o.variants as any[]).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
(o.variants || []).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
} else {
map[o.id] = 0;
}
@@ -290,9 +268,9 @@ export default function ManualRegistrationPage() {
const totalDue = useMemo(() => {
return options.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants as any[]).reduce((vs: number, v: any) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
}
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
}, 0);
}, [options, quantities]);
@@ -319,7 +297,7 @@ export default function ManualRegistrationPage() {
const hasEmail = !!guest.email.trim();
const hasPhone = !!guest.phoneNumber.trim();
const resolvedPref = hasEmail && hasPhone ? notifPref : hasPhone ? "whatsapp" : "email";
const res = await apiFetch<any>("/api/registrations/manual", {
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
@@ -339,7 +317,7 @@ export default function ManualRegistrationPage() {
// 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)]);
setAllRegistrations(prev => [res, ...prev.filter((r) => r.id !== res.id)]);
setPayUserId(res.userId || res.user?.id || "");
setPayUserQuery(res.user?.name || guest.name || "");
setPayRegistrationId(res.id);
@@ -356,8 +334,8 @@ export default function ManualRegistrationPage() {
setAttendeeEntries({});
setDelivery("payer");
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
} catch (e) {
setError(errorMessage(e) || "Failed to create manual registration");
} finally {
setSubmitting(false);
}
@@ -558,8 +536,8 @@ export default function ManualRegistrationPage() {
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-800">
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span> : null}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
{(opt.variants || []).map((v) => {
const unit = variantUnitNow(opt, v);
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
@@ -586,7 +564,7 @@ export default function ManualRegistrationPage() {
</div>
);
}
const unit = effectiveOptionUnit(opt);
const unit = optionUnitNow(opt);
return (
<div key={opt.id} className="border rounded p-3">
<div className="font-medium text-sm">{opt.name}</div>
@@ -718,7 +696,7 @@ export default function ManualRegistrationPage() {
<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) => {
{regsForPayUser.map((r) => {
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>;
@@ -7,6 +7,8 @@ import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
type Report = {
@@ -28,7 +30,7 @@ export default function OfflineScansPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState("");
const [locks, setLocks] = useState<Lock[]>([]);
const [reports, setReports] = useState<Report[]>([]);
@@ -38,7 +40,7 @@ export default function OfflineScansPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
.then(evs => {
const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
setEvents(recent);
@@ -57,8 +59,8 @@ export default function OfflineScansPage() {
]);
setLocks(l);
setReports(r.filter(x => !onlyOpen || x.conflicts.length > 0 || x.afterForceRelease));
} catch (e: any) {
setError(e?.message || "Failed to load offline scanning");
} catch (e) {
setError(errorMessage(e) || "Failed to load offline scanning");
}
}, [token, eventId, onlyOpen, setError]);
useEffect(() => { load(); }, [load]);
@@ -68,13 +70,13 @@ export default function OfflineScansPage() {
const res = await apiFetch<{ message: string }>(`/api/scan-locks/${l.id}/force-release`, { method: "POST", authToken: token! });
setInfo(res.message);
load();
} catch (e: any) { setError(e?.message || "Failed"); }
} catch (e) { setError(errorMessage(e) || "Failed"); }
};
const review = async (r: Report) => {
try {
await apiFetch(`/api/scan-locks/reports/${r.id}/review`, { method: "POST", authToken: token! });
load();
} catch (e: any) { setError(e?.message || "Failed"); }
} catch (e) { setError(errorMessage(e) || "Failed"); }
};
return (
+13 -12
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
@@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -66,38 +67,38 @@ export default function SupervisorDashboardPage() {
// /api/stats/supervisor covers scan activity + today's payments (unchanged from before);
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint, shared
// with the admin dashboard since supervisors already have full revenue/donation visibility.
const [scanStats, setScanStats] = useStableState<any | null>(null);
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [scanStats, setScanStats] = useStableState<ScanStats | null>(null);
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
const [overview, setOverview] = useStableState<Overview | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const loadStats = async () => {
const loadStats = useCallback(async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const [data, overviewData] = await Promise.all([
apiFetch<any>("/api/stats/supervisor", { authToken: token }),
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
]);
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
setOverview(overviewData);
} catch (e) {
} catch {
// ignore
} finally {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
};
}, [token, setScanStats, setPaymentStats, setRecentScans, setOverview]);
useEffect(() => {
if (!token) return;
loadStats();
}, [token]);
}, [token, loadStats]);
// Poll every 15s while the tab is visible; pause in the background and refetch
// immediately on return instead of leaving stale numbers up.
@@ -184,7 +185,7 @@ export default function SupervisorDashboardPage() {
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
</div>
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
{recentScans.map((u: any) => (
{recentScans.map((u) => (
<li key={u.id} className="border rounded p-2">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
@@ -236,11 +237,11 @@ export default function SupervisorDashboardPage() {
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
{scanStats && scanStats.byStaff?.length > 0 && (
<div className="mt-4">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
{scanStats.byStaff.map((s) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
@@ -1,19 +1,22 @@
"use client";
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import type { Event, Payment, Registration, User } from "@/types";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Money stats/lists must count each real inflow exactly once, so legs are
// excluded — the money was already counted via the original donation row.
function isDonationLeg(p: any): boolean {
function isDonationLeg(p: Payment): boolean {
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
}
@@ -29,13 +32,27 @@ function refundMethodForOriginal(method: string | null | undefined): string {
return "";
}
type Outstanding = Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
// An unreconciled Yoco transaction (GET /api/yoco-transactions/unreconciled). `raw` is the
// webhook as Yoco sent it; its checkout metadata says who/what the payment was for.
type YocoTx = {
id: string;
amount: number;
checkoutId?: string | null;
externalId?: string | null;
methodType?: string | null;
createdDate?: string | null;
raw?: { payload?: { metadata?: { userId?: string; eventId?: string } } } | null;
};
function RegistrationOptions({ regs, regOutstanding }: {
regs: any[];
regs: Registration[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
}) {
return (
<>
{regs.map((r: any) => {
{regs.map((r) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — ${r.user?.name || r.userId || 'User'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return (
@@ -49,7 +66,7 @@ function RegistrationOptions({ regs, regOutstanding }: {
}
function UserSearchField({ allUsers, value, onChange, placeholder = "Search by name, email or phone…", disabled = false }: {
allUsers: any[];
allUsers: User[];
value: string;
onChange: (userId: string) => void;
placeholder?: string;
@@ -136,87 +153,59 @@ function PaymentsContent() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [payments, setPayments] = useState<any[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [registrations, setRegistrations] = useState<any[]>([]);
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
const [regOutstanding, setRegOutstanding] = useState<Outstanding>({});
const loadPayments = async () => {
const loadPayments = useCallback(async () => {
if (!token) return;
try {
setLoadingList(true);
const list = await fetchAllPayments(token);
setPayments(list.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
} catch (e: any) {
setError(e?.message || "Failed to load payments");
setPayments(list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
} catch (e) {
setError(errorMessage(e) || "Failed to load payments");
} finally {
setLoadingList(false);
}
};
}, [token, setError]);
useEffect(() => { loadPayments(); }, [token]);
// 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;
};
useEffect(() => { loadPayments(); }, [loadPayments]);
// Load registrations for dropdowns.
// GET /api/registrations already embeds each registration's `payments`, so outstanding
// balances are computed from that in one pass — no per-registration follow-up requests.
const loadRegistrations = async () => {
const loadRegistrations = useCallback(async () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
const list = Array.isArray(regs) ? regs : [];
const now = new Date();
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
const map: Outstanding = {};
for (const r of list) {
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
setRegOutstanding(map);
// Sort by createdAt desc
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
setRegistrations(sorted);
} catch (e) {
} catch {
// ignore
} finally {
setLoadingRegs(false);
}
};
}, [token]);
useEffect(() => { loadRegistrations(); }, [token]);
useEffect(() => { loadRegistrations(); }, [loadRegistrations]);
// Load all users for Refund section
useEffect(() => {
@@ -226,7 +215,7 @@ function PaymentsContent() {
setLoadingUsers(true);
const users = await fetchAllUsers(token);
setAllUsers(users);
} catch (e) {
} catch {
// ignore; fallback to derived users from registrations
} finally {
setLoadingUsers(false);
@@ -240,12 +229,12 @@ function PaymentsContent() {
const [registrationId, setRegistrationId] = useState<string>("");
const [isDonation, setIsDonation] = useState<boolean>(false);
const [eventId, setEventId] = useState<string>("");
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [submitting, setSubmitting] = useState(false);
// Backdate field
const [paidAtLocal, setPaidAtLocal] = useState<string>("");
// Users for Refund section (load all users with supervisor rights)
const [allUsers, setAllUsers] = useState<any[]>([]);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
// New: user selection states for split dropdowns
@@ -253,33 +242,12 @@ function PaymentsContent() {
// Derived lists for users and outstanding registrations
const outstandingRegs = useMemo(() => {
return registrations.filter((r: any) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
return registrations.filter((r) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
}, [registrations, regOutstanding]);
// Users list for UI dropdowns (use all users if loaded; fallback to outstanding registrations)
const usersList = useMemo(() => {
if (allUsers && allUsers.length > 0) {
return [...allUsers]
//.filter(u => u?.isActive !== false)
.map(u => ({ id: String(u.id), name: String(u.name || u.email || u.id) }))
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
}
const map = new Map<string, string>();
for (const r of outstandingRegs) {
const uid = r.user?.id || r.userId;
if (!uid) continue;
const name = r.user?.name || r.user?.email || String(r.userId || uid);
if (!map.has(String(uid))) {
map.set(String(uid), String(name));
}
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
}, [allUsers, outstandingRegs]);
const regsForSelectedUser = useMemo(() => {
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(selectedUserId)));
return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(selectedUserId)));
}, [outstandingRegs, selectedUserId]);
// Payment link tab state
@@ -292,7 +260,7 @@ function PaymentsContent() {
const [linkCopied, setLinkCopied] = useState(false);
const regsForLinkUser = useMemo(() => {
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(linkUserId)));
return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(linkUserId)));
}, [outstandingRegs, linkUserId]);
useEffect(() => {
@@ -327,8 +295,8 @@ function PaymentsContent() {
if (!res.redirectUrl) { setError("Failed to generate link"); return; }
setLinkResult({ redirectUrl: res.redirectUrl, amount: res.amount ?? amt, registrationId: linkRegistrationId });
setInfo("Payment link generated");
} catch (e: any) {
setError(e?.message || "Failed to generate payment link");
} catch (e) {
setError(errorMessage(e) || "Failed to generate payment link");
} finally {
setLinkGenerating(false);
}
@@ -356,8 +324,8 @@ function PaymentsContent() {
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
});
setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`);
} catch (e: any) {
setError(e?.message || `Failed to send link via ${channel}`);
} catch (e) {
setError(errorMessage(e) || `Failed to send link via ${channel}`);
} finally {
setLinkSending(null);
}
@@ -371,7 +339,7 @@ function PaymentsContent() {
// If a registration is preselected via query, infer and set its user
useEffect(() => {
if (!registrationId) return;
const reg = registrations.find((r: any) => String(r.id) === String(registrationId));
const reg = registrations.find((r) => String(r.id) === String(registrationId));
const uid = reg?.user?.id || reg?.userId;
if (uid) setSelectedUserId(String(uid));
}, [registrationId, registrations]);
@@ -387,7 +355,7 @@ function PaymentsContent() {
if (eventsIncludePast) params.set("includePast", "true");
if (eventsIncludeInactive) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
setEvents(evs || []);
} catch {}
})();
@@ -417,7 +385,7 @@ function PaymentsContent() {
}
try {
setSubmitting(true);
const res = await apiFetch<any>("/api/payments", {
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
@@ -433,8 +401,8 @@ function PaymentsContent() {
setInfo(`Payment created (R ${amt.toFixed(2)})`);
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
await Promise.all([loadPayments(), loadRegistrations()]);
} catch (e: any) {
setError(e?.message || "Failed to create payment");
} catch (e) {
setError(errorMessage(e) || "Failed to create payment");
} finally {
setSubmitting(false);
}
@@ -454,38 +422,38 @@ function PaymentsContent() {
// Yoco unreconciled list and actions
const [yocoLoading, setYocoLoading] = useState(false);
const [yocoTxs, setYocoTxs] = useState<any[]>([]);
const loadYocoUnreconciled = async () => {
const [yocoTxs, setYocoTxs] = useState<YocoTx[]>([]);
const loadYocoUnreconciled = useCallback(async () => {
if (!token) return;
try {
setYocoLoading(true);
const res = await apiFetch<any>("/api/yoco-transactions/unreconciled", { authToken: token });
const items = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
const res = await apiFetch<{ data?: YocoTx[] } | YocoTx[]>("/api/yoco-transactions/unreconciled", { authToken: token });
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
setYocoTxs(items);
} catch (e) {
} catch {
// ignore silently, shown on demand via error state if needed
} finally {
setYocoLoading(false);
}
};
useEffect(() => { loadYocoUnreconciled(); }, [token]);
}, [token]);
useEffect(() => { loadYocoUnreconciled(); }, [loadYocoUnreconciled]);
// Inline reconcile form state per-row
const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
const openReconcileToRegistration = (tx: any) => {
const openReconcileToRegistration = (tx: YocoTx) => {
const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : '';
setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false });
};
const openReconcileAsDonation = (tx: any) => {
const openReconcileAsDonation = (tx: YocoTx) => {
const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : '';
setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false });
};
const regsForUser = (uid: string) => registrations
.filter((r: any) => String(r.user?.id || r.userId) === String(uid))
.filter((r: any) => {
.filter((r) => String(r.user?.id || r.userId) === String(uid))
.filter((r) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
return out > 0.000001; // show only registrations with an outstanding balance
});
@@ -515,8 +483,8 @@ function PaymentsContent() {
}
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
} catch (e: any) {
setError(e?.message || 'Failed to reconcile');
} catch (e) {
setError(errorMessage(e) || 'Failed to reconcile');
} finally {
setReconcileForm(prev => ({ ...prev, submitting: false }));
}
@@ -618,8 +586,8 @@ function PaymentsContent() {
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
setInfo('Transaction ignored');
await loadYocoUnreconciled();
} catch (e:any) {
setError(e?.message || 'Failed to ignore');
} catch (e) {
setError(errorMessage(e) || 'Failed to ignore');
}
}}>Ignore</button>
</td>
@@ -637,7 +605,7 @@ function PaymentsContent() {
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.registrationId} onChange={e => setReconcileForm(prev => ({ ...prev, registrationId: e.target.value }))} disabled={!reconcileForm.userId}>
<option value="">Select registration…</option>
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r: any) => {
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r) => {
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>;
@@ -753,8 +721,7 @@ function PaymentsContent() {
<RefundSection
payments={payments}
allUsers={allUsers}
usersList={usersList}
regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))}
regsForUser={(uid:string)=> registrations.filter((r)=> String(r.user?.id||r.userId)===String(uid))}
regOutstanding={regOutstanding}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
/>
@@ -875,15 +842,14 @@ function PaymentsContent() {
}
type RefundSectionProps = {
payments: any[];
allUsers: any[];
usersList: { id: string; name: string }[];
regsForUser: (userId: string) => any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
payments: Payment[];
allUsers: User[];
regsForUser: (userId: string) => Registration[];
regOutstanding: Outstanding;
onDone: () => void | Promise<void>;
};
function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
const { token } = useAuth();
const [userId, setUserId] = useState<string>("");
const [target, setTarget] = useState<'payment'|'registration'>('payment');
@@ -896,7 +862,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
const [err, setErr] = useState<string | null>(null);
const paymentsForUser = useMemo(() => {
if (!userId) return [] as any[];
if (!userId) return [];
return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0);
}, [payments, userId]);
@@ -946,8 +912,8 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
});
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
await onDone();
} catch (e: any) {
setErr(e?.message || 'Failed to create refund');
} catch (e) {
setErr(errorMessage(e) || 'Failed to create refund');
} finally {
setSubmitting(false);
}
@@ -970,7 +936,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
<label className="text-xs text-gray-600 mt-2">Payment</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={paymentId} onChange={e => setPaymentId(e.target.value)} disabled={!userId}>
<option value="">Select payment…</option>
{paymentsForUser.map((p:any)=>{
{paymentsForUser.map((p)=>{
const label = `R ${(p.amount||0).toFixed(2)} — ${p.registrationId ? `Registration ${String(p.registrationId).slice(0,8)}` : (p.event?.title || p.eventId || 'Event')} — #${String(p.id).slice(0,8)}`;
return <option key={p.id} value={p.id} title={label}>{label}</option>;
})}
@@ -981,7 +947,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
<label className="text-xs text-gray-600 mt-2">Registration</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={registrationId} onChange={e => setRegistrationId(e.target.value)} disabled={!userId}>
<option value="">Select registration…</option>
{regs.map((r:any)=>{
{regs.map((r)=>{
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>;
@@ -1003,7 +969,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
<option value="eft-refund">EFT refund</option>
<option value="voucher-refund">Voucher refund</option>
</select>
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded — this is what nets against that method's total in reports.</div>
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded — this is what nets against that method&apos;s total in reports.</div>
</div>
</div>
<label className="block text-xs text-gray-600 mt-2">Reason (optional)</label>
@@ -1019,7 +985,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
// stats both deliberately filter them out (they're not new money, see isDonationLeg's comment) —
// so this is the only place staff can see and reverse an assignment.
type AssignedDonationsListProps = {
payments: any[];
payments: Payment[];
onDone: () => void | Promise<void>;
};
@@ -1033,12 +999,12 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
const allLegs = useMemo(() => {
return payments
.filter(isDonationLeg)
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [payments]);
const donationById = useMemo(() => {
const m = new Map<string, any>();
payments.forEach((p: any) => { if (p.isDonation) m.set(p.id, p); });
const m = new Map<string, Payment>();
payments.forEach((p) => { if (p.isDonation) m.set(p.id, p); });
return m;
}, [payments]);
@@ -1046,7 +1012,7 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
// never shows an event with nothing to filter down to.
const eventOptions = useMemo(() => {
const m = new Map<string, string>();
allLegs.forEach((leg: any) => {
allLegs.forEach((leg) => {
const ev = leg.registration?.event;
if (ev?.id) m.set(String(ev.id), ev.title || String(ev.id));
});
@@ -1055,10 +1021,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
const legs = useMemo(() => {
const q = query.trim().toLowerCase();
return allLegs.filter((leg: any) => {
return allLegs.filter((leg) => {
if (eventFilter && String(leg.registration?.eventId || leg.registration?.event?.id) !== eventFilter) return false;
if (!q) return true;
const donation = donationById.get(leg.originalPaymentId);
const donation = leg.originalPaymentId ? donationById.get(leg.originalPaymentId) : undefined;
const haystack = [
leg.registration?.user?.name,
leg.registration?.user?.email,
@@ -1072,7 +1038,7 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
});
}, [allLegs, donationById, query, eventFilter]);
const unassign = async (leg: any) => {
const unassign = async (leg: Payment) => {
if (!token) return;
setErr(null);
const ok = window.confirm("Unassign this donation? The registration's balance will increase, and any tickets issued only because this payment completed it may be revoked.");
@@ -1085,8 +1051,8 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
body: { legId: leg.id }
});
await onDone();
} catch (e: any) {
setErr(e?.message || 'Failed to unassign donation');
} catch (e) {
setErr(errorMessage(e) || 'Failed to unassign donation');
} finally {
setUnassigningId(null);
}
@@ -1120,8 +1086,8 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
<div className="text-sm text-gray-500">No assigned donations match your search.</div>
) : (
<ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2">
{legs.map((leg: any) => {
const donation = donationById.get(leg.originalPaymentId);
{legs.map((leg) => {
const donation = leg.originalPaymentId ? donationById.get(leg.originalPaymentId) : undefined;
return (
<li key={leg.id} className="border rounded p-2">
<div className="flex justify-between items-start gap-2">
@@ -1153,10 +1119,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
}
type DonationAssignSectionProps = {
payments: any[];
allUsers: any[];
registrations: any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
payments: Payment[];
allUsers: User[];
registrations: Registration[];
regOutstanding: Outstanding;
onDone: () => void | Promise<void>;
};
@@ -1180,8 +1146,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
// Registrations for the selected user that can actually receive a donation: an outstanding
// balance to apply it to, and an event that isn't closed.
const regsForUser = useMemo(() => {
if (!userId) return [] as any[];
return registrations.filter((r: any) => {
if (!userId) return [];
return registrations.filter((r) => {
if (String(r.user?.id || r.userId) !== String(userId)) return false;
if (r.status === 'cancelled') return false;
if (r.event?.cashupStatus === 'closed') return false;
@@ -1191,7 +1157,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
}, [registrations, regOutstanding, userId]);
const selectedRegistration = useMemo(
() => registrations.find((r: any) => String(r.id) === String(registrationId)),
() => registrations.find((r) => String(r.id) === String(registrationId)),
[registrations, registrationId]
);
@@ -1204,7 +1170,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
// adding the refund back on top of what's left to allocate).
const legsById = useMemo(() => {
const m = new Map<string, number>();
payments.forEach((p: any) => {
payments.forEach((p) => {
if (p.originalPaymentId && !p.isDonation) {
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + Math.abs(p.amount || 0));
}
@@ -1215,9 +1181,9 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
// Donations "relevant to that event" — donations logged against the same event as the
// chosen registration that still have a remaining, unused balance.
const donationsForEvent = useMemo(() => {
if (!selectedRegistration) return [] as any[];
if (!selectedRegistration) return [];
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
return payments.filter((p: any) => {
return payments.filter((p) => {
if (!p.isDonation || p.registrationId) return false;
if (String(p.eventId) !== String(eventId)) return false;
if (sponsorshipsOnly && !p.isSponsorship) return false;
@@ -1227,7 +1193,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
}, [payments, selectedRegistration, legsById, sponsorshipsOnly]);
const selectedDonation = useMemo(
() => payments.find((p: any) => String(p.id) === String(paymentId)),
() => payments.find((p) => String(p.id) === String(paymentId)),
[payments, paymentId]
);
@@ -1269,8 +1235,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
});
setUserId(""); setRegistrationId(""); setPaymentId(""); setAmountStr("");
await onDone();
} catch (e: any) {
setErr(e?.message || 'Failed to assign donation');
} catch (e) {
setErr(errorMessage(e) || 'Failed to assign donation');
} finally {
setAssigning(false);
}
@@ -1306,7 +1272,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
disabled={!registrationId}
>
<option value="">Select donation…</option>
{donationsForEvent.map((p: any) => {
{donationsForEvent.map((p) => {
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
const donor = p.isSponsorship ? `Sponsorship: ${p.sponsorAnonymous ? 'anonymous' : (p.sponsorName || p.user?.name || 'sponsor')}` : (p.user?.name || p.userId || 'Donor');
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${donor} — #${String(p.id).slice(0,8)}`;
@@ -8,6 +8,8 @@ import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
type Entry = {
id: string;
@@ -43,7 +45,7 @@ export default function WaitlistPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState(search.get("eventId") || "");
const [entries, setEntries] = useState<Entry[]>([]);
const [busy, setBusy] = useState(false);
@@ -52,7 +54,7 @@ export default function WaitlistPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<any[]>("/api/events/all", { authToken: token })
apiFetch<Event[]>("/api/events/all", { authToken: token })
.then(evs => {
const withList = (evs || []).filter(e => e.waitlistEnabled);
setEvents(withList);
@@ -67,22 +69,22 @@ export default function WaitlistPage() {
setBusy(true);
try {
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
} catch (e: any) {
setError(e?.message || "Failed to load the waitlist");
} catch (e) {
setError(errorMessage(e) || "Failed to load the waitlist");
} finally {
setBusy(false);
}
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
const act = async (path: string, body?: any) => {
const act = async (path: string, body?: { direction: "up" | "down" }) => {
setBusy(true);
try {
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
if (res?.message) setInfo(res.message);
await load();
} catch (e: any) {
setError(e?.message || "Something went wrong");
} catch (e) {
setError(errorMessage(e) || "Something went wrong");
} finally {
setBusy(false);
}
@@ -1,16 +1,21 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
import { errorMessage } from "@/lib/utils";
import type { Event, MessageSendResult, Registration, User } from "@/types";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
type UserEntry = { id: string; name: string; phone: string; pref: string };
type TemplateKey = "custom" | "payment_reminder" | "event_reminder" | "tickets";
type StatusFilter = "any" | "paid" | "unpaid" | "partial_paid" | "cancelled";
type MessageFilter = { status?: Exclude<StatusFilter, "any">; attendeeIds?: string[]; allocationGroupId?: string };
const WA_TAB_ICONS: Record<"attendees" | "automations" | "broadcasts" | "scheduled", LucideIcon> = {
attendees: Users,
@@ -160,16 +165,14 @@ function AttendeesCheckboxDropdown({
// ─── Preference warning banner ────────────────────────────────────────────────
function PrefWarning({ attendees, selectedIds, channel }: { attendees: (Attendee|UserEntry)[]; selectedIds: string[]; channel: "whatsapp" | "email" }) {
const prefMatch = (pref: string) =>
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
const mismatched = useMemo(
() => selectedIds.filter((id) => {
const mismatched = useMemo(() => {
const prefMatch = (pref: string) =>
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
return selectedIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && !prefMatch(a.pref);
}),
[attendees, selectedIds, channel]
);
});
}, [attendees, selectedIds, channel]);
if (mismatched.length === 0) return null;
return (
@@ -188,9 +191,9 @@ function PrefWarning({ attendees, selectedIds, channel }: { attendees: (Attendee
}
// ─── WhatsApp Automations Panel ───────────────────────────────────────────────
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[]; token?: string | null; onInfo: (s: string | null) => void; onError: (s: string | null) => void }) {
const [autoEventId, setAutoEventId] = React.useState<string>("");
const currentEvent = React.useMemo(() => (events || []).find((e: any) => e.id === autoEventId), [events, autoEventId]);
const currentEvent = React.useMemo(() => (events || []).find((e) => e.id === autoEventId), [events, autoEventId]);
const preWeekDefault = `Hi {{name}}\n\nJust a friendly reminder that {{event.title}} is one week away!\n\nEvent details: {{event.link}}`;
const finalDefault = `Hi {{name}}\n\nFinal reminder for {{event.title}}.\nPlease have your QR code/ticket ready at entry.\n\nEvent details: {{event.link}}`;
@@ -230,10 +233,10 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
const onSchedule = async () => {
try {
onError(null as any); onInfo(null as any);
onError(null); onInfo(null);
if (!token) { onError("Not authenticated"); return; }
if (!autoEventId) { onError("Please select an event"); return; }
const jobs: any[] = [];
const jobs: { message: string; scheduledAt: string; promoEventId?: string }[] = [];
if (enablePre && preBody.trim() && preWhen) jobs.push({ message: preBody, scheduledAt: new Date(preWhen).toISOString() });
if (enableFinal && finalBody.trim() && finalWhen) jobs.push({ message: finalBody, scheduledAt: new Date(finalWhen).toISOString() });
if (enableThanks && thanksBody.trim() && thanksWhen) jobs.push({ message: thanksBody, scheduledAt: new Date(thanksWhen).toISOString() });
@@ -248,8 +251,8 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
scheduled++;
}
onInfo(`Scheduled ${scheduled} WhatsApp automation(s).`);
} catch (e: any) {
onError(e?.message || "Failed to schedule automations");
} catch (e) {
onError(errorMessage(e) || "Failed to schedule automations");
}
};
@@ -259,7 +262,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={(e) => setAutoEventId(e.target.value)}>
<option value="">Select an event…</option>
{(events || []).map((ev: any) => (
{(events || []).map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
@@ -286,7 +289,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
<input type="checkbox" checked={enableFinal} onChange={(e) => setEnableFinal(e.target.checked)} /> Enable
</label>
<label className="text-xs text-gray-600">Hours before:</label>
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={(e) => setFinalHours(e.target.value as any)}>
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={(e) => setFinalHours(e.target.value as "24" | "48")}>
<option value="24">24</option>
<option value="48">48</option>
</select>
@@ -324,7 +327,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={(e) => setPromoEventId(e.target.value)}>
<option value="">Select event to promote…</option>
{(events || []).map((ev: any) => (
{(events || []).map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
@@ -372,7 +375,7 @@ function WhatsAppAttendeesPageInner() {
const [info, setInfo] = useDismissingState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
@@ -385,24 +388,26 @@ function WhatsAppAttendeesPageInner() {
const loadEvents = async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
setAllEvents((evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (e: any) {
setError(e?.message || "Failed to load events");
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
setAllEvents((evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
} finally {
setLoadingEvents(false);
}
};
loadEvents();
}, [user, token]);
}, [user, token, setError]);
// ── Attendees tab ──────────────────────────────────────────────────────────
const [eventId, setEventId] = useState<string>(preselectEventId);
useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]);
const [templateKey, setTemplateKey] = useState<"custom" | "payment_reminder" | "event_reminder" | "tickets">("custom");
const [templateKey, setTemplateKey] = useState<TemplateKey>("custom");
const [message, setMessage] = useState("");
const [messageDirty, setMessageDirty] = useState(false);
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
// Set once the supervisor edits the message, so picking a template doesn't overwrite it.
// A ref, not state: it only gates the template effect below and is never rendered.
const messageDirtyRef = useRef(false);
const [status, setStatus] = useState<StatusFilter>("any");
const [groupId, setGroupId] = useState("");
const [attendees, setAttendees] = useState<Attendee[]>([]);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
@@ -418,11 +423,11 @@ function WhatsAppAttendeesPageInner() {
useEffect(() => {
const title = currentEvent?.title || "the event";
if (templateKey === "payment_reminder") {
if (!messageDirty) setMessage(`Hi {{name}}\n\nFriendly reminder: you have an outstanding balance for ${title}.\n\nPlease settle your balance to secure your tickets. Thank you!`);
if (!messageDirtyRef.current) setMessage(`Hi {{name}}\n\nFriendly reminder: you have an outstanding balance for ${title}.\n\nPlease settle your balance to secure your tickets. Thank you!`);
} else if (templateKey === "event_reminder") {
if (!messageDirty) setMessage(`Hi {{name}}\n\nA quick reminder about ${title}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`);
if (!messageDirtyRef.current) setMessage(`Hi {{name}}\n\nA quick reminder about ${title}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`);
} else if (templateKey === "tickets") {
setMessageDirty(false); setMessage("");
messageDirtyRef.current = false; setMessage("");
}
}, [templateKey, currentEvent]);
@@ -431,10 +436,10 @@ function WhatsAppAttendeesPageInner() {
try {
setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]);
if (!eventId || !token) return;
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const uniq = new Map<string, Attendee>();
(regs || []).forEach((r: any) => {
const u = r?.user;
(regs || []).forEach((r) => {
const u: User | undefined = r?.user;
if (u?.id && u?.phoneNumber && !u.email?.endsWith("@guest.local") && u.isActive !== false) {
uniq.set(u.id, { id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" });
}
@@ -450,7 +455,7 @@ function WhatsAppAttendeesPageInner() {
}, [eventId, token]);
const buildPayload = (dryRun?: boolean) => {
const payload: any = {
const payload: { filter: MessageFilter; template: TemplateKey; dryRun?: boolean; message?: string } = {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey,
dryRun: dryRun || undefined,
@@ -465,15 +470,15 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s) with a phone number.`);
} catch (e: any) { setError(e?.message || "Failed to preview"); }
} catch (e) { setError(errorMessage(e) || "Failed to preview"); }
};
const resetAttendeesForm = () => {
setTemplateKey("custom"); setMessage(""); setMessageDirty(false);
setTemplateKey("custom"); setMessage(""); messageDirtyRef.current = false;
setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null);
setSelectedAttendeeIds(attendees.map((a) => a.id));
};
@@ -485,10 +490,10 @@ function WhatsAppAttendeesPageInner() {
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
} catch (e) { setError(errorMessage(e) || "Failed to send"); } finally { setSending(false); }
};
const onScheduleSend = async () => {
@@ -499,15 +504,15 @@ function WhatsAppAttendeesPageInner() {
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
if (!scheduledAtLocal) { setError("Scheduled time is required"); return; }
const whenIso = new Date(scheduledAtLocal).toISOString();
const payload: any = {
const payload: { message?: string; filter: MessageFilter; template: TemplateKey; scheduledAt: string } = {
message: message || undefined,
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, scheduledAt: whenIso,
};
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to schedule"); }
} catch (e) { setError(errorMessage(e) || "Failed to schedule"); }
};
// ── Broadcasts tab ─────────────────────────────────────────────────────────
@@ -528,9 +533,9 @@ function WhatsAppAttendeesPageInner() {
setLoadingUsers(true);
const allUsers = await fetchAllUsers(token);
const mapped = allUsers
.filter((u: any) => u.isActive !== false && !u.email?.endsWith("@guest.local") && u.phoneNumber)
.map((u: any) => ({ id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" }))
.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
.filter((u) => u.isActive !== false && !u.email?.endsWith("@guest.local") && u.phoneNumber)
.map((u) => ({ id: u.id, name: u.name || "", phone: u.phoneNumber || "", pref: u.notificationPreference || "email" }))
.sort((a, b) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
setUsers(mapped);
} catch { } finally { setLoadingUsers(false); }
};
@@ -552,30 +557,30 @@ function WhatsAppAttendeesPageInner() {
const [editWhen, setEditWhen] = useState<string>("");
const [savingEdit, setSavingEdit] = useState(false);
const loadScheduled = async () => {
const loadScheduled = useCallback(async () => {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch { } finally { setLoadingScheduled(false); }
};
}, [token]);
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, token]);
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, loadScheduled]);
const saveEdit = async () => {
if (!editing) return;
try {
setSavingEdit(true);
if (!token) { setError("Not authenticated"); return; }
const body: any = {};
const body: { scheduledAt?: string; message?: string } = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.message = editMessage;
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
setInfo("Scheduled message updated.");
setEditing(null);
loadScheduled();
} catch (e: any) { setError(e?.message || "Failed to update"); } finally { setSavingEdit(false); }
} catch (e) { setError(errorMessage(e) || "Failed to update"); } finally { setSavingEdit(false); }
};
const removeJob = async (job: ScheduledJob) => {
@@ -584,7 +589,7 @@ function WhatsAppAttendeesPageInner() {
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: "DELETE", authToken: token });
setInfo("Scheduled message removed.");
loadScheduled();
} catch (e: any) { setError(e?.message || "Failed to remove"); }
} catch (e) { setError(errorMessage(e) || "Failed to remove"); }
};
// ── Render ─────────────────────────────────────────────────────────────────
@@ -663,7 +668,7 @@ function WhatsAppAttendeesPageInner() {
Info
</button>
</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={(e) => setTemplateKey(e.target.value as any)}>
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={(e) => setTemplateKey(e.target.value as TemplateKey)}>
<option value="custom">Custom message</option>
<option value="payment_reminder">Payment reminder</option>
<option value="event_reminder">Event reminder</option>
@@ -693,7 +698,7 @@ function WhatsAppAttendeesPageInner() {
<li><code>{"{{event.title}}"}</code> — the event title.</li>
<li><code>{"{{event.start}}"}</code> — the event start date/time.</li>
<li><code>{"{{balance}}"}</code> — outstanding balance for the event.</li>
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee&apos;s outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="text-[11px] text-gray-500 mt-2">
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
@@ -709,7 +714,7 @@ function WhatsAppAttendeesPageInner() {
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Payment status filter</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value as any)}>
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value as StatusFilter)}>
<option value="any">Any</option>
<option value="paid">Paid</option>
<option value="unpaid">Unpaid (pending or partial)</option>
@@ -724,7 +729,7 @@ function WhatsAppAttendeesPageInner() {
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={message}
onChange={(e) => { setMessage(e.target.value); setMessageDirty(true); }}
onChange={(e) => { setMessage(e.target.value); messageDirtyRef.current = true; }}
placeholder="Write your WhatsApp message to attendees..." />
</div>
) : (
@@ -837,11 +842,11 @@ function WhatsAppAttendeesPageInner() {
try {
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
const res = await apiFetch(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
} catch (e: any) { setError(e?.message || "Failed to preview"); }
} catch (e) { setError(errorMessage(e) || "Failed to preview"); }
}}>Preview recipients</button>
<button type="button" className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={async () => {
@@ -849,10 +854,10 @@ function WhatsAppAttendeesPageInner() {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const res = await apiFetch(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
resetBroadcastForm();
} catch (e: any) { setError(e?.message || "Failed to send"); }
} catch (e) { setError(errorMessage(e) || "Failed to send"); }
}}>Send now</button>
<button type="button" disabled={!broadcastScheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={async () => {
@@ -861,10 +866,10 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const res = await apiFetch(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
resetBroadcastForm();
} catch (e: any) { setError(e?.message || "Failed to schedule broadcast"); }
} catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); }
}}>Schedule send</button>
{broadcastPreviewCount != null && <span className="text-xs text-gray-600">Preview: {broadcastPreviewCount} recipient(s)</span>}
@@ -4,10 +4,12 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { HandHeart } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
export default function DonatePage() {
const { token } = useAuth();
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
const [loading, setLoading] = useState(false);
@@ -17,14 +19,14 @@ export default function DonatePage() {
useEffect(() => {
(async () => {
try {
const evs = await apiFetch<any[]>("/api/events");
const evs = await apiFetch<Event[]>("/api/events");
setEvents(evs);
if (evs.length > 0) setEventId(evs[0].id);
} catch (e: any) {
setError(e?.message || "Failed to load events");
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
}
})();
}, []);
}, [setError]);
const submit = async () => {
if (!token) { setError("Please login"); return; }
@@ -49,8 +51,8 @@ export default function DonatePage() {
});
setInfo("Redirecting to payment...");
window.location.href = res.redirectUrl;
} catch (e: any) {
setError(e?.message || "Failed to start donation checkout");
} catch (e) {
setError(errorMessage(e) || "Failed to start donation checkout");
} finally {
setLoading(false);
}
+19 -17
View File
@@ -6,6 +6,8 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
@@ -16,7 +18,7 @@ function FormsContent() {
const { token } = useAuth();
const registrationId = search.get("registrationId");
const [registration, setRegistration] = useState<any | null>(null);
const [registration, setRegistration] = useState<Registration | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
@@ -32,22 +34,22 @@ function FormsContent() {
setError(null);
setInfo(null);
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
if (ev?.form) setEventForm(ev.form);
// Load any saved draft
try {
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
const draft = await apiFetch<{ data?: Record<number, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
} catch {}
} catch (e: any) {
setError(e?.message || 'Failed to load registration');
} catch (e) {
setError(errorMessage(e) || 'Failed to load registration');
} finally {
setLoading(false);
}
})();
}, [token, registrationId]);
}, [token, registrationId, setError, setInfo]);
const submittedCount = useMemo(() => {
return (registration?.formResponses || []).length;
@@ -57,8 +59,8 @@ function FormsContent() {
if (!registration) return 0;
try {
return (registration.registrationOptions || [])
.filter((ro: any) => ro.eventOption?.isMainTicket)
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
.filter((ro) => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
} catch {
return 0;
}
@@ -88,7 +90,7 @@ function FormsContent() {
if (!token || !registrationId) return;
try {
setError(null); setInfo(null);
const payload: any[] = [];
const payload: { answers: Record<string, string> }[] = [];
for (let i = 0; i < remaining; i++) {
payload.push({ answers: formsData[i] || {} });
}
@@ -103,13 +105,13 @@ function FormsContent() {
});
setInfo('Attendee forms submitted successfully.');
// reload registration to reflect new responses
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
setFormsData({});
// On success, redirect to the user dashbaord
router.push('/dashboard/user');
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
} catch (e) {
setError(errorMessage(e) || 'Failed to submit forms');
}
};
@@ -123,8 +125,8 @@ function FormsContent() {
body: { data: formsData }
});
setInfo('Draft saved. You can return later to finish.');
} catch (e: any) {
setError(e?.message || 'Failed to save draft');
} catch (e) {
setError(errorMessage(e) || 'Failed to save draft');
}
};
@@ -159,14 +161,14 @@ function FormsContent() {
<div className="border rounded p-3 mb-4 bg-gray-50">
<div className="text-sm font-medium mb-2">Submitted responses</div>
<ul className="space-y-2">
{(registration.formResponses || []).map((resp: any, idx: number) => (
{(registration.formResponses || []).map((resp, idx) => (
<li key={resp.id} className="bg-white border rounded p-2">
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
{(resp.answers || []).length === 0 ? (
<div className="text-xs text-gray-600">No answers recorded.</div>
) : (
<ul className="text-xs list-disc pl-5 space-y-0.5">
{resp.answers.map((a: any) => (
{(resp.answers || []).map((a) => (
<li key={a.id}>
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
</li>
+116 -137
View File
@@ -12,16 +12,22 @@ import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
import { MyWaitlists } from "@/components/shared/MyWaitlists";
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
// Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
const isFuture = (d: string | Date) => new Date(d).getTime() > Date.now();
const isEventOver = (ev: any) =>
type EventStatusFields = Pick<Event, "endDate" | "cashupStatus" | "isActive">;
type Billing = { totalDue: number; totalPaid: number; outstanding: number; payments: Payment[] };
const isEventOver = (ev: EventStatusFields | null | undefined) =>
!!ev && ((ev.endDate && new Date(ev.endDate).getTime() < Date.now()) || ev.cashupStatus === 'closed');
// Status badge for an event, shown wherever an event title is displayed on this page.
// Precedence when more than one applies: Closed > Past > Inactive.
function EventStatusBadge({ event }: { event: any }) {
function EventStatusBadge({ event }: { event: EventStatusFields | null | undefined }) {
if (!event) return null;
let label: string | null = null;
let className = '';
@@ -53,26 +59,26 @@ function RegistrationStatusBadge({ status }: { status: string }) {
// 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
// a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx.
function effectiveEventOptionUnitPrice(eo: any, variantId: string | null): number {
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null): number {
const variant = variantId ? (eo?.variants || []).find((v) => v.id === variantId) : null;
const base = (variant && variant.price !== null && variant.price !== undefined)
? Number(variant.price)
: Number(eo?.price || 0);
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers : [];
const tiers = variantId ? allTiers.filter((t: any) => t.variantId === variantId) : allTiers.filter((t: any) => !t.variantId);
const tiers = variantId ? allTiers.filter((t) => t.variantId === variantId) : allTiers.filter((t) => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t: any) => ({ ...t, _d: new Date(t.deadline) }))
.filter((t: any) => now < t._d)
.sort((a: any, b: any) => a.price - b.price || a._d.getTime() - b._d.getTime());
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
.filter((t) => now < t._d)
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
return applicable.length > 0 ? applicable[0].price : base;
}
export default function UserDashboardPage() {
const { token, user } = useAuth();
const [registrations, setRegistrations] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]);
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
@@ -81,7 +87,7 @@ export default function UserDashboardPage() {
const [showPast, setShowPast] = useState<boolean>(false);
// Billing map: regId -> { totalDue, totalPaid, outstanding, payments }
const [billing, setBilling] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }>>({});
const [billing, setBilling] = useState<Record<string, Billing>>({});
// Forms status cache for registrations
// regId -> { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }
@@ -99,35 +105,6 @@ export default function UserDashboardPage() {
// Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | 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;
// Use variant price as base if available, otherwise option base
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() : [];
// Filter: variant options use only their variant-specific tiers; plain options use option-level tiers
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;
};
useEffect(() => {
(async () => {
@@ -139,29 +116,29 @@ export default function UserDashboardPage() {
// waterfalling — this was previously fetched only after registrations + all
// their payments had already resolved.
const [myRegs, myTicks] = await Promise.all([
apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
apiFetch<any[]>("/api/tickets/mytickets", { authToken: token }),
apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
]);
setRegistrations(myRegs);
setTickets(myTicks);
// Use the backend's tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
const totals: Record<string, Billing> = {};
for (const r of myRegs) {
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
}
setBilling(totals);
// Fetch payments per registration to compute outstanding
await Promise.all(
myRegs.map(async (r: any) => {
myRegs.map(async (r) => {
try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
// totalDueComputed is not time-dependent, no need to recompute per payment time
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
setBilling(prev => ({
...prev,
@@ -178,13 +155,13 @@ export default function UserDashboardPage() {
}
})
);
} catch (e: any) {
setError(e?.message || "Failed to load data");
} catch (e) {
setError(errorMessage(e) || "Failed to load data");
} finally {
setLoading(false);
}
})();
}, [token, showPast]);
}, [token, showPast, setError]);
// After registrations load, compute forms status for each registration.
// `myregistrations` now embeds event.form (with fields) directly, so this no longer
@@ -195,7 +172,7 @@ export default function UserDashboardPage() {
if (!token || registrations.length === 0) return;
const next: Record<string, { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }> = {};
await Promise.all(
registrations.map(async (r: any) => {
registrations.map(async (r) => {
try {
const ev = r.event;
const hasForm = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
@@ -204,12 +181,12 @@ export default function UserDashboardPage() {
return;
}
// Load registration to get number of submitted responses
const regFull = await apiFetch<any>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
const regFull = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
const submittedCount = (regFull?.formResponses || []).length;
// Required count equals the number of main tickets purchased
const requiredCount = (r.registrationOptions || [])
.filter((ro: any) => ro.eventOption?.isMainTicket)
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
.filter((ro) => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
const complete = submittedCount >= requiredCount && requiredCount > 0 ? true : (submittedCount >= requiredCount);
next[r.id] = {
hasForm: true,
@@ -218,7 +195,7 @@ export default function UserDashboardPage() {
requiredCount,
complete,
};
} catch (e) {
} catch {
// On any error, do not show forms status to avoid noisy UI
next[r.id] = { hasForm: false };
}
@@ -245,16 +222,16 @@ export default function UserDashboardPage() {
// Open loading dialog immediately
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
body: { ticketIds },
authToken: token,
});
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) {
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to email tickets");
setError(errorMessage(e) || "Failed to email tickets");
}
};
@@ -265,16 +242,16 @@ export default function UserDashboardPage() {
setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
body: { ticketIds, channel: "whatsapp" },
authToken: token,
});
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) sent to WhatsApp.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) {
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send tickets to WhatsApp");
setError(errorMessage(e) || "Failed to send tickets to WhatsApp");
}
};
@@ -288,15 +265,15 @@ export default function UserDashboardPage() {
loadingSubtitle: "Please wait while we prepare your invoice.",
});
try {
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
method: "POST",
body: { channel },
authToken: token,
});
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
} catch (e: any) {
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send invoice");
setError(errorMessage(e) || "Failed to send invoice");
}
};
@@ -310,15 +287,15 @@ export default function UserDashboardPage() {
loadingSubtitle: "Please wait while we prepare your receipt.",
});
try {
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
} catch (e: any) {
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send receipt");
setError(errorMessage(e) || "Failed to send receipt");
}
};
@@ -347,10 +324,10 @@ export default function UserDashboardPage() {
// 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 r = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const pays = await apiFetch<Payment[]>(`/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);
const totalDue = (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
} catch {}
return;
@@ -361,14 +338,14 @@ export default function UserDashboardPage() {
return;
}
window.location.href = res.redirectUrl;
} catch (e: any) {
} catch (e) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to create checkout");
setError(errorMessage(e) || "Failed to create checkout");
}
};
// Print helpers
const buildTicketHtmlCard = (t: any, buyerName?: string) => {
const buildTicketHtmlCard = (t: TicketRecord, buyerName?: string) => {
const eventTitle = t.event?.title || t.eventId || "Event";
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
@@ -439,7 +416,7 @@ export default function UserDashboardPage() {
w.focus();
};
const printTickets = (ticketList: any[]) => {
const printTickets = (ticketList: TicketRecord[]) => {
if (!ticketList || ticketList.length === 0) return;
const cards = ticketList.map(t => buildTicketHtmlCard(t, user?.name)).join("");
openPrintWindow(`Tickets (${ticketList.length})`, cards);
@@ -447,7 +424,7 @@ export default function UserDashboardPage() {
// Upcoming events derived from registrations
const upcomingEvents = useMemo(() => {
const map = new Map<string, any>();
const map = new Map<string, Event>();
for (const r of registrations) {
if (r.event && isFuture(r.event.startDate)) {
if (!map.has(r.event.id)) map.set(r.event.id, r.event);
@@ -456,16 +433,6 @@ export default function UserDashboardPage() {
return Array.from(map.values()).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
}, [registrations]);
// Tickets by grouping
const ticketsByEvent = useMemo(() => {
const m: Record<string, any[]> = {};
for (const t of tickets) {
const eid = t.eventId;
if (!m[eid]) m[eid] = [];
m[eid].push(t);
}
return m;
}, [tickets]);
// Registration click -> open modal
const activeReg = useMemo(() => registrations.find(r => r.id === activeRegId) || null, [registrations, activeRegId]);
@@ -484,7 +451,7 @@ export default function UserDashboardPage() {
}
(async () => {
try {
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(ar.eventId)}`);
const ev = await apiFetch<Event>(`/api/events/${encodeURIComponent(ar.eventId)}`);
const has = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
setActiveEventHasForm(has);
} catch {
@@ -515,35 +482,35 @@ export default function UserDashboardPage() {
authToken: token,
body: { code: codeInput.trim() },
});
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const updated = myRegs.find(r => r.id === activeRegId);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
setCodeInput("");
setInfo(res?.message || 'Code applied.');
} catch (e: any) {
setCodeError(e?.message || "Couldn't apply that code");
} catch (e) {
setCodeError(errorMessage(e) || "Couldn't apply that code");
} finally {
setCodeBusy(false);
}
};
const [transferTarget, setTransferTarget] = useState<any | null>(null);
const [transferTarget, setTransferTarget] = useState<TicketRecord | null>(null);
// Re-fetch registrations and tickets after something on one changes (attendee names).
const reloadAfterAttendees = async () => {
if (!token) return;
try {
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
};
@@ -560,13 +527,13 @@ export default function UserDashboardPage() {
method: 'DELETE',
authToken: token,
});
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
setActiveRegId(null);
setShowCancelConfirm(false);
setInfo('Registration cancelled.');
} catch (e: any) {
setError(e?.message || 'Failed to cancel registration');
} catch (e) {
setError(errorMessage(e) || 'Failed to cancel registration');
setShowCancelConfirm(false);
} finally {
setCancelLoading(false);
@@ -577,7 +544,7 @@ export default function UserDashboardPage() {
// "eventOptionId" for plain options, "eventOptionId:variantId" for variant options.
const [editMode, setEditMode] = useState<boolean>(false);
const [editLoading, setEditLoading] = useState<boolean>(false);
const [editEventOptions, setEditEventOptions] = useState<any[]>([]);
const [editEventOptions, setEditEventOptions] = useState<EventOption[]>([]);
const [editQuantities, setEditQuantities] = useState<Record<string, number>>({});
const [editBaseQty, setEditBaseQty] = useState<Record<string, number>>({});
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
@@ -591,9 +558,9 @@ export default function UserDashboardPage() {
}, [activeReg]);
const editTotal = useMemo(() => {
return editEventOptions.reduce((sum: number, eo: any) => {
return editEventOptions.reduce((sum, eo) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
return sum + eo.variants.reduce((vsum: number, v: any) => {
return sum + eo.variants.reduce((vsum, v) => {
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
return vsum + qty * effectiveEventOptionUnitPrice(eo, v.id);
}, 0);
@@ -608,10 +575,10 @@ export default function UserDashboardPage() {
setEditLoading(true);
try {
// Load event options (with variants) for this event
const eventData = await apiFetch<any>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
const eventOptions = (eventData?.eventOptions || []) as any[];
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
const eventOptions = eventData?.eventOptions || [];
const regOptById = new Map<string, any>((activeReg.registrationOptions || []).map((o: any) => [o.id, o]));
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
// Prefill quantities from the current registration
const qtyMap: Record<string, number> = {};
@@ -624,16 +591,16 @@ export default function UserDashboardPage() {
// (tickets are never deleted or shrunk, only ever grown, once paid).
const minQtyMap: Record<string, number> = {};
for (const t of tickets) {
const ro = regOptById.get(t.registrationOptionId);
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
if (!ro) continue;
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0);
}
// Ensure every option/variant has a (possibly zero) entry so they all render
eventOptions.forEach((eo: any) => {
eventOptions.forEach((eo) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
eo.variants.forEach((v: any) => {
eo.variants.forEach((v) => {
const key = `${eo.id}:${v.id}`;
if (!(key in qtyMap)) qtyMap[key] = 0;
});
@@ -648,7 +615,7 @@ export default function UserDashboardPage() {
setEditMinQty(minQtyMap);
setEditMode(true);
} catch (e) {
setEditError((e as any)?.message || 'Failed to load event options');
setEditError(errorMessage(e) || 'Failed to load event options');
} finally {
setEditLoading(false);
}
@@ -662,8 +629,8 @@ export default function UserDashboardPage() {
if (minQty <= 0) continue;
if ((editQuantities[key] || 0) < minQty) {
const [eventOptionId, variantId] = key.includes(':') ? key.split(':') : [key, undefined];
const eo = editEventOptions.find((o: any) => o.id === eventOptionId);
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
const eo = editEventOptions.find((o) => o.id === eventOptionId);
const variant = variantId ? (eo?.variants || []).find((v) => v.id === variantId) : null;
const label = variant ? `${eo?.name || 'item'} (${variant.name})` : (eo?.name || 'item');
setEditError(`Cannot reduce "${label}" below the ${minQty} already issued`);
return;
@@ -698,22 +665,22 @@ export default function UserDashboardPage() {
body: { options: payloadOptions },
});
// Reload registrations to reflect changes
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
// Recompute billing totals using the backend's tranche-aware totalDueComputed
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
const totals: Record<string, Billing> = {};
for (const r of myRegs) {
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
}
setBilling(totals);
// Payments will be refreshed lazily or on next open; fetch for this one immediately
try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = totals[activeReg.id]?.totalDue ?? 0;
setBilling(prev => ({
@@ -728,8 +695,8 @@ export default function UserDashboardPage() {
} catch {}
setInfo('Registration updated successfully');
setEditMode(false);
} catch (e: any) {
setEditError(e?.message || 'Failed to update registration');
} catch (e) {
setEditError(errorMessage(e) || 'Failed to update registration');
} finally {
setLoading(false);
}
@@ -787,9 +754,9 @@ export default function UserDashboardPage() {
<ul className="space-y-2">
{registrations
// Hide cancelled always; hide past unless showPast
.filter((r: any) => r.status !== 'cancelled')
.filter((r: any) => showPast || !isEventOver(r.event))
.map((r: any) => {
.filter((r) => r.status !== 'cancelled')
.filter((r) => showPast || !isEventOver(r.event))
.map((r) => {
const bill = billing[r.id];
const isCancelled = r.status === 'cancelled';
const outstanding = isCancelled ? 0 : (bill?.outstanding ?? 0);
@@ -829,18 +796,18 @@ export default function UserDashboardPage() {
{(() => {
if (r.status == 'paid') return null;
// Early-bird notice: only for events with tiers that haven't passed
const allTiers = (r.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
const allTiers = (r.registrationOptions || []).flatMap((ro) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
const upcoming = allTiers
.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());
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t) => new Date() < t.deadline)
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime());
if (upcoming.length === 0) return null;
const nextDeadline = upcoming[0].deadline;
const pays = (billing[r.id]?.payments || []);
const lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p: any) => new Date(p.createdAt).getTime()))) : null;
const lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p) => new Date(p.createdAt).getTime()))) : null;
const now = new Date();
const priceNow = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
const afterPrice = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, new Date(nextDeadline.getTime() + 1000)) * (ro.quantity || 0), 0);
const priceNow = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
const afterPrice = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, new Date(nextDeadline.getTime() + 1000)) * (ro.quantity || 0), 0);
const dateStr = nextDeadline.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
if (afterPrice > priceNow) {
return (
@@ -971,8 +938,8 @@ export default function UserDashboardPage() {
) : (
<ul className="grid sm:grid-cols-2 gap-3">
{tickets
.filter((t: any) => showPast || !isEventOver(t.event))
.map((t: any) => {
.filter((t) => showPast || !isEventOver(t.event))
.map((t) => {
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
const variantName = t.registrationOption?.variant?.name;
@@ -997,7 +964,7 @@ export default function UserDashboardPage() {
</div>
{(t.attendee?.allocations || []).length > 0 && (
<div className="text-xs text-violet-800 bg-violet-50 rounded px-2 py-0.5 inline-block ml-1">
{[...t.attendee.allocations].sort((a: any, b: any) => (a.set?.order || 0) - (b.set?.order || 0)).map((a: any) => `${a.set.name}: ${a.group.name}`).join(" · ")}
{[...(t.attendee?.allocations || [])].sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0)).map((a) => `${a.set.name}: ${a.group.name}`).join(" · ")}
</div>
)}
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
@@ -1011,6 +978,18 @@ export default function UserDashboardPage() {
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
)}
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Print</button>
{(t.transferRequests || [])[0] && (
<span className="w-full text-xs text-violet-800 bg-violet-50 rounded px-2 py-1 flex items-center justify-between gap-2">
<span>Waiting for {t.transferRequests?.[0]?.toName} to accept</span>
<button className="underline" onClick={async () => {
try {
const r = await apiFetch<{ message?: string }>(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
setInfo(r?.message || "Transfer cancelled");
reloadAfterAttendees();
} catch (e) { setError(errorMessage(e) || "Couldn't cancel"); }
}}>Cancel</button>
</span>
)}
{canOfferTransfer(t, t.event) && (
<button onClick={() => setTransferTarget(t)} className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-100 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" title="Give this ticket to someone else">Transfer</button>
)}
@@ -1068,7 +1047,7 @@ export default function UserDashboardPage() {
// early-bird rows separately from standard-price rows so repeat purchases
// of the same item don't show as a wall of near-duplicate lines.
const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
(activeReg.registrationOptions || []).forEach((opt: any) => {
(activeReg.registrationOptions || []).forEach((opt) => {
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
const label = `${opt.eventOption?.name || ''}${variantLabel}`;
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
@@ -1080,7 +1059,7 @@ export default function UserDashboardPage() {
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
appliedTierId: opt.appliedTierId,
}];
tranches.forEach((t: any) => {
tranches.forEach((t) => {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
@@ -1151,7 +1130,7 @@ export default function UserDashboardPage() {
) : (
<>
<div className="space-y-2">
{editEventOptions.map((eo: any) => {
{editEventOptions.map((eo) => {
const hasVariants = Array.isArray(eo.variants) && eo.variants.length > 0;
const renderQtyInput = (key: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
@@ -1176,12 +1155,12 @@ export default function UserDashboardPage() {
};
if (hasVariants) {
const variants = eo.variants.slice().sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
const variants = (eo.variants || []).slice().sort((a, b) => (a.order || 0) - (b.order || 0));
return (
<div key={eo.id} className="border rounded bg-white overflow-hidden">
<div className="px-3 py-2 bg-gray-100 text-sm font-medium">{eo.name}</div>
<ul className="divide-y">
{variants.map((v: any) => {
{variants.map((v) => {
const key = `${eo.id}:${v.id}`;
const minQty = editMinQty[key] || 0;
const unitPrice = effectiveEventOptionUnitPrice(eo, v.id);
@@ -1259,7 +1238,7 @@ export default function UserDashboardPage() {
<div>
<div className="font-medium mb-1">Payments</div>
<ul className="text-sm space-y-1">
{activeBill.payments.map((p: any) => (
{activeBill.payments.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
<span>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
@@ -1286,8 +1265,8 @@ export default function UserDashboardPage() {
>Make payment</button>
)}
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
const regOptIds = new Set((activeReg.registrationOptions || []).map((o) => o.id));
const list = tickets.filter(t => !!t.registrationOptionId && regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
</div>
@@ -7,6 +7,8 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { Receipt } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Paginated } from "@/types";
interface PaymentItem {
id: string;
@@ -49,14 +51,14 @@ export default function UserPaymentsPage() {
setInfo(null);
setSendingReceipt(true);
try {
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setInfo((res && res.message) || "Receipt sent.");
} catch (e: any) {
setError(e?.message || "Failed to send receipt");
} catch (e) {
setError(errorMessage(e) || "Failed to send receipt");
} finally {
setSendingReceipt(false);
}
@@ -82,19 +84,20 @@ export default function UserPaymentsPage() {
setError(null);
setFetching(true);
try {
const res = await apiFetch<any>(buildQuery(p), { authToken: token });
const res = await apiFetch<Paginated<PaymentItem>>(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");
} catch (e) {
setError(errorMessage(e) || "Failed to load payments");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
}, [token, buildQuery, setError]);
useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]);
// loadPayments changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes.
useEffect(() => { if (token) loadPayments(1); }, [token, loadPayments]);
const goToPage = (p: number) => loadPayments(p);
@@ -150,7 +153,7 @@ export default function UserPaymentsPage() {
</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)}>
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as "" | "payment" | "refund")}>
<option value="">Payments &amp; refunds</option>
<option value="payment">Payments only</option>
<option value="refund">Refunds only</option>
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
@@ -8,6 +8,7 @@ import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
import { errorMessage } from "@/lib/utils";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
@@ -54,7 +55,7 @@ export default function UserProfilePage() {
setSavingProfile(true);
setProfileMsg(null);
try {
const res = await apiFetch<any>("/api/users/profile", {
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
method: "PUT",
authToken: token,
body: {
@@ -67,8 +68,8 @@ export default function UserProfilePage() {
// Backend returns a refreshed token — save it so the session stays valid
if (res?.token) updateToken(res.token);
setProfileMsg({ type: "ok", text: "Profile updated." });
} catch (e: any) {
setProfileMsg({ type: "err", text: e?.message || "Failed to update profile." });
} catch (e) {
setProfileMsg({ type: "err", text: errorMessage(e) || "Failed to update profile." });
} finally {
setSavingProfile(false);
}
@@ -95,7 +96,7 @@ export default function UserProfilePage() {
setSavingPw(true);
setPwMsg(null);
try {
const res = await apiFetch<any>("/api/users/profile", {
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
method: "PUT",
authToken: token,
body: { currentPassword, password: newPassword },
@@ -106,8 +107,8 @@ export default function UserProfilePage() {
setConfirmPassword("");
setPwMsg({ type: "ok", text: "Password changed." });
loadActivity();
} catch (e: any) {
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
} catch (e) {
setPwMsg({ type: "err", text: errorMessage(e) || "Failed to change password." });
} finally {
setSavingPw(false);
}
@@ -123,7 +124,7 @@ export default function UserProfilePage() {
setRevoking(true);
setRevokeMsg(null);
try {
const res = await apiFetch<any>("/api/users/revoke-sessions", {
const res = await apiFetch<{ token?: string; message?: string }>("/api/users/revoke-sessions", {
method: "POST",
authToken: token,
});
@@ -131,8 +132,8 @@ export default function UserProfilePage() {
// save the freshly issued one so this device stays signed in.
if (res?.token) updateToken(res.token);
setRevokeMsg({ type: "ok", text: res?.message || "All other sessions signed out." });
} catch (e: any) {
setRevokeMsg({ type: "err", text: e?.message || "Failed to revoke sessions." });
} catch (e) {
setRevokeMsg({ type: "err", text: errorMessage(e) || "Failed to revoke sessions." });
} finally {
setRevoking(false);
}
@@ -142,20 +143,20 @@ export default function UserProfilePage() {
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [loadingActivity, setLoadingActivity] = useState(false);
const loadActivity = async () => {
const loadActivity = useCallback(async () => {
if (!token) return;
setLoadingActivity(true);
try {
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
setActivity(Array.isArray(res) ? res : []);
} catch (e) {
} catch {
// Non-critical — just leave the list empty rather than showing an error banner.
} finally {
setLoadingActivity(false);
}
};
}, [token]);
useEffect(() => { loadActivity(); }, [token]);
useEffect(() => { loadActivity(); }, [loadActivity]);
// ── Two-factor authentication ─────────────────────────────────────────────
// Mirrors the saved account state locally: the auth context's `user` is only
@@ -203,8 +204,8 @@ export default function UserProfilePage() {
setTfCode("");
setTfPassword("");
setTfStep("code");
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to send a verification code." });
} catch (e) {
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to send a verification code." });
} finally {
setTfBusy(false);
}
@@ -227,8 +228,8 @@ export default function UserProfilePage() {
resetTwoFactorForm();
setTfMsg({ type: "ok", text: wasEnabled ? `Codes will now be sent by ${TWO_FACTOR_METHOD_LABEL[res.twoFactorMethod]}.` : "Two-factor authentication is on." });
loadActivity();
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
} catch (e) {
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to verify the code." });
} finally {
setTfBusy(false);
}
@@ -249,8 +250,8 @@ export default function UserProfilePage() {
resetTwoFactorForm();
setTfMsg({ type: "ok", text: "Two-factor authentication is off." });
loadActivity();
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to turn off two-factor authentication." });
} catch (e) {
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to turn off two-factor authentication." });
} finally {
setTfBusy(false);
}
@@ -269,7 +270,7 @@ export default function UserProfilePage() {
setClosing(true);
setCloseMsg(null);
try {
const res = await apiFetch<any>("/api/users/close-account", {
const res = await apiFetch<{ message?: string }>("/api/users/close-account", {
method: "POST",
authToken: token,
body: { password: closePassword, deleteData },
@@ -277,8 +278,8 @@ export default function UserProfilePage() {
setCloseMsg({ type: "ok", text: res?.message || "Account closed." });
// Slight delay so the user can read the message, then log out
setTimeout(() => logout(), 2500);
} catch (e: any) {
setCloseMsg({ type: "err", text: e?.message || "Failed to close account." });
} catch (e) {
setCloseMsg({ type: "err", text: errorMessage(e) || "Failed to close account." });
} finally {
setClosing(false);
}
@@ -5,6 +5,7 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
export default function ResetPasswordPage() {
const { token } = useAuth();
@@ -35,8 +36,8 @@ export default function ResetPasswordPage() {
setPassword("");
setConfirm("");
setTimeout(() => router.push("/dashboard/user"), 1200);
} catch (err: any) {
setError(err?.message || "Failed to update password");
} catch (err) {
setError(errorMessage(err) || "Failed to update password");
} finally {
setLoading(false);
}
@@ -4,7 +4,9 @@ import { Share2, QrCode, CalendarPlus } from "lucide-react";
import QRCode from "qrcode";
import { API_BASE } from "@/lib/api";
export default function ClientActions({ event }: { event: any }) {
type Props = { event: { id: string; title: string; description?: string | null } };
export default function ClientActions({ event }: Props) {
return (
<div className="flex gap-2">
<button
@@ -12,7 +14,7 @@ export default function ClientActions({ event }: { event: any }) {
try {
await navigator.share({
title: event.title,
text: event.description,
text: event.description ?? undefined,
url: window.location.href,
});
} catch (err) {
+2 -2
View File
@@ -12,7 +12,7 @@ import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number };
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
type EventOption = {
id: string;
name: string;
@@ -283,7 +283,7 @@ function TicketList({ event }: { event: Event }) {
</div>
</div>
<ul className="divide-y">
{opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => {
{opt.variants!.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((v) => {
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
+3 -2
View File
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { MessageSquareHeart, CheckCircle2 } from "lucide-react";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
type Field = { id: string; type: string; label: string; isRequired: boolean; helpText: string | null; options: unknown };
type FeedbackInfo = {
@@ -46,8 +47,8 @@ export default function FeedbackPage() {
try {
await apiFetch(`/api/feedback/public/${encodeURIComponent(token!)}`, { method: "POST", body: { answers } });
setDone(true);
} catch (err: any) {
setError(err?.message || "Couldn't send your feedback");
} catch (err) {
setError(errorMessage(err) || "Couldn't send your feedback");
} finally {
setBusy(false);
}
+29 -27
View File
@@ -7,6 +7,8 @@ import { apiFetch } from "@/lib/api";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
@@ -19,7 +21,7 @@ function FormsContent() {
// Set when a named attendee opens the link sent to them — show only their form.
const onlyAttendeeId = search.get("attendeeId");
const [registration, setRegistration] = useState<any | null>(null);
const [registration, setRegistration] = useState<Registration | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -36,17 +38,17 @@ function FormsContent() {
setInfo(null);
try {
// authToken optional — guests access via registrationId alone
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
setRegistration(reg);
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
if (ev?.form) setEventForm(ev.form);
// Load any saved draft to prefill fields
try {
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
const draft = await apiFetch<{ data?: Record<string, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
} catch {}
} catch (e: any) {
setError(e?.message || 'Failed to load registration');
} catch (e) {
setError(errorMessage(e) || 'Failed to load registration');
} finally {
setLoading(false);
}
@@ -61,8 +63,8 @@ function FormsContent() {
if (!registration) return 0;
try {
return (registration.registrationOptions || [])
.filter((ro: any) => ro.eventOption?.isMainTicket)
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
.filter((ro) => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
} catch {
return 0;
}
@@ -75,13 +77,13 @@ function FormsContent() {
const blocks = useMemo((): FormBlock[] => {
if (!registration) return [];
const responses = registration.formResponses || [];
const answered = new Set(responses.map((r: any) => r.attendeeId).filter(Boolean));
const attendees = (registration.attendees || []) as any[];
const answered = new Set(responses.map((r) => r.attendeeId).filter(Boolean));
const attendees = registration.attendees || [];
const named = attendees
.filter(a => !answered.has(a.id))
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id as string }));
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id }));
const unnamedPlaces = Math.max(0, requiredCount - attendees.length);
const unnamedAnswered = responses.filter((r: any) => !r.attendeeId).length;
const unnamedAnswered = responses.filter((r) => !r.attendeeId).length;
const unnamed = Array.from({ length: Math.max(0, unnamedPlaces - unnamedAnswered) }, (_, i) => ({
key: String(i), label: `Attendee ${unnamedAnswered + i + 1}`,
}));
@@ -89,7 +91,7 @@ function FormsContent() {
return onlyAttendeeId ? all.filter(b => b.attendeeId === onlyAttendeeId) : all;
}, [registration, requiredCount, onlyAttendeeId]);
const remaining = blocks.length;
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a: any) => a.id === id)?.name;
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a) => a.id === id)?.name;
// Determine if all required fields are filled for each attendee form to be submitted
const canSubmit = useMemo(() => {
@@ -116,7 +118,7 @@ function FormsContent() {
const payload = blocks.map(b => ({ ...(b.attendeeId ? { attendeeId: b.attendeeId } : {}), answers: formsData[b.key] || {} }));
if (payload.length === 0) {
// Nothing left to submit – still go to success page to continue the flow
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
return;
}
@@ -126,10 +128,10 @@ function FormsContent() {
body: { responses: payload }
});
// On success, redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
} catch (e) {
setError(errorMessage(e) || 'Failed to submit forms');
}
};
@@ -146,13 +148,13 @@ function FormsContent() {
if (!token || !registrationId) return;
try {
// Redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
} catch (e: any) {
setError(e?.message || 'Failded to go to next step');
} catch (e) {
setError(errorMessage(e) || 'Failded to go to next step');
}
} catch (e: any) {
setError(e?.message || 'Failed to save draft');
} catch (e) {
setError(errorMessage(e) || 'Failed to save draft');
}
};
@@ -160,10 +162,10 @@ function FormsContent() {
if (!registrationId) return;
try {
// Redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
} catch (e: any) {
setError(e?.message || 'Failded to skip');
} catch (e) {
setError(errorMessage(e) || 'Failded to skip');
}
};
@@ -210,14 +212,14 @@ function FormsContent() {
<div className="border rounded p-3 mb-4 bg-gray-50">
<div className="text-sm font-medium mb-2">Submitted responses</div>
<ul className="space-y-2">
{(registration.formResponses || []).map((resp: any, idx: number) => (
{(registration.formResponses || []).map((resp, idx) => (
<li key={resp.id} className="bg-white border rounded p-2">
<div className="font-medium text-sm mb-1">{attendeeName(resp.attendeeId) || `Attendee ${idx + 1}`}</div>
{(resp.answers || []).length === 0 ? (
<div className="text-xs text-gray-600">No answers recorded.</div>
) : (
<ul className="text-xs list-disc pl-5 space-y-0.5">
{resp.answers.map((a: any) => (
{(resp.answers || []).map((a) => (
<li key={a.id}>
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
</li>
-11
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
@@ -14,16 +13,6 @@ import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
/**
+89 -38
View File
@@ -4,23 +4,25 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
const sections = [
{ id: "summary", title: "Plain English Summary" },
{ id: "introduction",title: "1. Introduction" },
{ id: "information", title: "2. Information We Collect" },
{ id: "use", title: "3. How We Use Your Information" },
{ id: "whatsapp", title: "4. WhatsApp Communications" },
{ id: "storage", title: "5. Where We Store Your Data" },
{ id: "retention", title: "6. Data Retention" },
{ id: "children", title: "7. Children and Minors" },
{ id: "sharing", title: "8. Sharing of Information" },
{ id: "rights", title: "9. Your Rights (under POPIA)" },
{ id: "cookies", title: "10. Cookies and Local Storage" },
{ id: "security", title: "11. Security" },
{ id: "changes", title: "12. Changes to This Policy" },
{ id: "officer", title: "13. Information Officer" },
{ id: "contact", title: "14. Contact" },
{ id: "others", title: "3. People You Register or Transfer Tickets To" },
{ id: "use", title: "4. How We Use Your Information" },
{ id: "whatsapp", title: "5. WhatsApp Communications" },
{ id: "storage", title: "6. Where We Store Your Data" },
{ id: "retention", title: "7. Data Retention" },
{ id: "children", title: "8. Children, Parental Consent and Health Information" },
{ id: "sharing", title: "9. Who Can See Your Information" },
{ id: "rights", title: "10. Your Rights (under POPIA)" },
{ id: "cookies", title: "11. Cookie Policy (Cookies and Browser Storage)" },
{ id: "security", title: "12. Security" },
{ id: "changes", title: "13. Changes to This Policy" },
{ id: "officer", title: "14. Information Officer" },
{ id: "contact", title: "15. Contact" },
];
export default function PrivacyPolicyPage() {
@@ -89,15 +91,15 @@ export default function PrivacyPolicyPage() {
<h2 className="text-2xl font-semibold text-brand-600 mb-4">🟢 Plain English Summary</h2>
<p>We respect your privacy — here&apos;s what you need to know:</p>
<ul className="list-disc ml-6 space-y-1">
<li>We only collect info needed to register you for events and process payments.</li>
<li>We only collect info needed to run events: registrations, payments, tickets and check-in.</li>
<li>We store your info safely on South African servers.</li>
<li>We don&apos;t sell or share your data with anyone.</li>
<li>You can ask us what info we have about you or request deletion at any time.</li>
<li>Under 18s must register with a parent&apos;s permission.</li>
<li>We don&apos;t sell your data, and only share it with the services that process payments and deliver messages.</li>
<li>If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.</li>
<li>For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.</li>
<li>Payments go through Yoco; we never see your card details.</li>
<li>We may send you security alerts (e.g. new login notifications) via email and/or WhatsApp to keep your account safe.</li>
<li>If you opt in to WhatsApp notifications, we use your mobile number to send you event updates, tickets, and account alerts via WhatsApp (powered by WAWP).</li>
<li>We keep your data for up to 5 years after your last event registration.</li>
<li>We send tickets, updates and security alerts by email and/or WhatsApp (powered by WAWP).</li>
<li>Visit statistics are only collected if you click &quot;Allow&quot; on the cookie notice — no third-party analytics.</li>
<li>You can ask what we hold about you, or delete your account, at any time. We keep data for up to 5 years after your last event registration.</li>
</ul>
</section>
@@ -129,21 +131,36 @@ function Section({
],
information: [
"We collect the following information when you interact with our platform:",
"• Account Information: name, email address, phone number, and password (encrypted).",
"• Event Information: events you register for, ticket selections, and related preferences.",
"• Payment Information: processed by Yoco; we receive only payment status and reference numbers.",
"• Communication Data: emails or messages exchanged with our team.",
"• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.",
"• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.",
"• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.",
"• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).",
"• Payment information: processed by Yoco; we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
"• Communication data: emails or messages exchanged with our team.",
"• Visit statistics: only if you allow them on the cookie notice (see section 11).",
"We do not collect or store credit card numbers or bank details.",
"Administrator-created accounts (e.g. walk-in event registrations) may be created with a mobile number only. These accounts use a placeholder email address and can be activated by the account holder at any time.",
"Accounts may be created by an administrator, or when someone registers you or transfers a ticket to you, with a mobile number or email address only. You can activate such an account at any time.",
],
others: [
"You can register other people (for example family members) and give each one their own ticket, or pass your ticket on to someone else.",
"• You type their name and, optionally, their email address or phone number. There is no search or list of existing users to choose from, and we never tell you whether the details you enter belong to an account.",
"• We use those details only to send that person their ticket, their attendee form, a parental consent request (youth events), or — for a transfer — a link to accept the ticket. A transfer only happens once they accept it; they can also decline.",
"• If the email or phone number matches an existing account, the ticket also appears in that person's own account. Otherwise an account may be created for them, which they can activate if they wish.",
"• Some events ask for each person's date of birth or gender (for example, to allocate rooms at a camp).",
"By entering someone else's details, you confirm that you are allowed to share them with us for this purpose (for a child, that you are their parent or guardian or have their permission).",
],
use: [
"We use your information to:",
"• Process event registrations and payments.",
"• Process event registrations, payments, discounts and refunds.",
"• Communicate event updates, confirmations, or changes via email and/or WhatsApp (based on your preference).",
"• Send security notifications for new logins, password changes, and account actions on your account.",
"• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).",
"• Manage user accounts and event attendance records.",
"• Improve our website and event experience.",
"• Check tickets in at the door, including with scanners that keep working without an internet connection.",
"• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.",
"• Send you an optional feedback form after an event, with one reminder.",
"• Manage user accounts and event attendance records, and give support when you ask for help.",
"• Improve our events and website (using anonymous statistics, and only with your permission).",
"• Comply with legal obligations.",
],
whatsapp: [
@@ -165,19 +182,33 @@ function Section({
],
retention: [
"We retain your personal data for up to 5 years after your last event registration, or until you request deletion of your account — whichever comes first.",
"Medical and emergency-contact details from parental consent forms are deleted a set number of days after the event ends (30 days unless the organisation sets a different period). The record of who gave consent, when, and to which wording is kept.",
"Detailed visit statistics are combined into anonymous daily totals after 90 days.",
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
"Records of support access to accounts (see section 9) and other audit records are kept for accountability.",
"You can delete your account at any time from the Profile & Security page in your account. Deletion anonymises your personal information (name, email address, and phone number are replaced with placeholder values) while retaining event and payment records in anonymised form for financial record-keeping purposes.",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 13).",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 14).",
],
children: [
"Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.",
"We do not knowingly collect or process personal information of minors without such consent.",
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
"A parent or guardian may also say whether photos of the young person may be used.",
],
sharing: [
"We do not sell, rent, or share your personal information with any third parties.",
"The only exceptions are:",
"• Processing payments securely through Yoco (a South African payment processor).",
"• Delivering WhatsApp messages through WAWP (wawp.net), a WhatsApp Business API provider — only your mobile number and message content are shared, solely for delivery purposes.",
"• Where required by South African law.",
"We do not sell or rent your personal information. Outside our organisation, it is shared only with:",
"• Yoco (a South African payment processor), to process payments securely.",
"• WAWP (wawp.net), a WhatsApp Business API provider, to deliver WhatsApp messages — only your mobile number and the message content, solely for delivery.",
"• Our email delivery provider, to deliver emails.",
"• Anyone we are required to share it with under South African law.",
"Within our organisation, people see only what they need:",
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
"• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.",
"• Supervisors and administrators manage registrations, payments and communication for events.",
"• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.",
"• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.",
"• Nobody else can see whether you have an account.",
],
rights: [
"Under POPIA, you have the right to:",
@@ -188,16 +219,31 @@ function Section({
"To contact the Information Regulator:",
"• Website: www.inforeg.org.za",
"• Email: enquiries@inforeg.org.za",
"To exercise your rights directly with us, contact our Information Officer using the details in section 13 below.",
"To exercise your rights directly with us, contact our Information Officer using the details in section 14 below.",
],
cookies: [
"This website uses browser localStorage to store your login session token and user interface preferences (such as dismissed notifications). No third-party cookies, tracking pixels, or analytics tools are used.",
"If you choose \"Allow\" on the cookie notice, we also keep a random, anonymous visit identifier so we can count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link) and the type of device — never your name, IP address or browsing history. Detailed records are combined into daily totals after 90 days. If you choose \"No thanks\", nothing is counted.",
"You can clear your browser's local storage at any time through your browser settings, which will log you out of the site.",
"Our own site does not set tracking cookies. It keeps a few small items in your browser's storage (localStorage, and on staff scanners IndexedDB and an offline page cache). These work much like cookies, so this policy covers them. We do not use third-party analytics, advertising or tracking pixels.",
"Strictly necessary — always used, because the site can't work without them:",
"• hope_events_token — keeps you signed in.",
"• hope_events_user_cache — your last loaded profile, so pages keep working if your connection drops.",
"• hope_events_device — only if you tick \"Trust this device\" when entering a two-factor code, so you aren't asked for a code every time.",
"• hope_events_cookie_consent — remembers your choice on the cookie notice.",
"• banner_dismissed_… — remembers notices you have closed.",
"Staff and administrators only:",
"• hope_events_scan_device, hope_events_offline_lock and the \"hope-events-offline-scan\" database — on a staff member's scanner in offline mode: the device's id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.",
"• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.",
"Optional — only if you click \"Allow\" on the cookie notice:",
"• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose \"No thanks\", nothing is counted and no visit id is created.",
"Third parties:",
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
"• Payments open Yoco's secure checkout page, which uses its own cookies on Yoco's site.",
"You can change your cookie choice at any time with \"Cookie settings\" at the bottom of every page, or with the button above. You can also clear your browser's storage in your browser settings; this signs you out.",
],
security: [
"Your personal data is encrypted during transmission and stored using industry-standard security protocols.",
"Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.",
"Passwords are encrypted and cannot be viewed by anyone, including administrators.",
"Two-factor authentication is available to everyone and is required for supervisors and administrators.",
"Links we send for consent, feedback and ticket transfers are personal and time-limited. Only a secure fingerprint of each link is stored, not the link itself.",
"In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.",
],
changes: [
@@ -219,6 +265,11 @@ function Section({
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-brand-600 mb-3">{title}</h3>
{id === "cookies" && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
)}
{content[id]?.map((line, i) => {
// Render email addresses as links
const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
+54 -15
View File
@@ -12,14 +12,19 @@ const sections = [
{ id: "accounts", title: "3. User Accounts" },
{ id: "payments", title: "4. Payments & Refunds" },
{ id: "registrations", title: "5. Registrations & Tickets" },
{ id: "acceptable", title: "6. Acceptable Use" },
{ id: "communication", title: "7. Communication" },
{ id: "intellectual", title: "8. Intellectual Property" },
{ id: "liability", title: "9. Limitation of Liability" },
{ id: "forcemajeure", title: "10. Force Majeure" },
{ id: "termination", title: "11. Termination of Access" },
{ id: "law", title: "12. Governing Law" },
{ id: "contact", title: "13. Contact" },
{ id: "others", title: "6. Registering Other People" },
{ id: "youth", title: "7. Youth Events & Parental Consent" },
{ id: "transfers", title: "8. Ticket Transfers" },
{ id: "extras", title: "9. Discount Codes, Waitlists, Sponsorships & Feedback" },
{ id: "acceptable", title: "10. Acceptable Use" },
{ id: "communication", title: "11. Communication" },
{ id: "support", title: "12. Account Security & Support Access" },
{ id: "intellectual", title: "13. Intellectual Property" },
{ id: "liability", title: "14. Limitation of Liability" },
{ id: "forcemajeure", title: "15. Force Majeure" },
{ id: "termination", title: "16. Termination of Access" },
{ id: "law", title: "17. Governing Law" },
{ id: "contact", title: "18. Contact" },
];
export default function TermsOfUsePage() {
@@ -88,7 +93,9 @@ export default function TermsOfUsePage() {
<li>Respect that under 18s need parental permission to register.</li>
<li>Understand that all payments go through Yoco, and we don&apos;t store your card details.</li>
<li>Accept that we may contact you via email and/or WhatsApp (based on your preference) about events you register for, your tickets, and account security alerts.</li>
<li>Understand that registrations are personal to you and non-transferable.</li>
<li>Only enter other people&apos;s details (when booking for them or passing on a ticket) if you&apos;re allowed to.</li>
<li>Where an event allows it, you can pass your ticket to someone else before the event — it moves to them once they accept, with a new QR code.</li>
<li>For youth events, a parent or guardian must give consent before a young person can be checked in.</li>
<li>If we cancel an event, you&apos;ll get a full refund on request or your registration moved to the new date.</li>
</ul>
<p className="mt-2">If you disagree with these terms, please don&apos;t use the site.</p>
@@ -126,11 +133,11 @@ function Section({
],
accounts: [
"You must create an account to register for certain events or access restricted features.",
"You are responsible for keeping your password secure.",
"You are responsible for keeping your password secure, and for any two-factor codes sent to you.",
"Passwords are encrypted and not accessible to any staff member.",
"Users under the age of 18 must only register or create an account with the consent and supervision of a parent or guardian.",
"You agree to provide accurate, current, and complete information when creating your account.",
`Accounts may also be created on your behalf by an administrator (e.g. for walk-in event registration). In this case, you will receive a WhatsApp message with an activation link if your mobile number is provided.`,
`Accounts may also be created on your behalf — by an administrator (e.g. for walk-in event registration), or when someone registers you for an event or transfers a ticket to you. You will receive an activation link by email or WhatsApp, and can choose whether to activate the account.`,
],
payments: [
"Payments are processed securely through Yoco, a third-party payment processor.",
@@ -142,18 +149,46 @@ function Section({
`Refund Policy: Refunds are handled on a per-event basis. As a baseline, if you are unable to attend an event you have paid for, please contact us at ${orgEmail} at least 48 hours before the event. Refund requests at this stage are at the discretion of the event organiser. No refunds will be issued for no-shows. Any event-specific refund terms will be communicated at the time of registration and take precedence over this baseline.`,
],
registrations: [
`Registrations and tickets are personal to the registered individual and are non-transferable unless explicitly authorised by ${orgName} in writing.`,
"You may not sell, gift, or pass on your registration to another person without written authorisation.",
"Each ticket has a QR code that is checked at the event. Keep it private — anyone holding a copy of it may be able to use it.",
"A ticket can only be used for the number of places it covers. Once a ticket has been scanned it cannot be transferred.",
"Tickets may be passed on to another person only through the platform's transfer feature, where the event allows it (see section 8). You may not sell tickets for profit.",
"At some events, door staff may check tickets against a list downloaded to their device in case the internet connection fails. If a ticket was bought or changed after that list was downloaded, you may be asked to go to the door desk.",
"You may cancel your own registration at any time before any payment has been made. Once a payment has been recorded against your registration, cancellation must be requested through the organisation. Please contact us to arrange this.",
`If ${orgName} cancels or significantly postpones an event, affected registrants will be notified by email. In this case, a full refund will be issued upon request, or your registration may be transferred to the rescheduled or replacement event — whichever you prefer. Refund requests for organiser-initiated cancellations must be submitted to ${orgEmail} within 30 days of the cancellation notice.`,
],
others: [
"When you register other people (for example family members) or give each person their own ticket, you confirm that:",
"• the details you enter are accurate, and you are allowed to share them with us for this purpose; and",
"• for anyone under 18, you are their parent or guardian or have their parent's or guardian's permission.",
"We will use the email address or phone number you enter to send that person their ticket, attendee form, or other event messages. There is no searching for other users — you must type their details yourself.",
"The person who registers and pays remains responsible for the registration and its payment.",
],
youth: [
"Some events are marked as youth events. Each attendee's date of birth is required, and for anyone under 18 a parent or guardian must give consent through the link we send them before the young person can be checked in.",
"The parent or guardian is responsible for the accuracy of any medical and emergency information they provide, and for telling the organisers about changes.",
"A supervisor may in exceptional cases check someone in without recorded consent; such decisions are logged.",
],
transfers: [
"Where an event allows it, the person who booked a ticket or the person currently holding it can offer the ticket to someone else before the event starts, provided the ticket has not been scanned. Supervisors may transfer tickets even where an event does not otherwise allow it.",
"You enter the new holder's name and email address and/or phone number. We send them a link to accept the ticket. Nothing changes until they accept; they may also decline, and you may cancel the offer until then. Offers expire after 7 days or when the event starts.",
"When the transfer is accepted, the new holder receives their own ticket with a new QR code, and the old QR code (including any copies or screenshots of it) stops working.",
"The person who booked stays responsible for the registration and its payment; a transfer does not move or refund any payment.",
"On youth events, the new holder needs their own parental consent, and any attendee form answers from the previous holder are removed.",
],
extras: [
"Discount codes: codes are issued at the organisers' discretion, may be limited to certain events, people, dates or numbers of uses, and may not be sold or shared where they are restricted. We may cancel a discount obtained through misuse.",
"Waitlists: joining a waitlist does not guarantee a place. If a place becomes available, it is offered to the next person for a limited time; if the offer is not claimed in time, it passes to the next person.",
"Sponsor a spot: a sponsorship is a donation towards places for people who couldn't otherwise attend. The organisers decide how sponsorship money is allocated, and sponsors are not told who benefits. Refunds of sponsorships are at the organisers' discretion.",
"Feedback: post-event feedback forms are optional. Please don't include other people's personal information in your answers.",
],
acceptable: [
"You agree not to:",
"• Use the website for any unlawful or fraudulent purpose.",
"• Impersonate another person or misrepresent your identity.",
"• Interfere with or disrupt the operation of the site.",
"• Attempt to gain unauthorised access to our systems.",
"• Resell, tout, or transfer your registration or tickets without written authorisation.",
"• Resell or tout tickets, or transfer them other than through the platform's transfer feature.",
"• Enter another person's details without being allowed to, or use the registration, transfer or messaging features to contact people who have not agreed to it.",
"• Use automated tools, bots, or scrapers to access or extract data from the site.",
],
communication: [
@@ -163,6 +198,10 @@ function Section({
"For accounts without a valid email address (e.g. walk-in or at-the-door registrations), these required communications will be delivered via WhatsApp if a mobile number is on file.",
"WhatsApp messages are delivered via WAWP, a third-party WhatsApp Business API provider. By enabling WhatsApp notifications, you consent to your mobile number being used to send you messages via this service.",
],
support: [
"Two-factor authentication is required for supervisors and administrators, and recommended for everyone.",
"To help solve a problem with your account (for example, a ticket not showing), an administrator may view the site as you see it, for up to 30 minutes. This support access is strictly read-only: nothing can be changed, registered, paid, cancelled or sent on your behalf. A reason must be given, and every session is recorded in an audit log. The organisation may choose to notify you by email whenever this happens.",
],
intellectual: [
`All content on the platform, including text, graphics, and logos, is owned by ${orgName} unless otherwise stated.`,
"You may not copy, modify, or distribute any content without written permission.",
@@ -173,7 +212,7 @@ function Section({
"We are not liable for any loss or damages arising from:",
"• Technical failures; or",
"• Misuse of the platform.",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 10 (Force Majeure).",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 15 (Force Majeure).",
],
forcemajeure: [
`${orgName} shall not be liable for any failure or delay in performing its obligations where such failure or delay results from causes beyond its reasonable control, including but not limited to: acts of God, natural disasters, government restrictions or regulations, public health emergencies, venue closures, or any other event outside our reasonable control ('force majeure event').`,
+7 -6
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { EventCard } from "@/components/events/EventCard";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
@@ -71,10 +72,10 @@ export default async function HomePage() {
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {displayName}</h1>
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
<Link href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
<Calendar className="w-4 h-4" />
View Events
</a>
</Link>
<JoinUsButton />
</div>
</div>
@@ -83,10 +84,10 @@ export default async function HomePage() {
<section className="py-12 px-4 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-gray-900">Upcoming Events</h2>
<a href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
<Link href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
View all events
<ArrowRight className="w-3.5 h-3.5" />
</a>
</Link>
</div>
{sorted.length === 0 ? (
<div className="text-center py-12 border rounded-xl bg-gray-50">
@@ -101,10 +102,10 @@ export default async function HomePage() {
</div>
)}
<div className="text-center mt-8 sm:hidden">
<a href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
<Link href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
View all events
<ArrowRight className="w-3.5 h-3.5" />
</a>
</Link>
</div>
</section>
@@ -7,6 +7,7 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { analyticsSessionId } from "@/lib/analytics";
import { errorMessage } from "@/lib/utils";
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
function lowStockThreshold(stockLimit: number): number {
@@ -18,7 +19,7 @@ function lowStockThreshold(stockLimit: number): number {
return Math.round(stockLimit * pct);
}
type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; order?: number };
type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; availableCount?: number; order?: number };
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
type EventOption = {
id: string;
@@ -31,7 +32,7 @@ type EventOption = {
variants?: OptionVariant[];
};
type Event = {
export type RegisterEvent = {
id: string;
title: string;
description?: string;
@@ -41,7 +42,8 @@ type Event = {
price: number;
picture?: string;
eventOptions?: EventOption[];
form?: { isRequired: boolean; fields?: any[] } | null;
form?: { isRequired: boolean; fields?: unknown[] } | null;
requiresRegistration?: boolean;
requiresAuth?: boolean;
allowNamedAttendees?: boolean;
requiresParentalConsent?: boolean;
@@ -61,7 +63,7 @@ function effectiveUnitForOption(opt: EventOption): number {
const now = new Date();
const applicable = tiers
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
.filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || (t as any).availableCount === undefined || (t as any).availableCount > 0))
.filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || t.availableCount === undefined || t.availableCount > 0))
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
return applicable.length > 0 ? applicable[0].price : base;
}
@@ -252,7 +254,7 @@ function OptionCard({
);
}
export default function RegisterForm({ event: baseEvent }: { event: Event }) {
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
const { token, user } = useAuth();
const eventId = baseEvent.id;
@@ -271,9 +273,9 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
setOffer(res);
setQuantities(q => ({ ...q, [res.variantId ? `${res.eventOptionId}:${res.variantId}` : res.eventOptionId]: res.quantity }));
})
.catch((e: any) => setOfferError(e?.message || "This waitlist link is not valid."));
.catch((e: unknown) => setOfferError(errorMessage(e) || "This waitlist link is not valid."));
}, []);
const event = useMemo<Event>(() => {
const event = useMemo<RegisterEvent>(() => {
if (!offer) return baseEvent;
return {
...baseEvent,
@@ -300,8 +302,6 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
return initial;
});
const [error, setError] = useState<string | null>(null);
const [registrationId, setRegistrationId] = useState<string | null>(null);
const [creatingCheckout, setCreatingCheckout] = useState(false);
const [creatingRegistration, setCreatingRegistration] = useState(false);
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
const [namingOthers, setNamingOthers] = useState(false);
@@ -397,8 +397,8 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
body: { eventId, options: items, waitlistToken, analyticsSessionId: analyticsSessionId(), ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
});
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
} catch (e: any) {
setError(e?.message || "Registration failed");
} catch (e) {
setError(errorMessage(e) || "Registration failed");
} finally {
setCreatingRegistration(false);
}
@@ -441,8 +441,8 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
}
window.location.href = next;
}
} catch (e: any) {
setError(e?.message || "Registration failed");
} catch (e) {
setError(errorMessage(e) || "Registration failed");
} finally {
setCreatingRegistration(false);
}
@@ -477,34 +477,13 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=0.00`;
}
}
} catch (e: any) {
setError(e?.message || "Registration failed");
} catch (e) {
setError(errorMessage(e) || "Registration failed");
} finally {
setCreatingRegistration(false);
}
};
const createYocoCheckout = async () => {
if (!token || !registrationId) return;
try {
setCreatingCheckout(true);
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/yoco-checkout", {
method: "POST",
body: {
registrationId,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
authToken: token,
});
window.open(res.redirectUrl, "_blank");
} catch (e: any) {
setError(e?.message || "Failed to create checkout");
} finally {
setCreatingCheckout(false);
}
};
return (
<>
@@ -582,11 +561,11 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
const now = new Date();
const upcomingTiers = (event.eventOptions || []).flatMap(opt =>
(opt.earlyBirdTiers || [])
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => t.deadline > now)
).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t) => t.deadline > now)
).sort((a, b) => a.deadline.getTime() - b.deadline.getTime());
if (upcomingTiers.length === 0) return null;
const soonest = upcomingTiers[0].deadline as Date;
const soonest = upcomingTiers[0].deadline;
const dateStr = soonest.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
return (
<div className="mt-4 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
@@ -628,12 +607,11 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
)}
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
</div>
{!registrationId ? (
eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
Sold Out
</button>
) : (
{eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
Sold Out
</button>
) : (
<button
onClick={submitRegistration}
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible && !waitlistToken)}
@@ -641,15 +619,6 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
>
{creatingRegistration ? "Registering..." : "Register"}
</button>
)
) : (
<button
onClick={createYocoCheckout}
disabled={creatingCheckout}
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
>
{creatingCheckout ? "Creating checkout..." : "Pay with Yoco"}
</button>
)}
</div>
+3 -3
View File
@@ -2,18 +2,18 @@ import { notFound, redirect } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import RegisterForm from "./RegisterForm";
import RegisterForm, { type RegisterEvent } from "./RegisterForm";
import { FunnelTracker } from "@/components/events/FunnelTracker";
export const revalidate = 60;
export default async function RegisterPage({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = await params;
let event: any;
let event: RegisterEvent;
try {
// Fetched server-side (same pattern as /events/[id]) so the form's content is part of
// the initial HTML instead of showing a loading skeleton after a client-side fetch.
event = await apiFetch<any>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
event = await apiFetch<RegisterEvent>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
if (e instanceof ApiError && e.status === 404) notFound();
throw e;
+11 -135
View File
@@ -1,11 +1,12 @@
"use client";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { CheckCircle2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
@@ -20,37 +21,25 @@ function RegistrationSuccessContent() {
return isNaN(n) ? 0 : n;
}, [fallbackTotalParam]);
const [reg, setReg] = React.useState<any | null>(null);
const [reg, setReg] = React.useState<Registration | null>(null);
const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = React.useState<Record<number, Record<string, string>>>({});
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [info, setInfo] = React.useState<string | null>(null);
const [payLoading, setPayLoading] = React.useState(false);
React.useEffect(() => {
(async () => {
setError(null);
setInfo(null);
if (!registrationId) return;
try {
setLoading(true);
// Try to fetch registration and event form
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<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
if (r) {
setReg(r);
const ev = await (await import('@/lib/api')).apiFetch(`/api/events/${encodeURIComponent(r.eventId)}`);
const ev = await (await import('@/lib/api')).apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(r.eventId)}`);
if (ev?.form) setForm(ev.form);
// Load any saved draft
try {
const d = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
if (d && d.data && typeof d.data === 'object') setFormsData(d.data);
} catch {}
}
} catch (e: any) {
setError(e?.message || 'Failed to load details');
} finally {
setLoading(false);
} catch (e) {
setError(errorMessage(e) || 'Failed to load details');
}
})();
}, [registrationId, token]);
@@ -61,7 +50,7 @@ function RegistrationSuccessContent() {
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
try {
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
return (reg.registrationOptions || []).reduce((s, ro) => {
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
? Number(ro.priceSnapshot)
: (ro.variant?.price ?? ro.eventOption?.price ?? 0);
@@ -98,15 +87,15 @@ function RegistrationSuccessContent() {
// 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 });
const r = await (await import('@/lib/api')).apiFetch<Registration>(`/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');
} catch (e) {
setError(errorMessage(e) || 'Failed to create checkout');
} finally {
setPayLoading(false);
}
@@ -161,119 +150,6 @@ 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; }) {
const { token } = useAuth();
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 count = Math.max(0, mainTickets);
const canSubmit = React.useMemo(() => {
if (!form || count <= 0) return false;
const reqFields = (form.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < count; i++) {
const data = formsData[i] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
return false;
}
}
}
return true;
}, [form, formsData, count]);
const update = (idx: number, fieldId: string, value: string) => {
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
};
const submit = async () => {
try {
setError(null); setInfo(null);
if (!token) { setError('Please login to submit forms.'); return; }
const payload = [] as any[];
for (let i = 0; i < count; i++) {
payload.push({ answers: formsData[i] || {} });
}
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
method: 'POST',
authToken: token,
body: { responses: payload }
});
setInfo('Attendee forms submitted. You will receive tickets once payment is confirmed.');
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
}
};
const saveDraft = async () => {
try {
setError(null); setInfo(null);
if (!token) { setError('Please login to save drafts.'); return; }
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
method: 'PUT',
authToken: token,
body: { data: formsData }
});
setInfo('Draft saved. You can finish later from your dashboard.');
} catch (e: any) {
setError(e?.message || 'Failed to save draft');
}
};
if (!form || !Array.isArray(form.fields) || count === 0) return null;
return (
<div className="border rounded p-3 bg-gray-50 mb-4">
<div className="text-sm font-medium mb-2">Attendee details</div>
<div className="space-y-4">
{Array.from({ length: count }, (_, idx) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {idx + 1}</div>
{form.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
) : f.type === 'paragraph' ? (
<div className="text-sm text-gray-700">
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
</div>
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => update(idx, f.id, v)} />
) : f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
) : f.type === 'date' ? (
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
)}
</div>
))}
</div>
))}
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit attendee forms</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
</div>
</div>
);
}
export default function RegistrationSuccessPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
+3 -2
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
function ResetPasswordContent() {
const search = useSearchParams();
@@ -26,8 +27,8 @@ function ResetPasswordContent() {
await apiFetch("/api/users/reset", { method: "POST", body: { token, password } });
setStatus("Your password has been reset. You can now log in.");
setTimeout(() => router.push("/login"), 1500);
} catch (err: any) {
setStatus(err?.message || "Reset failed");
} catch (err) {
setStatus(errorMessage(err) || "Reset failed");
} finally {
setLoading(false);
}
+25 -26
View File
@@ -3,9 +3,11 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -25,7 +27,7 @@ type FormField = {
isRequired: boolean;
order: number;
helpText?: string | null;
options?: any;
options?: unknown;
};
type EventForm = {
id: string;
@@ -187,7 +189,6 @@ export default function SelfServicePage() {
// ── Event form (attendee form) state ────────────────────────────
const [currentRegistrationId, setCurrentRegistrationId] = useState<string | null>(null);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [currentTicketIndex, setCurrentTicketIndex] = useState(0);
const [allFormAnswers, setAllFormAnswers] = useState<Record<string, string>[]>([]);
const [currentTicketAnswers, setCurrentTicketAnswers] = useState<Record<string, string>>({});
@@ -294,11 +295,11 @@ export default function SelfServicePage() {
// Second pass for accounts with two-factor on: exchange the code that was
// sent after the password step. The kiosk never asks to be remembered.
const data = setupChallenge
? await apiFetch("/api/users/login/2fa", {
? await apiFetch<AuthResponse>("/api/users/login/2fa", {
method: "POST",
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
})
: await apiFetch("/api/users/login", {
: await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
@@ -319,8 +320,8 @@ export default function SelfServicePage() {
setSetupCode("");
setSupervisorToken(data.token);
await loadEvents(data.token);
} catch (err: any) {
setSetupError(err?.message || "Login failed. Please check your credentials.");
} catch (err) {
setSetupError(errorMessage(err) || "Login failed. Please check your credentials.");
} finally {
setSetupLoading(false);
}
@@ -362,8 +363,8 @@ export default function SelfServicePage() {
setScreen("setup");
setSelectedEventId("");
resetForm();
} catch (err: any) {
setChangeError(err?.message || "Incorrect password.");
} catch (err) {
setChangeError(errorMessage(err) || "Incorrect password.");
} finally {
setChangeLoading(false);
}
@@ -405,7 +406,15 @@ export default function SelfServicePage() {
async function handleRegister() {
setFormLoading(true);
try {
const payload: any = {
const user: { name: string; email?: string; phoneNumber?: string; password?: string } = {
name: visitorName.trim(),
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
};
if (createAccount && visitorPassword) {
user.password = visitorPassword;
}
const payload: Record<string, unknown> = {
eventId: selectedEventId,
options: Object.entries(quantities)
.filter(([, qty]) => qty > 0)
@@ -413,11 +422,7 @@ export default function SelfServicePage() {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
}),
user: {
name: visitorName.trim(),
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
},
user,
// If the visitor declined to create an account, don't push an unsolicited
// activation email/WhatsApp at them afterward.
skipActivationNotice: !createAccount,
@@ -431,18 +436,13 @@ export default function SelfServicePage() {
payload.attendees = kioskNames;
payload.ticketDelivery = delivery;
}
if (createAccount && visitorPassword) {
payload.user.password = visitorPassword;
}
const result: any = await apiFetch("/api/registrations/manual", {
const result = await apiFetch<Registration>("/api/registrations/manual", {
method: "POST",
body: payload,
authToken: supervisorToken,
});
setCurrentRegistrationId(result?.id || null);
setCurrentUserId(result?.userId || null);
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
setConfirmationName(visitorName.trim());
@@ -457,8 +457,8 @@ export default function SelfServicePage() {
setScreen("confirmation");
startCountdown();
}
} catch (err: any) {
setFormError(err?.message || "Registration failed. Please try again.");
} catch (err) {
setFormError(errorMessage(err) || "Registration failed. Please try again.");
} finally {
setFormLoading(false);
}
@@ -505,8 +505,8 @@ export default function SelfServicePage() {
setScreen("confirmation");
startCountdown();
} catch (err: any) {
setFormSubmitError(err?.message || "Form submission failed. Please try again.");
} catch (err) {
setFormSubmitError(errorMessage(err) || "Form submission failed. Please try again.");
} finally {
setFormSubmitLoading(false);
}
@@ -562,7 +562,6 @@ export default function SelfServicePage() {
setLookupMessage(null);
setFormError(null);
setCurrentRegistrationId(null);
setCurrentUserId(null);
setCurrentTicketIndex(0);
setAllFormAnswers([]);
setCurrentTicketAnswers({});
+6 -5
View File
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { Megaphone } from "lucide-react";
import { errorMessage } from "@/lib/utils";
type BannerType = "info" | "warning" | "success" | "danger";
@@ -37,7 +38,7 @@ export default function SetBannerPage() {
// Load current banner
useEffect(() => {
apiFetch<any>("/api/banner")
apiFetch<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>("/api/banner")
.then(b => {
if (!b) return;
setMessage(b.message || "");
@@ -75,8 +76,8 @@ export default function SetBannerPage() {
},
});
setStatus("Banner saved.");
} catch (e: any) {
setError(e?.message || "Failed to save banner.");
} catch (e) {
setError(errorMessage(e) || "Failed to save banner.");
} finally {
setBusy(false);
}
@@ -98,8 +99,8 @@ export default function SetBannerPage() {
setLiveFrom("");
setLiveTill("");
setStatus("Banner cleared.");
} catch (e: any) {
setError(e?.message || "Failed to clear banner.");
} catch (e) {
setError(errorMessage(e) || "Failed to clear banner.");
} finally {
setBusy(false);
}
+10 -7
View File
@@ -6,6 +6,7 @@ import { apiFetch, API_BASE } from "@/lib/api";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
import { errorMessage } from "@/lib/utils";
type Step = 1 | 2 | 3 | 4 | 5 | 6;
@@ -100,7 +101,7 @@ export default function SetupPage() {
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
.then((d) => { if (!d?.needsSetup) router.replace("/login"); })
.catch(() => {});
}, []);
}, [router]);
const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
@@ -144,8 +145,8 @@ export default function SetupPage() {
});
setSetupToken(res.token);
next();
} catch (e: any) {
setError(e?.message || "Failed to create admin account. Please try again.");
} catch (e) {
setError(errorMessage(e) || "Failed to create admin account. Please try again.");
} finally {
setLoading(false);
}
@@ -173,8 +174,8 @@ export default function SetupPage() {
},
});
setSmtpTestResult({ ok: true, message: res.message || "Connection successful." });
} catch (e: any) {
setSmtpTestResult({ ok: false, message: e?.message || "Connection failed." });
} catch (e) {
setSmtpTestResult({ ok: false, message: errorMessage(e) || "Connection failed." });
} finally {
setSmtpTesting(false);
}
@@ -260,8 +261,8 @@ export default function SetupPage() {
});
next(); // → step 6 (done)
} catch (e: any) {
setError(e?.message || "Setup failed. Please try again.");
} catch (e) {
setError(errorMessage(e) || "Setup failed. Please try again.");
} finally {
setLoading(false);
}
@@ -386,6 +387,8 @@ export default function SetupPage() {
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used.">
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" />
{logoPreview && (
// Local file preview (data: URL) — nothing for next/image to optimize.
// eslint-disable-next-line @next/next/no-img-element
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
)}
{suggestedColors.length > 0 && (
+100
View File
@@ -0,0 +1,100 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { Ticket, CheckCircle2, XCircle } from "lucide-react";
import { errorMessage } from "@/lib/utils";
type Offer = {
status: "pending" | "accepted" | "declined" | "cancelled" | "expired";
toName: string;
fromName: string;
expiresAt: string;
option: string;
event: { id: string; title: string; startDate: string; location: string | null };
};
const STATUS_TEXT: Record<string, string> = {
accepted: "This ticket has already been accepted.",
declined: "This ticket offer was declined.",
cancelled: "This ticket offer was withdrawn.",
expired: "This ticket offer has expired.",
};
/** Public page the person a ticket is offered to opens from their email or WhatsApp. */
export default function TransferOfferPage() {
const params = useParams<{ token: string }>();
const token = params?.token;
const [offer, setOffer] = useState<Offer | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<{ ok: boolean; message: string } | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<Offer>(`/api/tickets/transfer-offer/${encodeURIComponent(token)}`)
.then(setOffer)
.catch(e => setLoadError(e?.message || "This transfer link isn't valid."));
}, [token]);
const answer = async (action: "accept" | "decline") => {
setBusy(true);
setError(null);
try {
const r = await apiFetch<{ message: string }>(`/api/tickets/transfer-offer/${encodeURIComponent(token!)}/${action}`, { method: "POST" });
setDone({ ok: action === "accept", message: r.message });
} catch (e) {
setError(errorMessage(e) || "Something went wrong");
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navbar />
<main className="flex-1 max-w-xl w-full mx-auto p-4 sm:p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">A ticket for you</h1>
</div>
{loadError && <div className="p-4 border rounded-xl bg-white text-sm text-gray-700">{loadError}</div>}
{done && (
<div className="p-5 border rounded-xl bg-white shadow-sm flex items-start gap-3">
{done.ok ? <CheckCircle2 className="w-6 h-6 text-green-600 shrink-0" /> : <XCircle className="w-6 h-6 text-gray-500 shrink-0" />}
<p className="text-sm text-gray-700">{done.message}</p>
</div>
)}
{offer && !done && (
<div className="p-5 border rounded-xl bg-white shadow-sm space-y-3">
<p className="text-sm text-gray-700">Hi {offer.toName.split(" ")[0]}, {offer.fromName || "someone"} would like to give you their ticket.</p>
<div className="rounded-lg bg-gray-50 p-3 text-sm">
<div className="font-semibold">{offer.event.title}</div>
<div className="text-gray-600">{formatDate(new Date(offer.event.startDate))}{offer.event.location ? ` · ${offer.event.location}` : ""}</div>
<div className="text-gray-600">{offer.option}</div>
</div>
{offer.status === "pending" ? (
<>
<p className="text-xs text-gray-500">Accepting sends you your own ticket and QR code. If you don&apos;t have an account with us yet, we&apos;ll create one and send you a link to activate it. This offer is open until {new Date(offer.expiresAt).toLocaleString()}.</p>
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<div className="flex gap-2">
<button onClick={() => answer("accept")} disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">{busy ? "Please wait…" : "Accept ticket"}</button>
<button onClick={() => answer("decline")} disabled={busy} className="px-4 py-2 rounded-lg border hover:bg-gray-50 disabled:opacity-50">Decline</button>
</div>
</>
) : (
<p className="text-sm text-gray-600">{STATUS_TEXT[offer.status]}</p>
)}
</div>
)}
</main>
<Footer />
</div>
);
}
+8 -7
View File
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { DatabaseBackup, Download } from "lucide-react";
import { errorMessage } from "@/lib/utils";
interface BackupEntry {
filename: string;
@@ -38,12 +39,12 @@ export function BackupsTab({ active }: { active: boolean }) {
]);
setBackups(list || []);
setRetainCount(allSettings?.backup_retain_count || "14");
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Failed to load backups" });
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load backups" });
} finally {
setLoading(false);
}
}, [token]);
}, [token, setMessage]);
const saveRetainCount = async () => {
if (!token) return;
@@ -51,8 +52,8 @@ export function BackupsTab({ active }: { active: boolean }) {
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
setMessage({ type: "ok", text: "Retention setting saved." });
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Failed to save retention setting" });
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to save retention setting" });
} finally {
setSavingRetain(false);
}
@@ -68,8 +69,8 @@ export function BackupsTab({ active }: { active: boolean }) {
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
await load();
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Backup failed" });
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Backup failed" });
} finally {
setRunning(false);
}
+355
View File
@@ -0,0 +1,355 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Copy, CreditCard, KeyRound, Trash2, Webhook } from "lucide-react";
import { errorMessage } from "@/lib/utils";
type Source = "db" | "env" | "none";
interface YocoConfig {
secretKeyMasked: string;
secretKeySource: Source;
mode: "test" | "live" | "unknown";
webhookSecretMasked: string;
webhookSecretSource: Source;
webhookId: string;
webhookUrl: string;
configured: boolean;
}
interface YocoWebhook {
id: string;
name: string;
url: string;
mode: "test" | "live";
isActive: boolean;
pointsHere: boolean;
}
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const primaryBtn =
"px-3 py-1.5 text-sm rounded-lg bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white font-medium";
const secondaryBtn = "px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50";
function ModeBadge({ mode }: { mode: string }) {
if (mode !== "test" && mode !== "live") return null;
return (
<span
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
mode === "live" ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"
}`}
>
{mode === "live" ? "Live" : "Test"}
</span>
);
}
function SourceNote({ source }: { source: Source }) {
if (source === "env") {
return <p className="text-xs text-amber-700 mt-1">From the server&apos;s .env file. Save it here to store it in the database instead.</p>;
}
if (source === "none") return <p className="text-xs text-gray-500 mt-1">Not set.</p>;
return null;
}
function Card({ icon: Icon, title, subtitle, children }: {
icon: React.ElementType; title: string; subtitle: string; children: React.ReactNode;
}) {
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-brand-600" />
<div>
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
<p className="text-xs text-gray-500">{subtitle}</p>
</div>
</div>
{children}
</div>
);
}
export function YocoTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [cfg, setCfg] = useState<YocoConfig | null>(null);
const [hooks, setHooks] = useState<YocoWebhook[]>([]);
const [hooksError, setHooksError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
// API key editor
const [editingKey, setEditingKey] = useState(false);
const [keyInput, setKeyInput] = useState("");
// Webhook secret: generate (register at Yoco) or enter by hand
const [secretMode, setSecretMode] = useState<"generate" | "enter">("generate");
const [hookName, setHookName] = useState("hope-events");
const [hookUrl, setHookUrl] = useState("");
const [secretInput, setSecretInput] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const loadHooks = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ webhooks: YocoWebhook[] }>("/api/yoco/webhooks", { authToken: token });
setHooks(res.webhooks || []);
setHooksError(null);
} catch (e) {
setHooks([]);
setHooksError(errorMessage(e) || "Could not load webhooks from Yoco");
}
}, [token]);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const c = await apiFetch<YocoConfig>("/api/yoco/config", { authToken: token });
setCfg(c);
setHookUrl((prev) => prev || c.webhookUrl);
if (c.secretKeySource !== "none") await loadHooks();
else { setHooks([]); setHooksError(null); }
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load Yoco settings" });
} finally {
setLoading(false);
}
}, [token, loadHooks, setMessage]);
useEffect(() => { if (active) load(); }, [active, load]);
const run = async (label: string, fn: () => Promise<string>) => {
setBusy(label);
setMessage(null);
try {
const text = await fn();
setMessage({ type: "ok", text });
await load();
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Something went wrong" });
} finally {
setBusy(null);
}
};
const saveKey = (value: string) => run("key", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { secretKey: value } });
setEditingKey(false);
setKeyInput("");
return value ? "Secret key saved." : "Secret key removed.";
});
const saveSecret = (value: string) => run("secret", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { webhookSecret: value } });
setSecretInput("");
return value ? "Webhook secret saved." : "Webhook secret removed.";
});
const generate = () => run("generate", async () => {
const res = await apiFetch<{ message: string }>("/api/yoco/webhooks", {
method: "POST", authToken: token!, body: { name: hookName.trim(), url: hookUrl.trim() },
});
return res.message;
});
const removeHook = (id: string) => run(`delete:${id}`, async () => {
const res = await apiFetch<{ message: string }>(`/api/yoco/webhooks/${encodeURIComponent(id)}`, {
method: "DELETE", authToken: token!,
});
setConfirmDelete(null);
return res.message;
});
const copy = (text: string) => {
navigator.clipboard?.writeText(text).then(
() => setMessage({ type: "ok", text: "Copied to clipboard." }),
() => setMessage({ type: "err", text: "Could not copy." }),
);
};
const hasKey = !!cfg && cfg.secretKeySource !== "none";
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<CreditCard className="w-4 h-4 text-brand-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Yoco payments</h2>
<p className="text-xs text-gray-500">Online card payments. Secrets are stored encrypted and never shown in full.</p>
</div>
</div>
{message && (
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{message.text}
</div>
)}
{loading && !cfg ? (
<p className="text-sm text-gray-400">Loading…</p>
) : cfg && (
<>
{/* ── API key ─────────────────────────────────────────────────── */}
<Card icon={KeyRound} title="Secret key" subtitle="From the Yoco app: Selling Online → Payment Gateway → API keys.">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-sm bg-gray-50 border rounded px-2 py-1">{cfg.secretKeyMasked || "—"}</code>
<ModeBadge mode={cfg.mode} />
{!editingKey && (
<button type="button" className={`${secondaryBtn} ml-auto`} onClick={() => setEditingKey(true)}>
{hasKey ? "Change" : "Add key"}
</button>
)}
</div>
<SourceNote source={cfg.secretKeySource} />
{editingKey && (
<div className="space-y-2">
<input
type="password"
autoComplete="off"
className={inputCls}
placeholder="sk_test_… or sk_live_…"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
/>
<div className="flex gap-2">
<button type="button" className={primaryBtn} disabled={!keyInput.trim() || busy === "key"} onClick={() => saveKey(keyInput.trim())}>
{busy === "key" ? "Checking…" : "Save"}
</button>
<button type="button" className={secondaryBtn} onClick={() => { setEditingKey(false); setKeyInput(""); }}>
Cancel
</button>
{cfg.secretKeySource === "db" && (
<button type="button" className={`${secondaryBtn} ml-auto text-red-600`} disabled={busy === "key"} onClick={() => saveKey("")}>
Remove
</button>
)}
</div>
<p className="text-xs text-gray-500">The key is checked with Yoco before it is saved.</p>
</div>
)}
</Card>
{/* ── Webhook secret ──────────────────────────────────────────── */}
<Card icon={Webhook} title="Webhook secret" subtitle="Used to check that payment notifications really come from Yoco.">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-sm bg-gray-50 border rounded px-2 py-1">{cfg.webhookSecretMasked || "—"}</code>
{cfg.webhookSecretSource === "db" && (
<button type="button" className={`${secondaryBtn} ml-auto text-red-600`} disabled={busy === "secret"} onClick={() => saveSecret("")}>
Remove
</button>
)}
</div>
<SourceNote source={cfg.webhookSecretSource} />
<div>
<label className="block text-xs text-gray-600 mb-1">Webhook endpoint on this server</label>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-50 border rounded px-2 py-1.5 flex-1 break-all">{cfg.webhookUrl || "—"}</code>
{cfg.webhookUrl && (
<button type="button" className={secondaryBtn} onClick={() => copy(cfg.webhookUrl)} aria-label="Copy webhook URL">
<Copy className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div className="flex gap-2 pt-1">
{(["generate", "enter"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setSecretMode(m)}
className={`px-3 py-1 text-xs rounded-lg border ${secretMode === m ? "bg-brand-50 border-brand-300 text-brand-700" : "bg-white border-gray-200 text-gray-600"}`}
>
{m === "generate" ? "Generate new" : "Enter manually"}
</button>
))}
</div>
{secretMode === "generate" ? (
hasKey ? (
<div className="space-y-2">
<p className="text-xs text-gray-500">Registers a webhook with Yoco and saves the secret it returns. Yoco only shows the secret once, so it is stored straight away.</p>
<div className="grid sm:grid-cols-3 gap-2">
<input className={inputCls} value={hookName} onChange={(e) => setHookName(e.target.value)} placeholder="Name" />
<input className={`${inputCls} sm:col-span-2`} value={hookUrl} onChange={(e) => setHookUrl(e.target.value)} placeholder="https://…/api/webhooks/yoco" />
</div>
<button type="button" className={primaryBtn} disabled={busy === "generate" || !hookUrl.trim()} onClick={generate}>
{busy === "generate" ? "Registering…" : "Register webhook"}
</button>
</div>
) : (
<p className="text-xs text-gray-500">Add the secret key first.</p>
)
) : (
<div className="space-y-2">
<p className="text-xs text-gray-500">Paste the secret of a webhook you registered yourself.</p>
<input
type="password"
autoComplete="off"
className={inputCls}
placeholder="whsec_…"
value={secretInput}
onChange={(e) => setSecretInput(e.target.value)}
/>
<button type="button" className={primaryBtn} disabled={!secretInput.trim() || busy === "secret"} onClick={() => saveSecret(secretInput.trim())}>
{busy === "secret" ? "Saving…" : "Save secret"}
</button>
</div>
)}
</Card>
{/* ── Registered webhooks ─────────────────────────────────────── */}
<Card icon={Webhook} title="Registered webhooks" subtitle="Webhooks on this Yoco account. Yoco allows up to 5 and recommends just one.">
{!hasKey ? (
<p className="text-xs text-gray-500">Add the secret key to see the webhooks.</p>
) : hooksError ? (
<p className="text-sm text-red-600">{hooksError}</p>
) : hooks.length === 0 ? (
<p className="text-sm text-gray-500">No webhooks registered.</p>
) : (
<ul className="divide-y border rounded-lg">
{hooks.map((h) => (
<li key={h.id} className="p-3 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-900">{h.name}</span>
<ModeBadge mode={h.mode} />
{h.isActive && <span className="text-[11px] font-medium px-1.5 py-0.5 rounded bg-brand-100 text-brand-700">Active</span>}
<div className="ml-auto flex gap-2">
{confirmDelete === h.id ? (
<>
<button type="button" className="px-3 py-1.5 text-sm rounded-lg bg-red-600 hover:bg-red-700 text-white disabled:opacity-50" disabled={busy === `delete:${h.id}`} onClick={() => removeHook(h.id)}>
{busy === `delete:${h.id}` ? "Deleting…" : "Delete"}
</button>
<button type="button" className={secondaryBtn} onClick={() => setConfirmDelete(null)}>Cancel</button>
</>
) : (
<button type="button" className={secondaryBtn} onClick={() => setConfirmDelete(h.id)} aria-label={`Delete ${h.name}`}>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<p className="text-xs text-gray-500 break-all">{h.url}</p>
{!h.pointsHere && <p className="text-xs text-amber-700">This URL isn&apos;t this server&apos;s webhook endpoint.</p>}
{confirmDelete === h.id && h.isActive && (
<p className="text-xs text-red-600">This is the active webhook. Deleting it also removes the stored secret, and payments won&apos;t be confirmed until a new one is set up.</p>
)}
</li>
))}
</ul>
)}
</Card>
</>
)}
</div>
);
}
+16 -23
View File
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { errorMessage } from "@/lib/utils";
// Matches the backend's minimum gap between code sends.
const RESEND_COOLDOWN_SECONDS = 60;
@@ -42,24 +43,16 @@ export function LoginForm() {
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
};
const getErrorMessage = (err: any) => {
if (!err) return "Login failed";
// If it's already a normal Error
if (err.message && typeof err.message === "string") {
// Handle case where message is accidentally JSON string
try {
const parsed = JSON.parse(err.message);
return parsed.message || err.message;
} catch {
return err.message;
}
const getErrorMessage = (err: unknown) => {
const msg = errorMessage(err);
if (!msg) return "Login failed";
// Handle case where message is accidentally a JSON string
try {
const parsed = JSON.parse(msg);
return parsed?.message || msg;
} catch {
return msg;
}
// If API response object was thrown directly
if (err.message) return err.message;
return "Login failed";
};
const onSubmit = async (e: React.FormEvent) => {
@@ -77,7 +70,7 @@ export function LoginForm() {
return;
}
goToDashboard(result);
} catch (err: any) {
} catch (err) {
setError(getErrorMessage(err) || "Login failed. Please try again.");
} finally {
setLoading(false);
@@ -92,7 +85,7 @@ export function LoginForm() {
try {
goToDashboard(await verifyTwoFactor(challenge.challengeId, code, rememberDevice));
} catch (err: any) {
} catch (err) {
setError(getErrorMessage(err) || "Verification failed. Please try again.");
} finally {
setLoading(false);
@@ -108,7 +101,7 @@ export function LoginForm() {
setChallenge({ ...next, enrolling: challenge.enrolling });
setCode("");
setResendIn(RESEND_COOLDOWN_SECONDS);
} catch (err: any) {
} catch (err) {
setError(getErrorMessage(err) || "Couldn't send a new code. Please try again.");
}
};
@@ -225,19 +218,19 @@ export function LoginForm() {
</button>
</div>
<div className="mt-1">
<a
<Link
href="/forgot-password"
className="text-xs text-brand-600 hover:underline"
>
Forgot your password?
</a>
</Link>
</div>
<div className="mt-1">
<a
href={registerHref}
className="text-xs text-brand-600 hover:underline"
>
Don't have an account? Sign up here.
Don&apos;t have an account? Sign up here.
</a>
</div>
</div>
+11 -18
View File
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { isValidZAPhone } from "@/lib/phone";
import { errorMessage } from "@/lib/utils";
export function RegisterForm() {
const { register } = useAuth();
@@ -23,24 +24,16 @@ export function RegisterForm() {
const hasValidPhone = isValidZAPhone(phoneNumber);
const getErrorMessage = (err: any) => {
if (!err) return "Login failed";
// If it's already a normal Error
if (err.message && typeof err.message === "string") {
// Handle case where message is accidentally JSON string
try {
const parsed = JSON.parse(err.message);
return parsed.message || err.message;
} catch {
return err.message;
}
const getErrorMessage = (err: unknown) => {
const msg = errorMessage(err);
if (!msg) return "Registration failed. Please try again.";
// Handle case where message is accidentally a JSON string
try {
const parsed = JSON.parse(msg);
return parsed?.message || msg;
} catch {
return msg;
}
// If API response object was thrown directly
if (err.message) return err.message;
return "Registration failed. Please try again.";
};
const onSubmit = async (e: React.FormEvent) => {
@@ -62,7 +55,7 @@ export function RegisterForm() {
notificationPreference: hasValidPhone ? notificationPreference : "email",
});
window.location.href = redirectTo;
} catch (err: any) {
} catch (err) {
setError(getErrorMessage(err) || "Registration failed. Please try again.");
} finally {
setLoading(false);
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { HeartHandshake, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
@@ -63,8 +64,8 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
},
});
window.location.href = res.redirectUrl;
} catch (err: any) {
setError(err?.message || "Couldn't start the payment");
} catch (err) {
setError(errorMessage(err) || "Couldn't start the payment");
setBusy(false);
}
};
@@ -3,6 +3,7 @@
import React, { useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void }) {
const { token } = useAuth();
@@ -23,8 +24,8 @@ export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void
authToken: token,
});
onUploaded(data.url);
} catch (e: any) {
setError(e?.message || "Upload failed");
} catch (e) {
setError(errorMessage(e) || "Upload failed");
} finally {
setLoading(false);
}
@@ -3,6 +3,7 @@
import Link from "next/link";
import { appName } from "@/lib/siteConfig";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
export const Footer = () => {
const { settings } = useSiteSettings();
@@ -19,6 +20,7 @@ export const Footer = () => {
<Link href="/legal/privacy" className="hover:underline text-gray-300">
Privacy Policy
</Link>
<CookieSettingsButton className="hover:underline text-gray-300" />
</div>
</footer>
{/* Spacer so content isn't hidden behind the fixed mobile bottom nav */}
+15 -12
View File
@@ -3,6 +3,7 @@
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import Webcam from "react-webcam";
import { useQRScanner } from "@/hooks/useQRScanner";
import { errorMessage } from "@/lib/utils";
export type QRScannerProps = {
onResult?: (text: string) => void;
@@ -27,11 +28,10 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
// Track the underlying HTMLVideoElement from react-webcam reliably
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
useEffect(() => {
let id: any;
let id: ReturnType<typeof setInterval> | undefined;
if (showCam) {
id = setInterval(() => {
const anyCam = webcamRef.current as any;
const v: HTMLVideoElement | null = anyCam?.video ?? null;
const v = webcamRef.current?.video ?? null;
if (v) { setVideoEl(v); clearInterval(id); }
}, 100);
} else {
@@ -42,13 +42,14 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
// Beep + cooldown state
const lastScanRef = useRef<number>(0);
const audioCtxRef = useRef<any>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const playBeep = () => {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
// Older Safari only has the prefixed constructor.
const AC = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
if (!audioCtxRef.current) audioCtxRef.current = new AC();
const ctx = audioCtxRef.current as AudioContext;
const ctx = audioCtxRef.current;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = "sine";
@@ -116,18 +117,20 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
async function startScan() {
const host = typeof window !== "undefined" ? window.location.hostname : "";
const isLocal = host === "localhost" || host === "127.0.0.1";
if (!(navigator as any)?.mediaDevices?.getUserMedia) {
// mediaDevices is missing on insecure origins and some older browsers.
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
setLocalError("Camera API is not available in this browser.");
return;
}
setLocalError(null);
try {
const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false } as any);
const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false });
try { warmup.getTracks().forEach(t => t.stop()); } catch {}
setShowCam(true);
if (!active) toggle();
} catch (e: any) {
const name = e?.name || e?.message;
} catch (e) {
// getUserMedia rejects with a DOMException whose `name` says why (NotAllowedError, ...).
const name = (e as { name?: string } | null)?.name || errorMessage(e);
if (name === "NotAllowedError") {
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
@@ -171,7 +174,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
className="absolute inset-0 w-full h-full object-cover"
onUserMedia={() => setLocalError(null)}
onUserMediaError={(e) => {
const name = (e as any)?.name || (e as any)?.message;
const name = typeof e === "string" ? e : e.name || e.message;
const protocol = typeof window !== "undefined" ? window.location.protocol : "";
const host = typeof window !== "undefined" ? window.location.hostname : "";
const isLocal = host === "localhost" || host === "127.0.0.1";
@@ -190,7 +193,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-white/80 text-sm p-4 text-center">
Camera is off. Tap "Scan Ticket" to start using the back camera.
Camera is off. Tap &quot;Scan Ticket&quot; to start using the back camera.
</div>
)}
</div>
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { Wifi, WifiOff, CloudUpload, Lock } from "lucide-react";
import { apiFetch } from "@/lib/api";
import type { OtherLock } from "./useOfflineScanning";
import { errorMessage } from "@/lib/utils";
type Offline = ReturnType<typeof import("./useOfflineScanning").useOfflineScanning>;
@@ -38,8 +39,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
try {
const n = await offline.enable(eventId, eventTitle, sectionId, sectionName);
onInfo(`Offline mode on — ${n} ticket(s) downloaded. You can keep scanning without a connection.`);
} catch (e: any) {
onError(e?.message || "Couldn't turn on offline mode");
} catch (e) {
onError(errorMessage(e) || "Couldn't turn on offline mode");
} finally {
setBusy(false);
}
@@ -49,8 +50,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
try {
await offline.release();
onInfo("Offline mode ended and the section is unlocked.");
} catch (e: any) {
onError(e?.message || "Couldn't end offline mode");
} catch (e) {
onError(errorMessage(e) || "Couldn't end offline mode");
} finally {
setBusy(false);
}
@@ -59,8 +60,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
try {
const r = await offline.sync();
if (r) onInfo(`Synced ${r.accepted} scan(s)${r.conflicts ? ` — ${r.conflicts} need a supervisor to review` : ""}.`);
} catch (e: any) {
onError(e?.message || "Sync failed — scans are kept on this device and will retry.");
} catch (e) {
onError(errorMessage(e) || "Sync failed — scans are kept on this device and will retry.");
}
};
@@ -57,7 +57,7 @@ export function useOfflineScanning(token: string | null) {
queue.forEach(q => byLock.set(q.lockId, [...(byLock.get(q.lockId) || []), q]));
let accepted = 0, conflicts = 0;
for (const [lockId, scans] of byLock) {
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: any[] }>(`/api/scan-locks/${lockId}/sync`, {
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: unknown[] }>(`/api/scan-locks/${lockId}/sync`, {
method: "POST", authToken: token, body: { deviceId: deviceId(), scans },
});
accepted += res.accepted;
@@ -34,5 +34,8 @@ export function ApiImage({ src, alt = "", className, style }: Props) {
// and then jump once resolved — same class of flash as an unauthenticated nav link showing too early.
if (!resolved) return <div className={`${className || ""} bg-gray-100 animate-pulse`} style={style} aria-hidden="true" />;
// A plain <img>: the API host is only known at runtime and the images have no fixed size,
// so next/image's optimizer (which needs both up front) doesn't fit here.
// eslint-disable-next-line @next/next/no-img-element
return <img src={resolved} alt={alt} className={className} style={style} />;
}
@@ -4,6 +4,8 @@ import React, { useMemo, useState } from "react";
import { Users, Send } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { Attendee } from "@/types";
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
pending: { text: "consent pending", cls: "text-amber-700" },
@@ -11,31 +13,47 @@ const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
declined: { text: "consent declined", cls: "text-red-700" },
};
type Attendee = {
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
ticket?: { id: string; isUsed: boolean } | null;
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
};
type Row = {
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
};
// The parts of a registration (with its attendees, options and event) this panel reads.
export type AttendeesRegistration = {
id: string;
ticketDelivery?: "payer" | "each";
attendees?: Attendee[];
event?: {
requiresParentalConsent?: boolean;
collectGender?: boolean;
allowNamedAttendees?: boolean;
startDate?: string;
} | null;
registrationOptions?: {
eventOptionId: string;
variantId?: string | null;
quantity: number;
eventOption?: { name?: string; isMainTicket?: boolean } | null;
variant?: { name?: string } | null;
}[];
formResponses?: { attendeeId?: string | null }[];
};
/**
* Named attendees on a registration (group tickets): shows who each main-ticket place
* is for, and lets the payer (or staff) name places, change details, choose where the
* tickets go, and send an attendee the link to their own form.
*/
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
registration: any;
registration: AttendeesRegistration;
token: string;
canEdit: boolean;
hasForm: boolean;
onSaved: () => void;
onMessage: (msg: string) => void;
}) {
const attendees: Attendee[] = registration.attendees || [];
const attendees = useMemo<Attendee[]>(() => registration.attendees || [], [registration.attendees]);
const youth = !!registration.event?.requiresParentalConsent;
const collectGender = !!registration.event?.collectGender;
const eventStart: string | undefined = registration.event?.startDate;
@@ -100,8 +118,8 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
setEditing(false);
onMessage("Attendees saved.");
onSaved();
} catch (e: any) {
setError(e?.message || "Couldn't save attendees");
} catch (e) {
setError(errorMessage(e) || "Couldn't save attendees");
} finally {
setBusy(false);
}
@@ -111,19 +129,19 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
try {
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
onMessage(res.message);
} catch (e: any) {
setError(e?.message || "Couldn't send the form link");
} catch (e) {
setError(errorMessage(e) || "Couldn't send the form link");
}
};
const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean));
const answered = new Set((registration.formResponses || []).map((r) => r.attendeeId).filter(Boolean));
const resendConsent = async (requestId: string) => {
try {
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
onMessage(res.message);
} catch (e: any) {
setError(e?.message || "Couldn't re-send the consent request");
} catch (e) {
setError(errorMessage(e) || "Couldn't re-send the consent request");
}
};

Some files were not shown because too many files have changed in this diff Show More