Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 4ca896130b TypeScript backend and /api/v2 foundations (1.51.0)
- tsconfig (allowJs, strict .ts), npm run build -> dist/, typecheck, dev via tsx, ts-jest.
- src/index.js is now a launcher (dist build when it matches, else tsx); server moved to app.js.
- /api/v2: defineRoute with zod -> request validation, response stripping, OpenAPI 3.1 at
  /docs/v2; one error shape and one paged-list shape. First endpoints: events, event, me,
  my registrations.
- deploy.sh/install.sh build the backend; rollback to older releases skips the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 08:48:54 +02:00
joshua d2d8cb1934 Merge branch 'chore/roadmap-ai-markup' 2026-10-29 08:29:15 +02:00
joshuaandClaude Opus 5.5 ad40618267 Roadmap: AI buttons follow the cap, markup on AI usage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 08:29:15 +02:00
joshua fc63454439 Merge branch 'feat/event-pickers-webhook-docs' 2026-10-29 08:23:09 +02:00
joshuaandClaude Opus 5.5 8f6f90a151 Searchable event picker and webhook guide (1.50.2)
- EventPicker replaces the plain event dropdowns on staff/supervisor pages and the kiosk:
  upcoming first, past behind "Show past events", search across all, keyboard support.
- Reports events list can be searched ("Select these N").
- Site settings -> Webhooks: "How webhooks work" guide with headers, signature checking
  (Node.js/PHP) and an example of every event type (EVENT_TYPES[].example, shape-tested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 08:23:09 +02:00
joshua dacd49652b Merge branch 'feat/long-lists' 2026-10-29 06:53:54 +02:00
joshuaandClaude Opus 5.5 4c066e67d9 Pagination and search on long lists (1.50.1)
Audit log: search (person, target, IP), role filter, every action in the
filter, inclusive To date. Data requests: search and paging (was cut at
200). Webhook deliveries: status filter and Load older. Supervisor Payments
loads only what it needs (unpaid registrations, recent/today/donation
payments, a person's own for refunds) instead of everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 06:53:54 +02:00
joshua 0ffbb13a1f Merge branch 'feat/accessibility' 2026-10-28 15:41:19 +02:00
joshuaandClaude Opus 5.5 e9d1b1fb24 Accessibility pass, WCAG 2.2 AA (1.50.0)
Public pages, auth, registration, payment, My Events and the kiosk: skip
link, focus ring, labels tied to fields, named quantity buttons, announced
errors, ModalOverlay popups (focus trap, Escape, focus return), contrast
and underlined in-text links, reduced motion. Branding warns about a primary
colour too light for white text and offers a darker shade. axe-core and
keyboard checks in e2e/accessibility.spec.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:41:19 +02:00
joshua 98173c1380 Merge branch 'chore/roadmap-no-afrikaans' 2026-10-28 15:02:22 +02:00
joshuaandClaude Opus 5.5 6297613d0d Roadmap: languages (Afrikaans) removed from scope
The accessibility pass becomes 1.50.0 and the TypeScript backend /
OpenAPI / api v2 1.51.0. Section 10 is kept for reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:02:22 +02:00
119 changed files with 6037 additions and 2529 deletions
+109
View File
@@ -7,6 +7,115 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.0] - 2026-10-29
### Added
- **API v2** (`/api/v2`), the groundwork for 2.0: every endpoint is declared once with zod
schemas (`src/v2/route.ts`). The declaration validates the request, strips the response to what
the schema lists, and generates the **OpenAPI 3.1** document (`GET /api/v2/openapi.json`, admin)
shown at **`/docs/v2`** (Swagger UI, served locally). One error shape
`{ error: { code, message, fields? } }` with stable codes, and one list shape
`{ data, meta: { page, limit, total, pages } }`. First endpoints (read-only): `GET /events`,
`GET /events/:id`, `GET /me`, `GET /me/registrations`. v1 (`/api/…`) is unchanged.
- **TypeScript in the backend:** `tsconfig.json` (`allowJs`; `.ts` strict), `npm run build` →
`dist/`, `npm run typecheck`, `npm run dev` through tsx, Jest runs `.ts` via ts-jest. Typed
doors into JS modules in `src/v2/legacy.ts`.
- Tests: `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts`.
### Changed
- The server moved from `backend/src/index.js` to `backend/src/app.js`. `src/index.js` is now a
launcher that runs the matching build in `dist/` (or compiles on the fly with tsx when there is
none), so existing PM2 configs keep working unchanged.
- `scripts/deploy.sh` and `scripts/install.sh` build the backend (`npm ci --include=dev`, then
`npm run build`); a rollback to a release before 1.51.0 skips the build. The first update to
1.51.0 by an older deploy script still starts (it compiles at start-up); the next update builds.
- e2e starts the backend with tsx.
## [1.50.2] - 2026-10-29
### Added
- **Searchable event picker** (`components/shared/EventPicker.tsx`) instead of the plain event
dropdowns on staff and supervisor pages (scanner, event tickets, at the door, payments, manual,
email and WhatsApp attendees, forms, attendance, consent, waitlist, allocations, bundles, offline
scans, kids and volunteer set-up, manage registrations, the kiosk). It opens on upcoming events
(soonest first); past ones are behind **Show past events** (latest first), and typing searches
every event by name, campus or date. Keyboard: arrows, Page Up/Down, Enter, Escape.
- **Reports:** the events list can be searched; "Select these N" / "Clear these" act on the matches.
- **Site settings → Webhooks → How webhooks work:** what is sent and when, the envelope and
headers, retries and duplicates, checking the signature (Node.js and PHP), Zapier/Make set-up,
and a copyable example of every event type. `GET /api/outbound-webhooks` event types now carry
an `example`; `tests/webhooks.test.js` checks each one has the same shape as the real payload.
### Changed
- Pages that listed every event ever made in their dropdown (email and WhatsApp attendees, forms,
bundles, scanner, payments) now open on upcoming ones; their separate "Include past events"
checkboxes are gone (the picker has its own). Event tickets and Payments can now reach past
events too.
- e2e: `pickEvent()` in `e2e/fixtures.ts`; `e2e/event-picker.spec.ts`.
## [1.50.1] - 2026-10-29
### Added
- **Audit log:** search by person (name or email), target (type or `#id`) or IP, a Role filter,
**Clear filters**, and "21–40 of 312" under the list. The Action filter now lists every action
that can be logged (it was missing 35 — campuses, kids check-in, volunteers, series, season
passes, webhooks, bulk actions, Hub updates, certificates and more), sorted by name.
- **Data requests:** search by name or email, and closed requests a page (50) at a time — the list
used to stop silently at 200.
- **Webhook delivery log:** filter by status (failed, retrying, succeeded) and **Load older** —
it used to show only the latest 50.
- API filters: `/api/registrations?unpaid=1&userId=`, `/api/payments?userId=&from=&donations=1`,
`/api/privacy/requests?search=&page=&limit=`, `/api/outbound-webhooks/:id/deliveries?before=`,
`/api/admin/audit-log?search=&role=`. Shared `components/shared/Pager.tsx`.
### Fixed
- Audit log: the **To** date left out that day's entries (it meant midnight at the start of the
day). Both dates are now whole days in the site's time zone.
### Changed
- **Payments** (supervisor) no longer downloads every payment and registration ever made when it
opens: it loads the latest payments, today's (for the totals), donations with what they were
applied to, and only registrations still owing. Refunds load the chosen person's own payments and
registrations when they're picked. Faster as the years add up; the page works the same.
## [1.50.0] - 2026-10-28
### Added
- **Accessibility pass (WCAG 2.2 AA)** on the public pages, sign-in/sign-up, registration,
payment, My Events and the self-service kiosk:
- **Keyboard:** a "Skip to main content" link as the first Tab stop on every page; a clear focus
ring (in the brand colour) on everything you can tab to; popups (Contact us, Join the waitlist,
Sponsor a spot, Transfer ticket, the registration popup and send dialogs on My Events, the kiosk's
Change Event and Confirm account changes) are announced as dialogs, take focus when they open,
keep Tab inside, close with Escape and hand focus back. The kiosk's "Create an account" switch
and its password eye can now be used with the keyboard.
- **Screen readers:** every form field on those pages has a label tied to it (sign-in, sign-up,
forgot/reset password, activation, profile, privacy centre, payments filters, donate, forms,
feedback, consent, waitlist, sponsorship, transfer, kiosk and its custom questions); ticket
quantity buttons say what they do ("Add one Adult", "Remove one Adult") and the number is
announced; option buttons say which one is chosen; error messages are announced when they
appear.
- **Colour:** light grey text darkened to meet 4.5:1, and links inside sentences are underlined
so they don't rely on colour alone. The page honours "reduce motion" on the device.
- **Branding:** Site settings → Branding (and the setup wizard) warn when the primary colour is
too light for white text and links (under 4.5:1) and offer a darker shade of it in one click.
- Automated accessibility checks: `e2e/accessibility.spec.ts` runs axe-core (WCAG 2.0–2.2 A/AA
rules) on those pages, plus keyboard tests (skip link, popup focus and Escape, ticket quantities,
the kiosk switch). New dev dependency `@axe-core/playwright`.
- `components/shared/ModalOverlay.tsx` for the app's own popups, and `components/layout/SkipLink.tsx`.
### Changed
- e2e tests click ticket quantities by name (`/^Add one /`) instead of "+".
## [1.49.2] - 2026-10-28
### Added
+2 -1
View File
@@ -305,7 +305,8 @@ cd /opt/hope-events
sudo -u hopeevents -H bash scripts/deploy.sh v1.41.2 # or no tag for the latest main
```
It backs up the database, checks out the release, installs, migrates, builds and reloads PM2,
It backs up the database, checks out the release, installs, migrates, builds (the backend too,
since 1.51.0) and reloads PM2,
and goes back to the previous version if anything fails first. `backend/.env`,
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
updates keep your changes from this guide.
+37 -10
View File
@@ -35,10 +35,11 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
19. [Volunteer rosters](#volunteer-rosters)
20. [Accounts & security](#accounts--security)
21. [Privacy (POPIA)](#privacy-popia)
22. [Help system](#help-system)
23. [Background jobs](#background-jobs)
24. [Operations](#operations)
25. [Page map](#page-map)
22. [Accessibility](#accessibility)
23. [Help system](#help-system)
24. [Background jobs](#background-jobs)
25. [Operations](#operations)
26. [Page map](#page-map)
---
@@ -414,6 +415,12 @@ logout controls. Disconnected sessions are recovered automatically.
## Supervisor tools
- **Choosing an event** (staff and supervisor pages: scanner, event tickets, at the door, payments,
manual registration, email and WhatsApp, forms, attendance, consent, waitlist, allocations,
bundles, offline scans, kids and volunteer set-up, manage registrations, kiosk): a searchable
picker instead of a long dropdown. It opens on upcoming events, soonest first; **Show past
events** lists past ones (latest first), and typing searches every event by name, campus or date.
Arrow keys, Enter and Escape work. `components/shared/EventPicker.tsx`.
- **Dashboard** — **Today** (each event or session on today: checked in so far
against places, children in now, volunteers here; money in today and this
week) and **Waiting for you** (unpaid registrations for the next two weeks,
@@ -488,7 +495,8 @@ logout controls. Disconnected sessions are recovered automatically.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
(created, edited, turned off, applied or removed by staff).
(created, edited, turned off, applied or removed by staff), and every later feature's admin
actions. Search by person, target or IP; filter by action, role and dates; 50 a page.
- **Site settings** — see below.
---
@@ -514,7 +522,7 @@ logout controls. Disconnected sessions are recovered automatically.
## Reports
A full-width workspace with shared filters (events, date range with presets,
A full-width workspace with shared filters (events, searchable, with "Select these N" for the matches; date range with presets,
include past/inactive/closed) and a searchable grid of reports, each opening in
a viewer with its own filters, stat tiles and bar charts.
@@ -545,7 +553,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|-----|----------|
| Organisation | Name, tagline, contact email/phone, address (with map link) |
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Branding | Primary/secondary/accent colours (with a readability warning and a darker shade offered when the primary is too light), logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Notifications | Registrations inbox and admin notification emails |
| 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 |
@@ -773,7 +781,7 @@ contact page shows the Information Officer.
and the people affected were told, and drafts of both notifications.
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
**For admins** — **Data requests** (`/dashboard/admin/data-requests`, searchable, closed ones paged): correction and
deletion requests with a 30-day due date, emailed to the Information Officer and
admins, reminded daily when due within a week. Completing a deletion anonymises the
person: name, contact details, the people they registered, form answers, consent
@@ -784,6 +792,18 @@ log.
---
## Accessibility
Built to WCAG 2.2 AA on the public pages, sign-in, registration, payment, My Events and the
self-service kiosk (1.50.0): "Skip to main content" link, visible keyboard focus, labelled fields,
named buttons (ticket quantities say "Add one …"), errors announced as they appear, popups that
trap focus and close with Escape, readable contrast with underlined in-text links, and "reduce
motion" respected. Branding warns when the primary colour is too light for white text and
suggests a darker shade. axe-core checks and keyboard tests in the e2e suite
(`e2e/accessibility.spec.ts`) keep it that way.
---
## Help system
A floating **"Need help?"** button on every page opens a page-specific guide
@@ -803,11 +823,15 @@ Site settings → **Webhooks** (admins): send what happens to other systems as i
check-in with session, person, event); headers `X-CrossEvents-Event`, `X-CrossEvents-Delivery` and
`X-CrossEvents-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">` with the endpoint's secret.
- **Delivery log** per endpoint (30 days): status, tries, response code or error, the full message,
**Resend**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
**Resend**; filter by status and **Load older**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
- Every path that registers, pays, cancels, refunds or checks in is covered (Prisma client
extension); rolled-back changes send nothing. Kids check-in and volunteer tickets are never sent.
- https and public addresses only (DNS checked at each send, redirects not followed). Each endpoint
is added to the operator register; deliveries are deleted after 30 days and with an account.
- **How webhooks work** (1.50.2), a guide on the tab (open until the first webhook is added): the
envelope and headers, retries and duplicates, checking the signature (Node.js and PHP examples),
Zapier/Make set-up, and a copyable example message for every event type. The examples come from
`EVENT_TYPES[].example` in `utils/webhooks.js`; a test keeps them the same shape as what is sent.
## Background jobs
@@ -865,7 +889,10 @@ can't start, the same jobs run on in-process timers.
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
set.
- Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`).
- Backend status page (`/`) and built-in API docs page (`/docs`); **API v2** (`/api/v2`, 1.51.0)
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins).
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
deploys build `dist/`, which `src/index.js` runs.
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
frontend against a separate, freshly seeded database: public pages, free and paid
+1 -1
View File
@@ -23,7 +23,7 @@ in-process settings cache TTL) without a server restart.
| Setting | Default when unset | Notes |
|---|---|---|
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. The Branding tab warns when it is under 4.5:1 against white (too light for white text and links) and offers a darker shade (1.50.0). |
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
+35
View File
@@ -12,6 +12,41 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.51.0
- **Groundwork for Cross Events 2.0.** Behind the scenes, the server is moving to a newer, safer
way of talking to the app, ready for the AI helpers in 2.0. Nothing changes in how you use
Cross Events.
## 1.50.2
- **Finding an event quickly.** Wherever staff and supervisors choose an event (the scanner, at
the door, payments, messages, forms, attendance and more) there is now a search box. The list
opens on upcoming events, past ones are one click away, and typing finds any event by name,
campus or date. The events list in Reports can be searched too.
- **Webhook guide.** Site settings → Webhooks has a "How webhooks work" guide for whoever connects
another system: what is sent and when, how to check it came from you, and an example message for
every kind of event.
## 1.50.1
- **Finding things in long lists.** The audit log can be searched by person, what was changed or
IP address, filtered by role, and its action list now includes every kind of action. Data
requests can be searched and come a page at a time, and the webhook delivery log can show only
failed deliveries and load older ones.
- **Fix:** the audit log's "To" date now includes that day.
- **Faster Payments page** for churches with a few years of payments behind them.
## 1.50.0
- **Easier for everyone to use.** The public pages, registration, payment, My Events and the
self-service kiosk now work properly with a keyboard and with screen readers: a "Skip to main
content" link, a clear outline showing where you are, popups that can be closed with Escape, and
every form field, button and error message read out correctly. Faint grey text is darker and
links in sentences are underlined, so they're easier to read.
- **Branding check.** If the main colour you pick in Site settings → Branding is too light for
white text to be readable, you'll see a warning and can switch to a darker shade with one click.
## 1.49.2
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
+11 -5
View File
@@ -2,6 +2,12 @@
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` on the running backend.
> **API v2 (1.51.0 on):** `/api/v2` is described by OpenAPI 3.1. The reference is at `/docs/v2`
> (admin token), the document at `GET /api/v2/openapi.json` (admin). It is generated from the route
> declarations, so it is not repeated here. v2 responses are `{ data }` (lists add
> `meta: { page, limit, total, pages }`, `?page=&limit=` up to 100) and errors
> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`), which stays until 2.0.
All authenticated requests require:
```
Authorization: Bearer <jwt>
@@ -243,7 +249,7 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
| PUT | `/api/registrations/:id/forms/draft` | optional | Save form draft |
| POST | `/api/registrations/:id/forms/responses` | optional | Submit form responses |
| PUT | `/api/registrations/:id/forms/responses` | supervisor+ | Replace form responses |
| GET | `/api/registrations` | staff+ | All registrations |
| GET | `/api/registrations?page=&limit=&unpaid=1&userId=` | staff+ | All registrations (a plain array; paged `{ data, total, page, limit, pages }` when `page` or `limit` is given). `unpaid=1`: still owing (pending, confirmed, partially paid); `userId`: one person's |
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
@@ -570,7 +576,7 @@ The data subject's tools, unsubscribe links and the admin data-requests inbox (`
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
| GET | `/api/privacy/requests?status=open\|closed\|all&search=&page=&limit=` | admin | Data requests (not self-service downloads), 50 a page (`total`, `pages`), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended). `search` matches name or email |
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
@@ -643,7 +649,7 @@ Options and variants also accept an optional `staffPrice`, charged automatically
| GET | `/api/payments/mypayments` | user+ | Own payments |
| GET | `/api/payments/:id` | user+ | Payment by ID |
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
| GET | `/api/payments` | supervisor+ | All payments |
| GET | `/api/payments?page=&limit=&userId=&from=&donations=1` | supervisor+ | Payments, newest first, paged (`limit` up to 200). `userId`: one person's; `from`: created on/after (ISO); `donations=1`: donations and the legs applying them |
| GET | `/api/payments/event/:eventId` | staff+ | Payments for an event |
| PUT | `/api/payments/assign-donation` | supervisor+ | Assign a donation payment to a registration |
| POST | `/api/payments/refund` | supervisor+ | Create refund record |
@@ -999,13 +1005,13 @@ where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anythi
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types |
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types (each with an `example` of its `data`) |
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
| POST | `/api/outbound-webhooks/:id/rotate-secret` | `{ secret }` |
| POST | `/api/outbound-webhooks/:id/test` | `{ status, statusCode, error }` |
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=` | Delivery log |
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=&before=` | Delivery log, newest first; `before` (a `createdAt`) loads older ones |
| POST | `/api/outbound-webhooks/deliveries/:id/resend` | Send again now |
## Season passes
+12 -3
View File
@@ -152,14 +152,22 @@ npm run prisma:generate
## Running the Server
```bash
# Development (auto-reload)
# Development (auto-reload; runs the TypeScript in src/ through tsx)
npm run dev
# Production
# Production: build once per update, then start (src/index.js runs the build in dist/)
npm run build
npm start
# Type-check only
npm run typecheck
```
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for full API documentation (admin only).
Since 1.51.0 the backend is partly TypeScript (`allowJs`: `.js` compiles as it is, `.ts` is checked
strictly). `src/index.js` is a small launcher: with plain Node it runs `dist/` when `dist/build.json`
matches this version, otherwise it compiles on the fly with tsx. The server itself is `src/app.js`.
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for the v1 API documentation and `/docs/v2` for the v2 (OpenAPI) reference — both admin only.
---
@@ -532,6 +540,7 @@ Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with
### PM2 (recommended)
```bash
npm install -g pm2
npm run build
pm2 start src/index.js --name hope-events-api
pm2 save && pm2 startup
```
+5 -1
View File
@@ -1,4 +1,8 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
testMatch: ['**/tests/**/*.test.js', '**/tests/**/*.test.ts'],
// TypeScript (src/**/*.ts and tests) runs through ts-jest; plain JS is left as it is.
transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }] },
moduleFileExtensions: ['ts', 'js', 'json'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
};
+1073 -11
View File
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -1,11 +1,11 @@
{
"name": "event-management-backend",
"version": "1.49.2",
"version": "1.51.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"dev": "tsx watch src/index.js",
"test": "jest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
@@ -14,7 +14,9 @@
"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",
"set-demo": "node scripts/set-demo.js"
"set-demo": "node scripts/set-demo.js",
"build": "tsc -p tsconfig.json && node -e \"require('fs').writeFileSync('dist/build.json', JSON.stringify({ version: require('./package.json').version, builtAt: new Date().toISOString() }))\"",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
@@ -37,11 +39,23 @@
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
"swagger-ui-dist": "^5.34.0",
"tsx": "^4.23.15",
"typescript": "^5.9.3",
"uuid": "^11.1.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/express": "^4.17.25",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.43",
"@types/supertest": "^7.2.1",
"@types/swagger-ui-dist": "^3.30.6",
"jest": "^30.4.2",
"prisma": "^7.10.1"
"prisma": "^7.10.1",
"supertest": "^7.3.1",
"ts-jest": "^29.4.15"
},
"overrides": {
"uuid": "^11.1.1",
@@ -54,6 +68,8 @@
"prisma@7.10.1": true,
"@prisma/engines@7.10.1": true,
"unrs-resolver@1.12.2": true,
"@parcel/watcher": false
"@parcel/watcher": false,
"@scarf/scarf": false,
"esbuild": false
}
}
+1812
View File
File diff suppressed because it is too large Load Diff
@@ -6,8 +6,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
// @access Admin
const listAuditLog = async (req, res) => {
try {
const { page, limit, actorId, action, from, to } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
const { page, limit, actorId, action, role, from, to, search } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, role, from, to, search });
res.json(result);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
@@ -154,6 +154,9 @@ const test = async (req, res) => {
const deliveries = async (req, res) => {
try {
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
// "Load older": the next page is everything before the last one shown.
const before = req.query.before ? new Date(String(req.query.before)) : null;
if (before && !Number.isNaN(before.getTime())) where.createdAt = { lt: before };
const rows = await prisma.webhookDelivery.findMany({
where, orderBy: { createdAt: 'desc' }, take: Math.min(200, parseInt(req.query.limit, 10) || 50),
select: { id: true, eventId: true, type: true, status: true, attempts: true, nextAttemptAt: true, lastStatusCode: true, lastError: true, deliveredAt: true, createdAt: true, payload: true },
+9 -1
View File
@@ -354,7 +354,15 @@ const getPayments = async (req, res) => {
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
// its registration's), so unlinked donations aren't listed for them.
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
const scopeWhere = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
// Narrowing filters: ?userId= (one person's), ?from= (on or after, e.g. today's start),
// ?donations=1 (donations and the legs that apply them to registrations).
const filters = [];
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
const from = req.query.from ? new Date(String(req.query.from)) : null;
if (from && !Number.isNaN(from.getTime())) filters.push({ createdAt: { gte: from } });
if (['1', 'true'].includes(String(req.query.donations || ''))) filters.push({ OR: [{ isDonation: true }, { originalPaymentId: { not: null } }] });
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
const [payments, total] = await prisma.$transaction([
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.payment.count({ where })
+11 -5
View File
@@ -227,12 +227,18 @@ const listRequests = async (req, res) => {
const where = { selfService: false };
if (status === 'open') where.status = 'open';
else if (status === 'closed') where.status = { not: 'open' };
const rows = await prisma.dataRequest.findMany({
const q = String(req.query.search || '').trim().slice(0, 100);
if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }];
// Closed requests pile up over the years, so they come a page at a time (newest first).
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const [rows, total] = await Promise.all([prisma.dataRequest.findMany({
where,
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
take: 200,
take: limit,
skip: (page - 1) * limit,
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
});
}), prisma.dataRequest.count({ where })]);
const requests = await Promise.all(rows.map(async r => ({
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
@@ -240,8 +246,8 @@ const listRequests = async (req, res) => {
overdue: r.status === 'open' && r.dueAt < new Date(),
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
})));
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount });
const openCount = status === 'open' && !q ? total : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount, total, page, limit, pages: Math.max(1, Math.ceil(total / limit)) });
} catch (error) {
fail(res, error);
}
@@ -569,7 +569,13 @@ const getRegistrations = async (req, res) => {
// array of every registration) working unchanged; callers that pass either param get back
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
// Campus-scoped staff: registrations for events they can see.
const where = await require('../utils/campusScope').listWhere(req, 'event');
const scopeWhere = await require('../utils/campusScope').listWhere(req, 'event');
// Narrowing filters, so callers needn't download every registration ever made:
// ?unpaid=1 — still owing (not paid or cancelled); ?userId= — one person's.
const filters = [];
if (['1', 'true'].includes(String(req.query.unpaid || ''))) filters.push({ status: { in: ['pending', 'confirmed', 'partial_paid'] } });
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
const registrations = await prisma.registration.findMany({ where, include });
return res.json(attachComputedTotalsToList(registrations));
+26 -1800
View File
File diff suppressed because it is too large Load Diff
+33 -5
View File
@@ -21,16 +21,44 @@ async function logAdminAction({ actorId, actorRole, action, targetType, targetId
}
}
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 24 * 60 * 60 * 1000;
// A date (YYYY-MM-DD) means midnight in the server's time zone (APP_TIMEZONE), not UTC.
const startOf = (v) => new Date(DATE_ONLY.test(v) ? `${v}T00:00:00` : v);
// The Prisma filter for the audit-log page. Dates (YYYY-MM-DD) are whole days, so `to`
// includes that day. `search` matches the person (name or email), the target type or id,
// or the IP.
function auditLogWhere({ actorId, action, role, from, to, search } = {}) {
const where = {};
if (actorId) where.actorId = actorId;
if (action) where.action = action;
if (role) where.actorRole = role;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
if (to) where.createdAt.lte = new Date(to);
if (from) where.createdAt.gte = startOf(from);
if (to) {
if (DATE_ONLY.test(to)) where.createdAt.lt = new Date(startOf(to).getTime() + DAY_MS);
else where.createdAt.lte = new Date(to);
}
}
const q = String(search || '').trim().slice(0, 100);
if (q) {
const contains = { contains: q, mode: 'insensitive' };
where.OR = [
{ actor: { name: contains } },
{ actor: { email: contains } },
{ targetType: contains },
{ targetId: { startsWith: q.replace(/^#/, '') } },
{ ip: { startsWith: q } },
];
}
return where;
}
// Paginated listing for the admin audit-log page.
async function getAdminAuditLog({ page = 1, limit = 50, ...filters } = {}) {
const where = auditLogWhere(filters);
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
@@ -49,4 +77,4 @@ async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, t
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
}
module.exports = { logAdminAction, getAdminAuditLog };
module.exports = { logAdminAction, getAdminAuditLog, auditLogWhere };
+42
View File
@@ -34,6 +34,48 @@ const EVENT_TYPES = [
];
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
// What `data` looks like for each type — shown in Site settings → Webhooks so whoever builds the
// receiving side knows what arrives. tests/webhooks.test.js checks these match buildPayload.
const EXAMPLE_EVENT = { id: 'c1a2b3c4-0000-4000-8000-000000000001', title: 'Youth Camp 2026', startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z' };
const EXAMPLE_PERSON = { id: 'u1a2b3c4-0000-4000-8000-000000000002', name: 'Thandi Mokoena', email: 'thandi@example.com', phone: '+27821234567' };
const exampleRegistration = (status, amountPaid) => ({
registration: {
id: 'r1a2b3c4-0000-4000-8000-000000000003', status, createdAt: '2026-10-29T08:15:00.000Z', totalDue: 1200, amountPaid,
seasonPassPurchaseId: null,
items: [{ ticket: 'Camper', variant: 'Bunk bed', quantity: 2, unitPrice: 600 }],
},
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
});
const examplePayment = (amount) => ({
payment: { id: 'p1a2b3c4-0000-4000-8000-000000000004', amount, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1a2b3c4-0000-4000-8000-000000000003', createdAt: '2026-10-29T08:16:00.000Z' },
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
});
const EXAMPLES = {
'registration.created': exampleRegistration('pending', 0),
'registration.paid': exampleRegistration('paid', 1200),
'registration.cancelled': exampleRegistration('cancelled', 0),
'payment.received': examplePayment(1200),
// Refunds are recorded as negative payments.
'payment.refunded': examplePayment(-600),
'ticket.checked_in': {
checkIn: { id: 't1a2b3c4-0000-4000-8000-000000000005', scannedAt: '2026-12-04T16:20:00.000Z', people: 1, manual: false, offline: false, session: { id: 's1a2b3c4-0000-4000-8000-000000000006', label: 'Friday evening', startsAt: '2026-12-04T16:00:00.000Z' } },
ticket: { id: 'k1a2b3c4-0000-4000-8000-000000000007', registrationOptionId: 'o1a2b3c4-0000-4000-8000-000000000008', holderName: 'Thandi Mokoena' },
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
},
'user.created': { person: { ...EXAMPLE_PERSON, role: 'user', createdAt: '2026-10-29T08:10:00.000Z' } },
'event.published': {
event: {
...EXAMPLE_EVENT, description: 'A weekend away for grades 8–12.', location: 'Camp Joy, Magaliesburg', price: 600,
goLiveAt: '2026-10-29T06:00:00.000Z', url: 'https://events.example.org/events/c1a2b3c4-0000-4000-8000-000000000001',
campus: { name: 'North', slug: 'north' }, series: null,
},
},
};
for (const t of EVENT_TYPES) t.example = EXAMPLES[t.type];
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
const KEEP_DAYS = 30;
+82
View File
@@ -0,0 +1,82 @@
import type { ErrorRequestHandler, RequestHandler } from 'express';
import { ZodError } from 'zod';
/**
* /api/v2 errors all look the same:
* { "error": { "code": "VALIDATION_FAILED", "message": "…", "fields": { "limit": "…" } } }
* `code` is stable (clients switch on it), `message` is for people, `fields` only on validation.
*/
export type ErrorCode =
| 'VALIDATION_FAILED'
| 'UNAUTHENTICATED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'RATE_LIMITED'
| 'READ_ONLY'
| 'INTERNAL';
export const ERROR_STATUS: Record<ErrorCode, number> = {
VALIDATION_FAILED: 400,
UNAUTHENTICATED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
RATE_LIMITED: 429,
READ_ONLY: 423,
INTERNAL: 500,
};
export class ApiError extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly fields?: Record<string, string>;
constructor(code: ErrorCode, message: string, fields?: Record<string, string>) {
super(message);
this.code = code;
this.status = ERROR_STATUS[code];
this.fields = fields;
}
}
export const notFound = (what = 'That'): ApiError => new ApiError('NOT_FOUND', `${what} wasn't found.`);
/** "limit" or "items.0.quantity" → the first problem with it. */
export function zodFields(err: ZodError): Record<string, string> {
const fields: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.length ? issue.path.join('.') : '_';
if (!fields[key]) fields[key] = issue.message;
}
return fields;
}
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' };
const PRISMA_CODE: Record<string, ErrorCode> = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' };
type LegacyError = Error & { statusCode?: number; status?: number; code?: string };
/** Turns anything thrown (ApiError, zod, Prisma, the v1 auth middleware's errors) into the v2 shape. */
export function toApiError(err: unknown, responseStatus: number): ApiError {
if (err instanceof ApiError) return err;
if (err instanceof ZodError) return new ApiError('VALIDATION_FAILED', 'Some of what was sent is not valid.', zodFields(err));
const e = (err || {}) as LegacyError;
if (typeof e.code === 'string' && PRISMA_CODE[e.code]) {
return new ApiError(PRISMA_CODE[e.code], e.code === 'P2002' ? 'A record with that value already exists.' : e.code === 'P2025' ? 'Record not found.' : 'A related record prevents this.');
}
// The shared v1 middleware (protect, staff…) sets res.status() and passes an Error;
// utils throw errors with statusCode. Those messages are written for people already.
const status = e.statusCode || e.status || (responseStatus >= 400 ? responseStatus : 500);
const code = STATUS_CODE[status];
if (code) return new ApiError(code, e.message || 'Request failed.');
return new ApiError('INTERNAL', 'Something went wrong on the server.');
}
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const apiErr = toApiError(err, res.statusCode);
if (apiErr.code === 'INTERNAL') console.error('[api/v2]', req.method, req.originalUrl, err);
res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}) } });
};
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
+92
View File
@@ -0,0 +1,92 @@
import express, { Router } from 'express';
import path from 'path';
import { mount, routes } from './route';
import { buildOpenApi } from './openapi';
import { errorHandler, notFoundHandler } from './errors';
import auth from '../middleware/authMiddleware';
// Resources: importing one registers its routes.
import './resources/events';
import './resources/me';
const pkg = require('../../package.json') as { version: string };
/** /api/v2 — see route.ts for how endpoints are declared, openapi.ts for the docs. */
export function v2Router(): Router {
const router = express.Router();
// The API description is for the people building on it (admins), like /docs.
router.get('/openapi.json', auth.protect, auth.admin, (_req, res) => {
res.json(buildOpenApi({ version: pkg.version }));
});
mount(router, routes);
router.use(notFoundHandler);
router.use(errorHandler);
return router;
}
/**
* /docs/v2: Swagger UI (served from swagger-ui-dist, nothing from a CDN). The page itself is
* public; it asks for an admin token and uses it to load openapi.json and to try requests.
*/
export function v2DocsRouter(): Router {
const router = express.Router();
const assets = path.dirname(require.resolve('swagger-ui-dist/package.json'));
router.use('/assets', express.static(assets, { index: false, maxAge: '1d' }));
router.get('/', (_req, res) => {
res.type('html').send(DOCS_HTML);
});
return router;
}
const DOCS_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cross Events API v2</title>
<link rel="stylesheet" href="/docs/v2/assets/swagger-ui.css">
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
#login { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
#login input { width: 100%; padding: .5rem; font: inherit; border: 1px solid #9ca3af; border-radius: 6px; box-sizing: border-box; }
#login button { margin-top: .75rem; padding: .5rem 1rem; font: inherit; background: #1d4ed8; color: #fff; border: 0; border-radius: 6px; cursor: pointer; }
#login .err { color: #b91c1c; margin-top: .5rem; }
</style>
</head>
<body>
<main id="login" hidden>
<h1>Cross Events API v2</h1>
<p>The API reference is for admins. Paste your admin token (after signing in, it's under <code>hope_events_token</code> in the browser's local storage).</p>
<label for="token">Admin token</label>
<input id="token" autocomplete="off">
<button id="go" type="button">Show the API</button>
<p class="err" id="err" role="alert"></p>
</main>
<div id="ui"></div>
<script src="/docs/v2/assets/swagger-ui-bundle.js"></script>
<script>
(function () {
var params = new URLSearchParams(location.search);
var token = params.get('token') || sessionStorage.getItem('v2docs_token') || localStorage.getItem('hope_events_token') || '';
function show(t) {
fetch('/api/v2/openapi.json', { headers: { Authorization: 'Bearer ' + t } }).then(function (r) {
if (!r.ok) return r.json().then(function (b) { throw new Error((b.error && b.error.message) || r.statusText); });
return r.json();
}).then(function (spec) {
sessionStorage.setItem('v2docs_token', t);
if (params.has('token')) history.replaceState(null, '', location.pathname);
document.getElementById('login').hidden = true;
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
ui.preauthorizeApiKey('bearerAuth', t);
}).catch(function (e) {
document.getElementById('login').hidden = false;
document.getElementById('err').textContent = t ? 'That token did not work: ' + e.message : '';
});
}
document.getElementById('go').addEventListener('click', function () { show(document.getElementById('token').value.trim()); });
show(token);
})();
</script>
</body>
</html>`;
+32
View File
@@ -0,0 +1,32 @@
/**
* Typed doors into the JavaScript side of the backend. TypeScript infers loose types from the
* .js files (the Prisma client is built by a function, helpers have defaulted options), so v2
* code imports them from here instead. When a module is converted to TypeScript, its line here
* goes and callers import it directly.
*/
import type { PrismaClient } from '@prisma/client';
// The client in config/db.js only adds query hooks (webhooks), so its models are PrismaClient's.
export const prisma = require('../config/db') as PrismaClient;
type CampusFilter = '' | 'all' | string;
const campuses = require('../utils/campuses') as {
campusesEnabled(): Promise<boolean>;
resolveCampusFilter(value: string | undefined): Promise<CampusFilter>;
campusWhere(filter: CampusFilter, opts: { enabled: boolean; withAllCampuses?: boolean }): object;
};
export const { campusesEnabled, resolveCampusFilter, campusWhere } = campuses;
export const { publicEventWhere } = require('../utils/series') as {
publicEventWhere(now?: Date): { isActive: true; isHidden: false; goLiveAt: { lte: Date } };
};
type OptionLike = { id: string; price: number; earlyBirdTiers?: unknown[] };
export const { publicOptions } = require('../utils/volunteers') as {
publicOptions<O extends { id: string }>(event: { eventOptions?: O[]; kidsOptionId?: string | null; volunteerOptionId?: string | null }): O[];
};
export const { getEffectiveUnitPrice, computeRegistrationTotalDue } = require('../utils/pricing') as {
getEffectiveUnitPrice(option: OptionLike, referenceTime: Date | null, atTime: Date): number;
computeRegistrationTotalDue(registration: object, atTime: Date): number;
};
+106
View File
@@ -0,0 +1,106 @@
import { z } from 'zod';
import { routes as allRoutes, type AnyRoute, type Access } from './route';
import { ERROR_STATUS } from './errors';
/**
* The OpenAPI 3.1 document for /api/v2, built from the route declarations (route.ts), so it is
* always what the code does. Served at /api/v2/openapi.json, shown at /docs/v2.
*/
type JsonSchema = Record<string, unknown>;
const toSchema = (schema: z.ZodType, io: 'input' | 'output'): JsonSchema => {
const { $schema: _drop, ...rest } = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema;
return rest;
};
const ACCESS_TEXT: Record<Access, string> = {
public: 'Anyone.',
optional: 'Anyone; signed-in staff may see more.',
user: 'Any signed-in person.',
staff: 'Staff, supervisors and admins.',
supervisor: 'Supervisors and admins.',
admin: 'Admins only.',
};
const errorSchema = {
type: 'object',
required: ['error'],
properties: {
error: {
type: 'object',
required: ['code', 'message'],
properties: {
code: { type: 'string', enum: Object.keys(ERROR_STATUS) },
message: { type: 'string' },
fields: { type: 'object', additionalProperties: { type: 'string' }, description: 'Per-field problems (VALIDATION_FAILED only)' },
},
},
},
};
const errorResponse = (description: string) => ({ description, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } });
function parameters(def: AnyRoute) {
const out: JsonSchema[] = [];
const add = (schema: z.ZodType | undefined, where: 'path' | 'query') => {
if (!schema) return;
const json = toSchema(schema, 'input') as { properties?: Record<string, JsonSchema>; required?: string[] };
for (const [name, prop] of Object.entries(json.properties || {})) {
const { description, ...propSchema } = prop as JsonSchema & { description?: string };
out.push({ name, in: where, required: where === 'path' || (json.required || []).includes(name), ...(description ? { description } : {}), schema: propSchema });
}
};
add(def.params, 'path');
add(def.query, 'query');
return out;
}
export function buildOpenApi(opts: { version: string; serverUrl?: string; list?: AnyRoute[] }) {
const list = opts.list ?? allRoutes;
const paths: Record<string, Record<string, unknown>> = {};
for (const def of list) {
const path = def.path.replace(/:(\w+)/g, '{$1}');
const secured = def.access !== 'public' && def.access !== 'optional';
paths[path] ||= {};
paths[path][def.method] = {
tags: [def.tag],
summary: def.summary,
description: [def.description, `**Who:** ${ACCESS_TEXT[def.access]}`].filter(Boolean).join('\n\n'),
operationId: `${def.method}${path.replace(/[{}]/g, '').split('/').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}`,
...(secured ? { security: [{ bearerAuth: [] }] } : def.access === 'optional' ? { security: [{}, { bearerAuth: [] }] } : {}),
parameters: parameters(def),
...(def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
responses: {
[String(def.status ?? 200)]: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
...(def.params || def.query || def.body ? { 400: errorResponse('Validation failed (VALIDATION_FAILED)') } : {}),
...(secured ? { 401: errorResponse('Not signed in (UNAUTHENTICATED)') } : {}),
...(secured && def.access !== 'user' ? { 403: errorResponse('Not allowed for this role (FORBIDDEN)') } : {}),
...(def.params ? { 404: errorResponse('Not found (NOT_FOUND)') } : {}),
},
};
}
return {
openapi: '3.1.0',
info: {
title: 'Cross Events API',
version: opts.version,
description: [
'Version 2 of the Cross Events API. Every response is JSON:',
'- a single item is `{ "data": { … } }`;',
'- a list is `{ "data": [ … ], "meta": { "page", "limit", "total", "pages" } }`, paged with `?page=` and `?limit=` (at most 100);',
'- an error is `{ "error": { "code", "message", "fields"? } }` with a stable `code`.',
'',
'Dates are ISO 8601 in UTC; amounts are in rand. Sign in through the app and send the token as `Authorization: Bearer <token>`.',
'Version 1 (`/api/…`) keeps working while the app moves over; it will be removed in 2.0.',
].join('\n'),
},
servers: [{ url: opts.serverUrl ?? '/api/v2' }],
tags: Array.from(new Set(list.map(r => r.tag))).map(name => ({ name })),
paths,
components: {
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
schemas: { Error: errorSchema },
},
};
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod';
/** `?page=2&limit=25` on every v2 list (limit 1–100, default 25). */
export const pageQuery = z.object({
page: z.coerce.number().int().min(1).default(1).describe('Page number, from 1'),
limit: z.coerce.number().int().min(1).max(100).default(25).describe('Items per page (at most 100)'),
});
export type PageQuery = z.infer<typeof pageQuery>;
export const pageMeta = z.object({
page: z.number().int(),
limit: z.number().int(),
total: z.number().int().describe('Items across all pages'),
pages: z.number().int().describe('Number of pages (at least 1)'),
});
/** The response shape of a v2 list: `{ data: [...], meta: { page, limit, total, pages } }`. */
export const paged = <T extends z.ZodType>(item: T) => z.object({ data: z.array(item), meta: pageMeta });
/** The response shape of a single v2 resource: `{ data: {...} }`. */
export const single = <T extends z.ZodType>(item: T) => z.object({ data: item });
/** Prisma's skip/take for a page. */
export const skipTake = ({ page, limit }: PageQuery) => ({ skip: (page - 1) * limit, take: limit });
export function pageOf<T>(data: T[], total: number, { page, limit }: PageQuery) {
return { data, meta: { page, limit, total, pages: Math.max(1, Math.ceil(total / limit)) } };
}
+130
View File
@@ -0,0 +1,130 @@
import { z } from 'zod';
import { defineRoute } from '../route';
import { notFound } from '../errors';
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
import { absoluteUrl, campusRef, id, idParams, iso, isoDate, seriesRef } from '../schemas';
import { prisma, campusesEnabled, campusWhere, resolveCampusFilter, publicEventWhere, publicOptions, getEffectiveUnitPrice } from '../legacy';
const eventSummary = z.object({
id,
title: z.string(),
startDate: isoDate,
endDate: isoDate,
location: z.string().nullable(),
price: z.number().describe('The headline price in rand (0 = free)'),
pictureUrl: z.string().nullable(),
requiresRegistration: z.boolean(),
scheduleType: z.enum(['single', 'sessions']),
campus: campusRef.nullable().describe('null = for all campuses (or campuses are off)'),
series: seriesRef.nullable(),
});
const ticketOption = z.object({
id,
name: z.string(),
price: z.number().describe('The normal price in rand'),
currentPrice: z.number().describe('What it costs now (an early-bird price while one runs)'),
isMainTicket: z.boolean(),
sessionMode: z.enum(['all', 'pick']).describe('"pick" = the buyer chooses sessions'),
});
const session = z.object({
id,
label: z.string().nullable(),
startsAt: isoDate,
endsAt: isoDate,
location: z.string().nullable(),
cancelled: z.boolean(),
});
const eventDetail = eventSummary.extend({
description: z.string().nullable(),
registrationDeadline: isoDate.nullable(),
ticketOptions: z.array(ticketOption),
sessions: z.array(session).describe('Multi-day events only; empty otherwise'),
});
const SUMMARY_SELECT = {
id: true, title: true, startDate: true, endDate: true, location: true, price: true, picture: true,
requiresRegistration: true, scheduleType: true,
campus: { select: { id: true, name: true, slug: true } },
series: { select: { id: true, name: true, slug: true } },
} as const;
type SummaryRow = {
id: string; title: string; startDate: Date; endDate: Date; location: string | null; price: number; picture: string | null;
requiresRegistration: boolean; scheduleType: 'single' | 'sessions';
campus: { id: string; name: string; slug: string } | null; series: { id: string; name: string; slug: string } | null;
};
const toSummary = (req: Parameters<typeof absoluteUrl>[0], e: SummaryRow): z.input<typeof eventSummary> => ({
id: e.id, title: e.title, startDate: iso(e.startDate), endDate: iso(e.endDate), location: e.location ?? null,
price: e.price, pictureUrl: absoluteUrl(req, e.picture), requiresRegistration: e.requiresRegistration,
scheduleType: e.scheduleType, campus: e.campus ?? null, series: e.series ?? null,
});
export const listEvents = defineRoute({
method: 'get',
path: '/events',
tag: 'Events',
summary: 'Upcoming public events',
description: 'Events anyone can see (active, not hidden, live) that have not ended, soonest first.',
access: 'public',
query: pageQuery.extend({
q: z.string().trim().max(100).optional().describe('Search the title'),
campus: z.string().max(64).optional().describe("A campus id or slug (its events plus church-wide ones), or 'all' for church-wide events only"),
series: z.string().max(120).optional().describe('A series slug'),
}),
response: paged(eventSummary),
async handler({ query, req }) {
const now = new Date();
const enabled = await campusesEnabled();
const and: object[] = [publicEventWhere(now), { endDate: { gte: now } }];
if (query.campus) and.push(campusWhere(await resolveCampusFilter(query.campus), { enabled, withAllCampuses: true }));
if (query.series) and.push({ series: { slug: query.series } });
if (query.q) and.push({ title: { contains: query.q, mode: 'insensitive' } });
const where = { AND: and };
const [rows, total] = await Promise.all([
prisma.event.findMany({ where, select: SUMMARY_SELECT, orderBy: [{ startDate: 'asc' }, { id: 'asc' }], ...skipTake(query) }),
prisma.event.count({ where }),
]);
return pageOf((rows as SummaryRow[]).map(e => toSummary(req, e)), total, query);
},
});
export const getEvent = defineRoute({
method: 'get',
path: '/events/:id',
tag: 'Events',
summary: 'One public event',
description: 'With its ticket options (internal kids check-in and volunteer tickets left out) and sessions. Hidden events can be fetched by id (they are unlisted, not private).',
access: 'public',
params: idParams,
response: single(eventDetail),
async handler({ params, req }) {
const now = new Date();
const { isHidden: _hiddenOk, ...visible } = publicEventWhere(now);
const e = await prisma.event.findFirst({
where: { id: params.id, ...visible },
select: {
...SUMMARY_SELECT, description: true, registrationDeadline: true, kidsOptionId: true, volunteerOptionId: true,
eventOptions: { include: { earlyBirdTiers: true }, orderBy: [{ isMainTicket: 'desc' }, { name: 'asc' }] },
sessions: { orderBy: { startsAt: 'asc' } },
},
});
if (!e) throw notFound('That event');
const options = publicOptions(e);
return {
data: {
...toSummary(req, e as unknown as SummaryRow),
description: e.description ?? null,
registrationDeadline: iso(e.registrationDeadline),
ticketOptions: options.map(o => ({
id: o.id, name: o.name, price: o.price, currentPrice: getEffectiveUnitPrice(o, null, now),
isMainTicket: o.isMainTicket, sessionMode: o.sessionMode,
})),
sessions: e.sessions.map(s => ({ id: s.id, label: s.label, startsAt: iso(s.startsAt), endsAt: iso(s.endsAt), location: s.location, cancelled: !!s.cancelledAt })),
},
};
},
});
+99
View File
@@ -0,0 +1,99 @@
import { z } from 'zod';
import { defineRoute } from '../route';
import { ApiError } from '../errors';
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
import { eventRef, id, iso, isoDate } from '../schemas';
import { prisma, computeRegistrationTotalDue } from '../legacy';
const me = z.object({
id,
name: z.string(),
email: z.string(),
phone: z.string().nullable(),
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
twoFactorEnabled: z.boolean(),
createdAt: isoDate,
});
export const getMe = defineRoute({
method: 'get',
path: '/me',
tag: 'Me',
summary: 'The signed-in person',
access: 'user',
response: single(me),
async handler({ user }) {
const u = await prisma.user.findUnique({
where: { id: user!.id },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, createdAt: true },
});
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
return { data: { id: u.id, name: u.name, email: u.email, phone: u.phoneNumber ?? null, role: u.role, twoFactorEnabled: !!u.twoFactorEnabled, createdAt: iso(u.createdAt) } };
},
});
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
const myRegistration = z.object({
id,
status: z.enum(STATUSES),
createdAt: isoDate,
event: eventRef,
items: z.array(z.object({
ticket: z.string(),
variant: z.string().nullable(),
quantity: z.number().int(),
unitPrice: z.number(),
})),
totalDue: z.number().describe('In rand, after discounts'),
amountPaid: z.number().describe('In rand (refunds subtracted)'),
balance: z.number().describe('totalDue − amountPaid; 0 or less = nothing owing'),
});
const r2 = (n: number) => Math.round(n * 100) / 100;
export const listMyRegistrations = defineRoute({
method: 'get',
path: '/me/registrations',
tag: 'Me',
summary: 'My registrations',
description: 'Newest first. By default only ones for events that have not ended, and not cancelled; `past=true` lists everything.',
access: 'user',
query: pageQuery.extend({
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include past and cancelled registrations'),
}),
response: paged(myRegistration),
async handler({ query, user }) {
const now = new Date();
const where = query.past
? { userId: user!.id }
: { userId: user!.id, status: { not: 'cancelled' as const }, event: { endDate: { gte: now } } };
const [rows, total] = await Promise.all([
prisma.registration.findMany({
where,
include: {
event: { select: { id: true, title: true, startDate: true, endDate: true } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { name: true, price: true } }, tranches: true } },
payments: { select: { amount: true, createdAt: true } },
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
...skipTake(query),
}),
prisma.registration.count({ where }),
]);
return pageOf(rows.map(r => {
const totalDue = r2(computeRegistrationTotalDue(r, now));
const amountPaid = r2(r.payments.reduce((s, p) => s + p.amount, 0));
return {
id: r.id,
status: r.status,
createdAt: iso(r.createdAt),
event: { id: r.event.id, title: r.event.title, startDate: iso(r.event.startDate), endDate: iso(r.event.endDate) },
items: r.registrationOptions.map(o => ({ ticket: o.eventOption?.name ?? '', variant: o.variant?.name ?? null, quantity: o.quantity, unitPrice: o.priceSnapshot ?? o.eventOption?.price ?? 0 })),
totalDue,
amountPaid,
balance: r2(totalDue - amountPaid),
};
}), total, query);
},
});
+105
View File
@@ -0,0 +1,105 @@
import type { Request, RequestHandler, Response, Router } from 'express';
import { z } from 'zod';
import { ApiError } from './errors';
import auth from '../middleware/authMiddleware';
/**
* Every /api/v2 endpoint is declared with `defineRoute`: method, path, who may call it, and zod
* schemas for the params, query, body and response. The same declaration
* - validates the request (400 VALIDATION_FAILED with per-field messages),
* - checks the response against its schema and strips anything it doesn't list (so a field
* can't leak by being added to a Prisma include), and
* - becomes the OpenAPI document (openapi.ts) — the docs can't drift from the code.
*/
export type Access = 'public' | 'optional' | 'user' | 'staff' | 'supervisor' | 'admin';
/** The signed-in person, as the auth middleware puts it on the request. */
export type ApiUser = { id: string; name: string; email: string; role: 'admin' | 'supervisor' | 'staff' | 'user' };
export type Ctx<P, Q, B> = {
params: P;
query: Q;
body: B;
user: ApiUser | null;
req: Request;
res: Response;
};
type Schema = z.ZodType;
type Infer<S> = S extends z.ZodType ? z.infer<S> : undefined;
export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema> = {
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
/** Express-style, relative to /api/v2: "/events/:id". */
path: string;
summary: string;
description?: string;
tag: string;
access: Access;
/**
* Staff and supervisor routes must say how they treat campus-scoped staff (see
* utils/campusScope.js and tests/campusScopeRoutes.test.js) — e.g. "scopeEvent(params.id)".
*/
campusScope?: string;
params?: P;
query?: Q;
body?: B;
response: R;
/** HTTP status on success (default 200). */
status?: number;
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyRoute = RouteDef<any, any, any, any>;
export const routes: AnyRoute[] = [];
const ACCESS: Record<Access, RequestHandler[]> = {
public: [],
optional: [auth.optionalAuth],
user: [auth.protect],
staff: [auth.protect, auth.staff],
supervisor: [auth.protect, auth.supervisor],
admin: [auth.protect, auth.admin],
};
export function defineRoute<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema>(def: RouteDef<P, Q, B, R>): RouteDef<P, Q, B, R> {
if ((def.access === 'staff' || def.access === 'supervisor') && !def.campusScope) {
throw new Error(`${def.method.toUpperCase()} /api/v2${def.path}: staff and supervisor routes must declare campusScope`);
}
routes.push(def as AnyRoute);
return def;
}
const parse = <S extends Schema | undefined>(schema: S, value: unknown): Infer<S> =>
(schema ? schema.parse(value) : undefined) as Infer<S>;
/** Adds every defined route to the router. */
export function mount(router: Router, list: AnyRoute[] = routes): void {
for (const def of list) {
const run: RequestHandler = async (req, res, next) => {
try {
const ctx = {
params: parse(def.params, req.params),
query: parse(def.query, req.query),
body: parse(def.body, req.body ?? {}),
user: (req as Request & { user?: ApiUser }).user ?? null,
req,
res,
};
const result = await def.handler(ctx);
const checked = def.response.safeParse(result);
if (!checked.success) {
console.error(`[api/v2] ${def.method.toUpperCase()} ${def.path}: response doesn't match its schema`, checked.error.issues);
throw new ApiError('INTERNAL', 'Something went wrong on the server.');
}
res.status(def.status ?? 200).json(checked.data);
} catch (err) {
next(err);
}
};
router[def.method](def.path, ...ACCESS[def.access], run);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod';
import type { Request } from 'express';
/** Shared pieces of v2 schemas. Dates go out as ISO 8601 strings (UTC). */
export const isoDate = z.iso.datetime();
export const id = z.string().min(1).max(64);
export const idParams = z.object({ id: id.describe('The id') });
/** A Date (or date string) as ISO 8601, for building responses. */
export function iso(d: Date | string): string;
export function iso(d: Date | string | null | undefined): string | null;
export function iso(d: Date | string | null | undefined): string | null {
return d == null ? null : new Date(d).toISOString();
}
/** A stored picture path ("/uploads/…") as an absolute URL on the host the request came to. */
export function absoluteUrl(req: Request, url: string | null | undefined): string | null {
const raw = String(url || '').trim();
if (!raw) return null;
if (/^https?:\/\//.test(raw)) return raw;
return `${req.protocol}://${req.get('host')}${raw.startsWith('/') ? '' : '/'}${raw}`;
}
export const campusRef = z.object({ id, name: z.string(), slug: z.string() });
export const seriesRef = z.object({ id, name: z.string(), slug: z.string() });
export const eventRef = z.object({ id, title: z.string(), startDate: isoDate, endDate: isoDate });
+40
View File
@@ -0,0 +1,40 @@
jest.mock('../src/config/db', () => ({}));
const { auditLogWhere } = require('../src/utils/adminAudit');
describe('auditLogWhere', () => {
test('no filters → everything', () => {
expect(auditLogWhere({})).toEqual({});
});
test('a "to" date includes that whole day', () => {
const where = auditLogWhere({ from: '2026-10-01', to: '2026-10-28' });
expect(where.createdAt.gte).toEqual(new Date('2026-10-01T00:00:00'));
expect(where.createdAt.lt).toEqual(new Date('2026-10-29T00:00:00'));
expect(where.createdAt.lte).toBeUndefined();
});
test('a full timestamp is used as given', () => {
const where = auditLogWhere({ to: '2026-10-28T10:00:00.000Z' });
expect(where.createdAt.lte).toEqual(new Date('2026-10-28T10:00:00.000Z'));
});
test('action and role filter exactly', () => {
expect(auditLogWhere({ action: 'refund_created', role: 'supervisor' })).toEqual({ action: 'refund_created', actorRole: 'supervisor' });
});
test('search looks at the person, the target and the IP', () => {
const where = auditLogWhere({ search: ' #ab12 ' });
expect(where.OR).toEqual([
{ actor: { name: { contains: '#ab12', mode: 'insensitive' } } },
{ actor: { email: { contains: '#ab12', mode: 'insensitive' } } },
{ targetType: { contains: '#ab12', mode: 'insensitive' } },
{ targetId: { startsWith: 'ab12' } },
{ ip: { startsWith: '#ab12' } },
]);
});
test('a blank search is ignored', () => {
expect(auditLogWhere({ search: ' ' }).OR).toBeUndefined();
});
});
+152
View File
@@ -0,0 +1,152 @@
import express from 'express';
import request from 'supertest';
// /api/v2 (1.51.0): the route declarations validate requests, shape responses and errors the same
// way everywhere, and produce the OpenAPI document.
const mockDb = {
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn() },
user: { findUnique: jest.fn() },
registration: { findMany: jest.fn(), count: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/campuses', () => ({
campusesEnabled: jest.fn().mockResolvedValue(false),
resolveCampusFilter: jest.fn(async (v: string) => v),
campusWhere: jest.fn(() => ({})),
}));
// Signed in as whoever the test says (the real middleware checks JWTs against the database).
let signedIn: { id: string; role: string } | null = null;
jest.mock('../src/middleware/authMiddleware', () => {
const deny = (status: number, message: string) => (_req: unknown, res: { status(n: number): void }, next: (e: Error) => void) => { res.status(status); next(new Error(message)); };
return {
protect: (req: { user?: unknown }, res: { status(n: number): void }, next: (e?: Error) => void) => (signedIn ? ((req.user = signedIn), next()) : deny(401, 'Not authorized, no token')(req, res, next as (e: Error) => void)),
optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { if (signedIn) req.user = signedIn; next(); },
admin: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (req.user?.role === 'admin' ? next() : deny(403, 'Not authorized as an admin')(req, res, next as (e: Error) => void)),
staff: jest.fn(), supervisor: jest.fn(),
};
});
import { v2Router } from '../src/v2';
import { routes } from '../src/v2/route';
import { buildOpenApi } from '../src/v2/openapi';
const app = express();
app.use(express.json());
app.use('/api/v2', v2Router());
const event = (over: Record<string, unknown> = {}) => ({
id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'),
location: 'Camp Joy', price: 600, picture: '/uploads/events/camp.jpg', requiresRegistration: true, scheduleType: 'single',
campus: null, series: { id: 's1', name: 'Camps', slug: 'camps' }, ...over,
});
beforeEach(() => { signedIn = null; jest.clearAllMocks(); });
describe('GET /api/v2/events', () => {
test('a page of public events, with meta and ISO dates', async () => {
mockDb.event.findMany.mockResolvedValue([event()]);
mockDb.event.count.mockResolvedValue(31);
const res = await request(app).get('/api/v2/events?page=2&limit=10&q=camp');
expect(res.status).toBe(200);
expect(res.body.meta).toEqual({ page: 2, limit: 10, total: 31, pages: 4 });
expect(res.body.data[0]).toMatchObject({ id: 'e1', startDate: '2026-12-04T16:00:00.000Z', series: { slug: 'camps' } });
expect(res.body.data[0].pictureUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/uploads\/events\/camp\.jpg$/);
const args = mockDb.event.findMany.mock.calls[0][0];
expect(args).toMatchObject({ skip: 10, take: 10 });
expect(args.where.AND).toEqual(expect.arrayContaining([{ isActive: true, isHidden: false, goLiveAt: { lte: expect.any(Date) } }, { title: { contains: 'camp', mode: 'insensitive' } }]));
});
test('fields the schema does not list are never sent', async () => {
mockDb.event.findMany.mockResolvedValue([event({ cashupStatus: 'closed', createdById: 'u9' })]);
mockDb.event.count.mockResolvedValue(1);
const res = await request(app).get('/api/v2/events');
expect(Object.keys(res.body.data[0]).sort()).toEqual(['campus', 'endDate', 'id', 'location', 'pictureUrl', 'price', 'requiresRegistration', 'scheduleType', 'series', 'startDate', 'title']);
});
test('a bad query is a VALIDATION_FAILED error naming the field', async () => {
const res = await request(app).get('/api/v2/events?limit=500&page=0');
expect(res.status).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');
expect(Object.keys(res.body.error.fields).sort()).toEqual(['limit', 'page']);
expect(mockDb.event.findMany).not.toHaveBeenCalled();
});
});
describe('GET /api/v2/events/:id', () => {
test('leaves out kids and volunteer tickets and shows the early-bird price now', async () => {
mockDb.event.findFirst.mockResolvedValue({
...event(), description: 'A weekend away', registrationDeadline: null, kidsOptionId: 'kids', volunteerOptionId: null,
eventOptions: [
{ id: 'o1', name: 'Camper', price: 600, isMainTicket: true, sessionMode: 'all', earlyBirdTiers: [{ id: 't', price: 500, deadline: new Date(Date.now() + 864e5), stockLimit: 0 }] },
{ id: 'kids', name: 'Kids check-in', price: 0, isMainTicket: false, sessionMode: 'all', earlyBirdTiers: [] },
],
sessions: [],
});
const res = await request(app).get('/api/v2/events/e1');
expect(res.status).toBe(200);
expect(res.body.data.ticketOptions).toEqual([{ id: 'o1', name: 'Camper', price: 600, currentPrice: 500, isMainTicket: true, sessionMode: 'all' }]);
});
test('an unknown or private event is NOT_FOUND', async () => {
mockDb.event.findFirst.mockResolvedValue(null);
const res = await request(app).get('/api/v2/events/nope');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: { code: 'NOT_FOUND', message: "That event wasn't found." } });
});
});
describe('me', () => {
test('signed out is UNAUTHENTICATED in the v2 shape', async () => {
const res = await request(app).get('/api/v2/me');
expect(res.status).toBe(401);
expect(res.body.error.code).toBe('UNAUTHENTICATED');
});
test('my registrations with totals and what is owing', async () => {
signedIn = { id: 'u1', role: 'user' };
mockDb.registration.findMany.mockResolvedValue([{
id: 'r1', status: 'partial_paid', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0,
event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') },
registrationOptions: [{ quantity: 2, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }],
payments: [{ amount: 500, createdAt: new Date() }],
}]);
mockDb.registration.count.mockResolvedValue(1);
const res = await request(app).get('/api/v2/me/registrations');
expect(res.status).toBe(200);
expect(res.body.data[0]).toMatchObject({ id: 'r1', totalDue: 1200, amountPaid: 500, balance: 700, items: [{ ticket: 'Camper', variant: null, quantity: 2, unitPrice: 600 }] });
expect(mockDb.registration.findMany.mock.calls[0][0].where).toMatchObject({ userId: 'u1', status: { not: 'cancelled' } });
});
});
describe('the API description', () => {
test('openapi.json is for admins', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).get('/api/v2/openapi.json')).body.error.code).toBe('FORBIDDEN');
signedIn = { id: 'a1', role: 'admin' };
const res = await request(app).get('/api/v2/openapi.json');
expect(res.status).toBe(200);
expect(res.body.openapi).toBe('3.1.0');
});
test('every route is described, with its parameters, auth and response', () => {
const doc = buildOpenApi({ version: 'test' });
for (const r of routes) {
const op = (doc.paths as Record<string, Record<string, Record<string, unknown>>>)[r.path.replace(/:(\w+)/g, '{$1}')][r.method];
expect(op).toBeDefined();
expect(op.responses).toHaveProperty(String(r.status ?? 200));
}
const list = doc.paths['/events'].get as { parameters: { name: string; in: string }[]; security?: unknown };
expect(list.parameters.map(p => p.name)).toEqual(['page', 'limit', 'q', 'campus', 'series']);
expect(list.security).toBeUndefined();
expect((doc.paths['/me'].get as { security: unknown }).security).toEqual([{ bearerAuth: [] }]);
expect((doc.paths['/events/{id}'].get as { parameters: { name: string; in: string; required: boolean }[] }).parameters[0]).toMatchObject({ name: 'id', in: 'path', required: true });
});
test('an unknown v2 path is a v2 NOT_FOUND', async () => {
const res = await request(app).get('/api/v2/nothing-here');
expect(res.status).toBe(404);
expect(res.body.error.code).toBe('NOT_FOUND');
});
});
+25
View File
@@ -3,8 +3,12 @@ const mockDb = {
webhookDelivery: { updateMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
user: { findUnique: jest.fn() },
registration: { findUnique: jest.fn() },
payment: { findUnique: jest.fn() },
ticketUsage: { findUnique: jest.fn() },
event: { findUnique: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: () => 1200 }));
const crypto = require('crypto');
const webhooks = require('../src/utils/webhooks');
@@ -112,4 +116,25 @@ describe('outbound webhooks', () => {
expect(made[0].eventId).toBe(made[1].eventId);
expect(made[0].payload.data.person).toMatchObject({ id: 'u1', email: 't@test.local' });
});
// The examples shown in Site settings → Webhooks must have the same shape as what is sent.
test('each event type has an example shaped like the real payload', async () => {
const shape = (v) => (v && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)
? Object.fromEntries(Object.keys(v).sort().map(k => [k, shape(v[k])]))
: Array.isArray(v) ? v.map(shape) : '·');
const ev = { id: 'e1', title: 'Camp', startDate: new Date(), endDate: new Date(), kidsOptionId: null, volunteerOptionId: null };
const user = { id: 'u1', name: 'T', email: 't@x', phoneNumber: '+27' };
const reg = (status) => ({ id: 'r1', status, createdAt: new Date(), userId: 'u1', seasonPassPurchaseId: null, event: ev, user, payments: [{ amount: 1200 }],
registrationOptions: [{ eventOptionId: 'o1', quantity: 2, priceSnapshot: 600, eventOption: { id: 'o1', name: 'Camper' }, variant: { name: 'Bunk' }, tranches: [] }] });
mockDb.payment.findUnique.mockResolvedValue({ id: 'p1', amount: 1200, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1', createdAt: new Date(), userId: 'u1', user, event: ev });
mockDb.ticketUsage.findUnique.mockResolvedValue({ id: 't1', scannedAt: new Date(), quantityRedeemed: 1, manual: false, offline: false, session: { id: 's1', label: 'Fri', startsAt: new Date() },
ticket: { id: 'k1', userId: 'u1', registrationOptionId: 'o1', event: ev, user, holder: null, attendee: { name: 'T' }, registrationOption: { eventOptionId: 'o1' } } });
mockDb.user.findUnique.mockResolvedValue({ ...user, role: 'user', createdAt: new Date() });
mockDb.event.findUnique.mockResolvedValue({ ...ev, description: 'd', location: 'l', price: 600, isActive: true, isHidden: false, goLiveAt: new Date(), campus: { name: 'N', slug: 'n' }, series: null });
for (const t of webhooks.EVENT_TYPES) {
const status = { 'registration.paid': 'paid', 'registration.cancelled': 'cancelled' }[t.type] || 'pending';
mockDb.registration.findUnique.mockResolvedValue(reg(status));
const built = await webhooks.buildPayload(t.type, 'x');
expect([t.type, shape(t.example)]).toEqual([t.type, shape(built.data)]);
}
});
});
+25
View File
@@ -0,0 +1,25 @@
{
// The backend is moving to TypeScript a file at a time (1.51.0): .js files are compiled as they
// are (not type-checked), .ts files are checked strictly. `npm run build` writes dist/, which is
// what production runs (dist/index.js); dev and the e2e tests run src/ through tsx.
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2023"],
"rootDir": "src",
"outDir": "dist",
"allowJs": true,
"checkJs": false,
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"types": ["node", "jest"]
},
"include": ["src"]
}
+76 -5
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.49.2** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-29, after **1.51.0** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,10 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
| 1.50.0 | **Accessibility pass** (§11, WCAG 2.2 AA) on public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labelled fields, named quantity buttons, announced errors, `ModalOverlay` popups (focus trap, Escape), contrast and underlined in-text links, reduce-motion; Branding warns about a too-light primary colour. axe + keyboard e2e in `e2e/accessibility.spec.ts` |
| 1.49.2 | **Saved filters** (§9 part 3) on registrations, users, events and reports (`/api/saved-filters`, `components/shared/SavedFilters.tsx`). Migration `20261028180000_saved_filters` |
| 1.49.1 | **Bulk actions** on Manage registrations (§9 part 2): message, mark paid, move ticket, allocate, export, cancel (`POST /api/registrations/bulk`, `utils/bulkRegistrations.js`). Migration `20261028170000_registrations_bulk` |
| 1.49.0 | **Dashboard and command palette** (§9 part 1): Today and Waiting for you on the staff/supervisor/admin dashboards (`GET /api/dashboard/today`), Ctrl+K palette (`GET /api/dashboard/search`, pages in `lib/commandPages.ts`), deep links on events/registrations/users |
@@ -58,7 +62,14 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.50.0 — Languages (Afrikaans first)** (§10), then 1.51.0 accessibility (§11).
1. **1.51.1 — v2 write endpoints** (register, pay, check-in, users; split `registrationController`
and `eventController` into TS services as they move; typed frontend calls from the schemas), then
**1.51.2 — the rest of v2, Express 5, v1 deprecation headers**, then **2.0.0 — AI helpers** (§15).
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
charges the real cost plus a markup % (a default and a per-licence override) and counts the cap
in charged rand. AI endpoints are `/api/v2` only.
## Working on this repo
@@ -74,7 +85,7 @@ a test server (both done 2026-10-28).
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every
15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/app.js` (v2 documents itself), and
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
- **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local
DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index
@@ -415,6 +426,68 @@ a test server (both done 2026-10-28).
page to `PAGES` (and the component's `page` type), then pass the values in and apply them out.
- e2e `saved-filters.spec.ts` (deletes what it saves).
## Accessibility (1.50.0)
- **Popups:** use `components/shared/ModalOverlay.tsx` (`labelledBy` = the heading's id, `onClose`,
`closeOnBackdrop`) instead of a bare `fixed inset-0` div — it gives the dialog role, focus trap,
Escape and focus return. Radix `ui/dialog` is fine too.
- **Fields:** tie every visible `<label>` to its control (`htmlFor`/`id`, or wrap the control); in
lists use an id with the row key, or `aria-label`. Icon-only buttons need `aria-label`.
- **Text colour:** `text-gray-500` is the lightest grey for text on white (`gray-400` fails 4.5:1).
Links inside a `<p>`/`<label>` are underlined by `globals.css`.
- **Tests:** `e2e/accessibility.spec.ts` — add new public/member pages to it. It checks with axe's
WCAG 2.0–2.2 A/AA tags; the app has no dark mode, so only the light theme is checked.
- **Not covered yet:** the staff, supervisor and admin screens (scanner, at-the-door, settings …)
weren't in §11's scope; many of their labels still aren't tied to fields and they use hand-made
popups. Do them when those pages are next worked on.
## Long lists (1.50.1)
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
payments, audit log, consent/access logs, data requests, My payments.
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
## TypeScript and API v2 (1.51.0)
- **Starting the backend:** PM2 runs `src/index.js`, a launcher. With plain Node it runs
`dist/app.js` when `dist/build.json` has this version; otherwise tsx compiles on the fly (with a
warning). tsx and typescript are runtime dependencies for that fallback. `npm run dev` and e2e
use tsx, so they never pick up a stale `dist/`. The server is `src/app.js`.
- **Paths:** `dist/` mirrors `src/`, so `path.join(__dirname, '..', '..', 'public')` works from
both. Non-code files in `src/` aren't copied (the favicon is read from `src/`).
- **New code in TypeScript.** Convert a JS module when a release touches it; until then import it
through `src/v2/legacy.ts` with a typed signature. TypeScript infers loose types from JS (the
Prisma client especially), so v2 code takes `prisma` from there, typed as `PrismaClient`.
- **A v2 endpoint** is `defineRoute({ method, path, tag, summary, access, params?, query?, body?,
response, handler })` in `src/v2/resources/<area>.ts`, imported in `src/v2/index.ts`. Lists use
`pageQuery`, `paged()` and `pageOf()`; single items `single()`. Dates go out as ISO strings
(`iso()`), so response schemas use `isoDate`. Throw `ApiError(code, message)` or `notFound()`.
The response is parsed by its schema: unknown fields are dropped, and a mismatch is a 500 plus a
log line (tests catch it). Staff/supervisor routes must declare `campusScope` (start-up fails
otherwise).
- Not yet v2-shaped: the global middleware in front of every route (rate limit, impersonation
read-only guard, licence read-only) still answers `{ message }`. Do that with 1.51.2.
## Event picker and webhook guide (1.50.2)
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
`pickEvent(page, title)` from `e2e/fixtures.ts`.
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
a slim `?fields=` mode would help if a church gets into the thousands.
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
shape.
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
@@ -425,8 +498,6 @@ a test server (both done 2026-10-28).
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
- There's no log of individual messages sent, so "Download my data" can't include "messages
sent to you" yet.
- The audit-log page's action filter list was already missing newer actions before 1.38.0
(only the three privacy ones were added).
- At the door, per-session tickets are booked on new registrations only; editing an
existing registration leaves its booked sessions as they are (the backend refuses
+48 -12
View File
@@ -36,8 +36,10 @@ ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. **Next: 1.50.0
Languages (Afrikaans first).**
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
foundations** shipped. **Next: 1.51.1 v2 write endpoints, then 1.51.2.**
**Repositories:**
@@ -59,7 +61,7 @@ Languages (Afrikaans first).**
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
8. [Outbound webhooks](#8-outbound-webhooks)
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
10. ~~[Languages (Afrikaans first)](#10-languages-afrikaans-first)~~ — removed from scope
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Cross Events Hub)](#13-central-management-platform-cross-events-hub)
@@ -534,6 +536,8 @@ palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters ✅.
## 10. Languages (Afrikaans first)
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
- User language preference; emails, WhatsApp messages and PDFs sent in the
user's language, with per-language templates.
@@ -550,6 +554,9 @@ palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters ✅.
fields.
- Automated `axe` checks in the Playwright suite so it doesn't regress.
**Shipped in 1.50.0** for the pages above. The app has no dark mode, so only the light theme is
checked. Staff/supervisor/admin screens are left for when they're next worked on (see HANDOVER).
---
## 12. Multi-campus (one install per church)
@@ -872,11 +879,34 @@ an install (one leaked install would leak it for everyone), so:
the install secret like the heartbeat. The Hub adds the key, calls Claude, and returns
the result. Still **install → Hub only** — the Hub never reaches into an install.
- **The Hub meters every call** in rand: tokens used × the model's price × an exchange rate
Joshua sets in the Hub (with an optional margin). Usage is kept per install per month.
Joshua sets in the Hub × **(1 + markup)**. Usage is kept per install per month, as both the
real cost and the charged (marked-up) amount.
- **Markup** (decided 2026-10-29): one percentage set in the Hub (a default for every licence,
with an override per licence), e.g. 40%. It does two jobs: it covers Joshua if a month's real
usage runs a little over (exchange-rate moves, a last call that costs more than expected), and
it is his profit on AI. Churches only ever see the charged amount in rand. The real cost, the
markup and the profit are shown only in the Hub.
- **Monthly rand cap per licence**, set by Joshua in the Hub to match what the church pays
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. At 80% the
install's admins get a warning email; at 100% the helpers are switched off until the next
month (or until Joshua raises the cap). Optional per-user daily limit against runaway use.
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. The cap is
counted in **charged** rand, so the real spend can never be more than cap ÷ (1 + markup).
- **Hard stop, checked before each call:** the Hub refuses a call when *charged so far + the
worst case for this call* (its prompt plus the helper's `max_tokens`, priced with markup) would
pass the cap. A month can't run over by more than the markup already covers, even with
several people clicking at once (the check and the usage record happen in one transaction).
At 80% the install's admins get a warning email. Optional per-user daily limit against
runaway use.
- **The buttons follow the cap** (decided 2026-10-29):
- **Cap R0** (or AI off on the licence, or the install not connected to the Hub): the AI
buttons are **not shown at all**, anywhere.
- **Cap above R0, room left:** the buttons show and work.
- **Cap reached:** the buttons stay where they are but are **disabled**, with a tooltip like
"This month's AI allowance is used up — it renews on 1 December". They come back by
themselves next month, or as soon as Joshua raises the cap.
- The install learns this from `GET /api/v2/ai/status` → `{ enabled, capReached, renewsOn,
helpers }`. The answer is cached briefly from the Hub and refreshed after every call.
Every AI call also returns the new status, so the buttons disable straight after the call
that used up the cap. The backend checks again on each request, so a stale page can't get
past the cap.
- **Admins see their usage** in Site settings → AI: used this month (R and number of uses),
the cap, a daily chart, use per helper and per person, and an on/off switch per helper.
- If the Hub or Claude is unavailable, the buttons say so; nothing else in the app is affected.
@@ -897,7 +927,12 @@ an install (one leaked install would leak it for everyone), so:
- Install: `backend/src/ai/` — one client for the Hub's AI endpoint, one prompt + schema
per helper, usage shown from the Hub. Feature flag per helper, all off when the licence
has no AI or the Hub isn't connected.
has no AI or the Hub isn't connected. The AI endpoints exist **only in `/api/v2`**
(`/api/v2/ai/status`, `/api/v2/ai/<helper>`), built on 1.51's schemas and error format.
The frontend has one `useAiStatus()` hook and an `<AiButton>` that hides, enables or
disables itself from it.
- Hub: real cost and charged amount per call; the markup default and per-licence override;
a per-install month view with **cost, charged and profit**; the cap check before each call.
- Hub: the AI proxy, metering, caps, warnings, an exchange-rate setting, and a per-install
usage view for Joshua (alongside the existing install detail page).
- A small **eval set** (example event descriptions, reports, notes, with what a good answer
@@ -940,8 +975,7 @@ each confirmed action in the audit log.
### More helpers
Smaller one-button additions as they come up: suggest an event's price tiers from past
events, write a post-event thank-you, draft certificate wording, translate a message into
Afrikaans (ties in with §10), flag unusual payments for the cashup.
events, write a post-event thank-you, draft certificate wording, flag unusual payments for the cashup.
### Self-hosted AI (long term)
@@ -972,8 +1006,8 @@ income justify the hardware:
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 ✅, 1.51.1, 1.51.2 | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 write endpoints and services, typed frontend calls; 1.51.2 the rest, Express 5, v1 deprecation headers | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
@@ -1011,6 +1045,7 @@ feature.
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
@@ -1024,6 +1059,7 @@ feature.
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
| — | Languages (Afrikaans) | **Removed from scope** (2026-10-28); the accessibility pass becomes 1.50.0 and the TypeScript backend / `/api/v2` 1.51.0 |
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings. Season pass: one checkout registers for every event on it, the supervisor picks the events, the price is split in proportion to normal prices (2026-10-28) |
+134
View File
@@ -0,0 +1,134 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
// WCAG 2.2 AA checks (axe-core) on the pages visitors and members use, so the
// accessibility pass (1.50.0) doesn't regress.
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
async function check(page: Page, label: string) {
// Let animations and late-loading data settle so colours are measured as shown.
await page.waitForLoadState("networkidle");
await page.waitForTimeout(300);
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
const summary = results.violations.map((v) => ({
id: v.id,
help: v.help,
nodes: v.nodes.map((n) => `${n.target.join(" ")} — ${n.failureSummary?.split("\n").slice(0, 2).join(" ")}`),
}));
expect.soft(summary, `${label}: accessibility violations`).toEqual([]);
}
async function visit(page: Page, url: string, label: string, ready?: () => Promise<void>) {
await page.goto(url);
if (ready) await ready();
await check(page, label);
}
test.describe("accessibility (WCAG 2.2 AA)", () => {
test("public pages", async ({ page }) => {
await visit(page, "/", "home");
await visit(page, "/events", "events", () => expect(page.getByText("E2E Free Evening")).toBeVisible());
await visit(page, `/events/${seed().events.course}`, "event page", () =>
expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible(),
);
await visit(page, `/events/${seed().events.dropIn}`, "drop-in event page");
await visit(page, "/contact", "contact");
await visit(page, "/legal/privacy", "privacy notice");
await visit(page, "/legal/terms", "terms");
});
test("sign in, sign up and forgot password, with errors", async ({ page }) => {
await visit(page, "/login", "login");
await page.getByLabel("Email or phone").fill(seed().users.user.email);
await page.getByLabel("Password", { exact: true }).fill("not-the-password");
await page.getByRole("button", { name: "Login" }).click();
await expect(page.getByRole("alert")).toBeVisible();
await check(page, "login with an error");
await visit(page, "/register", "sign up");
await visit(page, "/forgot-password", "forgot password");
});
test("registration form", async ({ page }) => {
await loginAs(page, "user");
await visit(page, `/register/${seed().events.paid}`, "registration", async () => {
await page.getByRole("button", { name: /^Add one / }).first().click();
await expect(page.getByText("Total: R150.00")).toBeVisible();
});
await visit(page, `/register/${seed().events.dropIn}`, "registration (pick sessions)");
});
test("My Events and account pages", async ({ page }) => {
await loginAs(page, "user");
await visit(page, "/dashboard/user", "My Events");
await visit(page, "/dashboard/user/profile", "profile");
await visit(page, "/dashboard/user/privacy", "privacy centre");
await visit(page, "/dashboard/user/payments", "payments");
await visit(page, "/dashboard/user/kids", "kids");
await visit(page, "/dashboard/user/serving", "serving");
});
test("self-service kiosk", async ({ page }) => {
await visit(page, "/self-service", "kiosk sign-in");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
await check(page, "kiosk event picker");
await pickEvent(page, "E2E Free Evening");
await page.getByRole("button", { name: "Start Kiosk" }).click();
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
await check(page, "kiosk form");
// "Create an account" is a switch you can reach and flip with the keyboard.
const create = page.getByRole("switch", { name: /Create an account/ });
await create.focus();
await page.keyboard.press("Space");
await expect(create).toHaveAttribute("aria-checked", "true");
await expect(page.getByLabel("Choose a password")).toBeVisible();
});
});
test.describe("keyboard", () => {
test("the first Tab reaches “Skip to main content”, which moves focus to the page content", async ({ page }) => {
await page.goto("/events");
await expect(page.getByText("E2E Free Evening")).toBeVisible();
await page.keyboard.press("Tab");
const skip = page.getByRole("link", { name: "Skip to main content" });
await expect(skip).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("main").first()).toBeFocused();
});
test("a popup keeps focus inside, closes with Escape and hands focus back", async ({ page }) => {
await page.goto("/self-service");
await page.getByLabel("Email").fill(seed().users.supervisor.email);
await page.getByLabel("Password", { exact: true }).fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await pickEvent(page, "E2E Free Evening");
await page.getByRole("button", { name: "Start Kiosk" }).click();
const opener = page.getByRole("button", { name: "Change Event" });
await opener.focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog", { name: "Change Event" });
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel("Password", { exact: true })).toBeFocused();
// Tab doesn't leave the popup.
for (let i = 0; i < 4; i++) await page.keyboard.press("Tab");
expect(await dialog.evaluate((el) => el.contains(document.activeElement))).toBe(true);
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
});
test("ticket quantities can be changed with the keyboard and are announced by name", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.paid}`);
const add = page.getByRole("button", { name: /^Add one / }).first();
await add.focus();
await page.keyboard.press("Enter");
await expect(page.getByText("Total: R150.00")).toBeVisible();
await expect(page.getByRole("button", { name: /^Remove one / }).first()).toBeEnabled();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { test, expect, seed, API } from "./fixtures";
// /api/v2 (1.51.0) against the real server and database: paged public events, the v2 error
// shape, the signed-in person, and the OpenAPI reference at /docs/v2.
test.describe("API v2", () => {
test("public events come a page at a time; errors have a code", async ({ request }) => {
const res = await request.get(`${API}/api/v2/events?limit=2`);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.data).toHaveLength(2);
expect(body.meta).toMatchObject({ page: 1, limit: 2 });
expect(body.meta.total).toBeGreaterThan(2);
const search = await (await request.get(`${API}/api/v2/events?q=free%20evening`)).json();
expect(search.data.map((e: { title: string }) => e.title)).toEqual(["E2E Free Evening"]);
const one = await (await request.get(`${API}/api/v2/events/${seed().events.paid}`)).json();
expect(one.data.ticketOptions.length).toBeGreaterThan(0);
const bad = await request.get(`${API}/api/v2/events?limit=1000`);
expect(bad.status()).toBe(400);
expect((await bad.json()).error).toMatchObject({ code: "VALIDATION_FAILED", fields: { limit: expect.any(String) } });
const missing = await request.get(`${API}/api/v2/events/00000000-0000-4000-8000-000000000000`);
expect((await missing.json()).error.code).toBe("NOT_FOUND");
});
test("me and my registrations need a token", async ({ request }) => {
expect((await request.get(`${API}/api/v2/me`)).status()).toBe(401);
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
const me = await (await request.get(`${API}/api/v2/me`, { headers })).json();
expect(me.data).toMatchObject({ id: seed().users.user.id, role: "user" });
const regs = await (await request.get(`${API}/api/v2/me/registrations?past=true`, { headers })).json();
expect(regs.meta).toMatchObject({ page: 1, limit: 25 });
expect(regs.data.length).toBe(Math.min(25, regs.meta.total));
});
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
await expect(page.locator(".opblock-summary-path", { hasText: "/events/{id}" })).toBeVisible();
// The token isn't left in the address bar.
expect(page.url()).not.toContain("token=");
});
});
+52
View File
@@ -0,0 +1,52 @@
import AxeBuilder from "@axe-core/playwright";
import { test, expect, loginAs, api } from "./fixtures";
// 1.50.2: event dropdowns are a searchable picker — upcoming events first, past ones a click
// away, and typing searches them all.
test.describe("event picker", () => {
test("opens on upcoming events; past ones are behind a button or found by searching", async ({ page }) => {
const start = new Date(Date.now() - 40 * 864e5);
await api(page, "supervisor", "POST", "/api/events", {
title: "E2E Picker Old Gathering", description: "A past event for the picker test.", price: 0, location: "1 Test Street, Pretoria",
startDate: start.toISOString(), endDate: new Date(start.getTime() + 2 * 3600e3).toISOString(),
});
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/email-attendees");
await page.getByRole("button", { name: /^Event: Select an event/ }).click();
const list = page.getByRole("listbox", { name: "Events" });
await expect(list.getByRole("option", { name: /E2E Free Evening/ })).toBeVisible();
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toHaveCount(0);
// The open picker passes the WCAG 2.2 AA checks.
const axe = await new AxeBuilder({ page }).include("[data-testid=event-picker]").withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze();
expect(axe.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`)).toEqual([]);
await page.getByRole("button", { name: /^Show past events/ }).click();
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toBeVisible();
await page.getByRole("button", { name: "Hide past events" }).click();
// Searching finds past events too; the keyboard chooses.
const search = page.getByRole("combobox", { name: "Search events" });
await search.fill("old gathering");
await expect(list.getByRole("option")).toHaveCount(1);
await search.press("Enter");
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeFocused();
// Escape closes without changing anything.
await page.keyboard.press("ArrowDown");
await expect(list).toBeVisible();
await page.keyboard.press("Escape");
await expect(list).toBeHidden();
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeVisible();
});
test("the reports event list can be searched", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/supervisor/reports");
await page.getByRole("button", { name: /^Events:/ }).click();
await page.getByRole("button", { name: "Clear", exact: true }).click();
await page.getByLabel("Search events").fill("alpha");
await page.getByRole("button", { name: /^Select these 1$/ }).click();
await expect(page.getByRole("button", { name: "Events: E2E Alpha Course" })).toBeVisible();
});
});
+8
View File
@@ -51,4 +51,12 @@ export async function api<T = unknown>(page: Page, role: Role, method: string, u
return (text ? JSON.parse(text) : null) as T;
}
/** Chooses an event in an EventPicker (the first on the page, or the one labelled `label`) by searching its title. */
export async function pickEvent(page: Page, title: string, label?: string) {
const button = label ? page.getByRole("button", { name: new RegExp(`^${label}:`) }) : page.getByTestId("event-picker").first().getByRole("button").first();
await button.click();
await page.getByRole("combobox", { name: /^Search/ }).fill(title);
await page.getByRole("option", { name: new RegExp(title) }).first().click();
}
export { expect };
+2 -2
View File
@@ -1,4 +1,4 @@
import { test, expect, seed, loginAs } from "./fixtures";
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
// Kids check-in (1.45.0): a supervisor sets it up, a parent adds a child, the desk checks the child
// in (pickup code + sticker panel), the parent sees the code, and a wrong code is refused before
@@ -7,7 +7,7 @@ test.describe.serial("kids check-in", () => {
test("a supervisor sets up the kids ticket, a room and switches it on", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/kids");
await page.getByRole("combobox").first().selectOption(seed().events.kids);
await pickEvent(page, "E2E Kids Church");
await page.getByRole("button", { name: /Add a free "Kids check-in" ticket/ }).click();
await expect(page.getByLabel("Kids ticket")).not.toHaveValue("");
await page.getByRole("button", { name: /Add "Kids rooms"/ }).click();
+51
View File
@@ -0,0 +1,51 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
// 1.50.1: long lists load a page (or just what's needed) at a time, with search and filters.
test.describe("long lists", () => {
test("Payments: today, recent and a person's payments for a refund", async ({ page }) => {
// A donation recorded today for the seeded user (on an event no other test totals up).
await api(page, "supervisor", "POST", "/api/payments", {
amount: 77, method: "cash", isDonation: true, eventId: seed().events.conference, userId: seed().users.user.id,
});
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/payments");
await expect(page.getByText("R 77.00").first()).toBeVisible();
// Refund: picking the person loads their payments.
await page.locator("label", { hasText: "Refund" }).first().click();
await page.getByPlaceholder("Search by name, email or phone…").last().fill(seed().users.user.name);
await page.getByRole("button", { name: new RegExp(seed().users.user.name) }).first().click();
await expect(page.locator("option", { hasText: "R 77.00" })).toHaveCount(1);
});
test("Payments API: narrowing filters", async ({ page }) => {
const userId = seed().users.user.id;
const mine = await api<{ data: { userId: string }[] }>(page, "supervisor", "GET", `/api/payments?userId=${userId}&limit=200`);
expect(mine.data.length).toBeGreaterThan(0);
expect(mine.data.every((p) => p.userId === userId)).toBe(true);
const unpaid = await api<{ status: string }[]>(page, "supervisor", "GET", "/api/registrations?unpaid=1");
expect(unpaid.every((r) => ["pending", "confirmed", "partial_paid"].includes(r.status))).toBe(true);
});
test("Audit log: search and the full list of actions", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/audit-log");
const action = page.getByLabel("Action");
await expect(action.locator("option", { hasText: "Bulk action on registrations" })).toHaveCount(1);
await expect(action.locator("option", { hasText: "Webhook added, changed or deleted" })).toHaveCount(1);
await page.getByLabel("Search").fill("nobody-matches-this-xyz");
await expect(page.getByText("No matching audit entries.")).toBeVisible();
await expect(page.getByText(/0 actions match the filters/)).toBeVisible();
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.getByLabel("Search")).toHaveValue("");
});
test("Data requests: search", async ({ page }) => {
const res = await api<{ requests: unknown[]; total: number }>(page, "admin", "GET", "/api/privacy/requests?status=closed&search=nobody-matches-this-xyz");
expect(res.requests).toEqual([]);
expect(res.total).toBe(0);
});
});
+6 -7
View File
@@ -1,4 +1,4 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
@@ -8,7 +8,7 @@ test.describe("pick sessions", () => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.dropIn}`);
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await expect(picker.getByText("Full")).toBeVisible();
@@ -50,7 +50,7 @@ test.describe("pick sessions", () => {
test("join the waitlist for a full class", async ({ page }) => {
await loginAs(page, "admin");
await page.goto(`/register/${seed().events.dropIn}`);
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("button", { name: "Join waitlist" }).click();
@@ -67,12 +67,11 @@ test.describe("pick sessions", () => {
test("at the door: book a class for a walk-in", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/at-the-door");
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
// The page picks a default event once its list loads; choose ours after that.
await page.waitForLoadState("networkidle");
await page.locator("select").first().selectOption(value!);
await pickEvent(page, "E2E Drop-in Class");
await page.waitForLoadState("networkidle");
await expect(page.locator("select").first()).toHaveValue(value!);
await expect(page.getByRole("button", { name: /^Event: E2E Drop-in Class/ })).toBeVisible();
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
await page.getByText('New attendee "Door Walk In"…').click();
@@ -80,7 +79,7 @@ test.describe("pick sessions", () => {
await page.getByRole("button", { name: "Next — Choose Items" }).click();
await expect(page.getByText("R 50.00 per session")).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("checkbox").nth(1).check();
await expect(page.getByText("Total: R 50.00")).toBeVisible();
+2 -2
View File
@@ -10,7 +10,7 @@ test.describe("registration → payment → ticket → check-in → cashup", ()
await loginAs(page, "user");
await page.goto(`/register/${seed().events.free}`);
await expect(page.getByText("E2E Free Evening").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
@@ -21,7 +21,7 @@ test.describe("registration → payment → ticket → check-in → cashup", ()
test("a paid event: pending until paid, then a ticket that checks in once", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.paid}`);
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
await expect(page.getByText("Total: R150.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
+2 -2
View File
@@ -1,4 +1,4 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
test.describe("multi-day events", () => {
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
@@ -44,7 +44,7 @@ test.describe("multi-day events", () => {
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/ticket-scanning");
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
await pickEvent(page, "E2E Alpha Course");
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
});
+3 -4
View File
@@ -1,4 +1,4 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
@@ -80,15 +80,14 @@ test.describe("supervisor tools", () => {
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.locator("select").selectOption(value!);
await pickEvent(page, "E2E Free Evening");
await page.getByRole("button", { name: "Start Kiosk" }).click();
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
await page.getByPlaceholder("john@example.com").fill("walkin@e2e.test");
await page.getByPlaceholder("+27 82 000 0000").fill("0820000099");
await page.getByPlaceholder("John Smith").fill("Walk In Visitor");
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
});
+16
View File
@@ -67,4 +67,20 @@ test.describe.serial("outbound webhooks", () => {
await endpoint.getByRole("button", { name: "Delivery log" }).click();
await expect(endpoint.getByTestId("webhook-delivery").filter({ hasText: "user.created" })).toContainText("succeeded");
});
test("the guide explains the signature and shows an example of each event", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/settings?tab=webhooks");
const guide = page.getByTestId("webhook-guide");
const toggle = guide.getByRole("button", { name: /How webhooks work/ });
await expect(toggle).toBeVisible();
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click();
await expect(guide).toContainText("X-CrossEvents-Signature");
await guide.getByRole("button", { name: /registration.paid/ }).click();
const example = guide.getByTestId("webhook-example-registration.paid");
await expect(example).toContainText("\"type\": \"registration.paid\"");
await expect(example).toContainText("\"amountPaid\"");
await guide.getByRole("button", { name: "PHP" }).click();
await expect(guide).toContainText("hash_hmac");
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.49.2",
"version": "1.51.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.49.2",
"version": "1.51.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.49.2",
"version": "1.51.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -45,14 +45,14 @@ export default function ForgotPasswordPage() {
<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>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border rounded px-3 py-2" />
<label htmlFor="forgot-email" className="block text-sm font-medium mb-1">Email</label>
<input id="forgot-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border rounded px-3 py-2" />
</div>
<button disabled={loading || !email} className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60">
{loading ? "Sending..." : "Send reset link"}
</button>
</form>
{status && <p className="text-sm text-gray-700 mt-4">{status}</p>}
{status && <p role="status" className="text-sm text-gray-700 mt-4">{status}</p>}
</div>
</main>
<Footer />
+4 -4
View File
@@ -57,13 +57,13 @@ function ActivateAccountContent() {
{!token && <p className="text-red-600 mb-4">Missing or invalid activation link.</p>}
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
<label htmlFor="activate-password" className="block text-sm font-medium mb-1">Password</label>
<input id="activate-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
<p className="text-xs text-gray-500 mt-1">At least 8 characters.</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
<label htmlFor="activate-confirm-password" className="block text-sm font-medium mb-1">Confirm Password</label>
<input id="activate-confirm-password" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
</div>
{password && confirm && password !== confirm && (
<p className="text-xs text-red-600">Passwords do not match.</p>
@@ -52,7 +52,7 @@ export default async function VerifyCertificatePage({ params }: { params: Promis
</p>
</>
)}
<p className="text-xs text-gray-400 mt-6 font-mono">{decodeURIComponent(code).toUpperCase()}</p>
<p className="text-xs text-gray-500 mt-6 font-mono">{decodeURIComponent(code).toUpperCase()}</p>
</div>
</main>
<Footer />
+2 -2
View File
@@ -151,8 +151,8 @@ export default function ConsentPage() {
)}
<div className="space-y-2 border-t pt-4">
<label className="block text-sm font-medium text-gray-800">Type your full name to sign</label>
<input className={inputCls} value={signedName} onChange={e => setSignedName(e.target.value)} aria-label="Your full name" />
<label htmlFor="consent-type-your-full-name" className="block text-sm font-medium text-gray-800">Type your full name to sign</label>
<input id="consent-type-your-full-name" className={inputCls} value={signedName} onChange={e => setSignedName(e.target.value)} aria-label="Your full name" />
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={isGuardian} onChange={e => setIsGuardian(e.target.checked)} />
<span>I am the parent or legal guardian of {info.attendeeName}.</span>
+1 -1
View File
@@ -28,7 +28,7 @@ export default function ContactPage() {
</div>
{!loading && !hasAny && (
<p className="text-center text-sm text-gray-400">Contact details haven&apos;t been set up yet.</p>
<p className="text-center text-sm text-gray-500">Contact details haven&apos;t been set up yet.</p>
)}
<div className="grid gap-4 sm:grid-cols-3">
@@ -10,50 +10,6 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
const ACTIONS = [
"refund_created",
"donation_assigned",
"donation_unassigned",
"registration_created_manual",
"registration_cancelled",
"event_created",
"event_updated",
"event_deleted",
"settings_updated",
"coupon_created",
"coupon_updated",
"coupon_disabled",
"coupon_applied_manual",
"bundle_created",
"bundle_updated",
"bundle_deleted",
"consent_override",
"consent_rerequested",
"medical_list_viewed",
"scan_lock_acquired",
"scan_lock_released",
"scan_lock_force_released",
"offline_sync",
"ticket_transferred",
"impersonation_started",
"impersonation_ended",
"yoco_config_updated",
"yoco_webhook_registered",
"yoco_webhook_deleted",
"data_request_completed",
"data_request_rejected",
"user_anonymised",
"privacy_notice_published",
"operator_created",
"operator_updated",
"operator_deleted",
"retention_policies_updated",
"retention_run",
"breach_recorded",
"breach_updated",
"hub_connected",
] as const;
const ACTION_LABELS: Record<string, string> = {
refund_created: "Refund created",
donation_assigned: "Donation assigned",
@@ -122,8 +78,23 @@ const ACTION_LABELS: Record<string, string> = {
series_deleted: "Event series deleted",
season_pass_changed: "Season pass created, changed or deleted",
registrations_bulk: "Bulk action on registrations",
attendance_marked: "Attendance marked",
attendance_unmarked: "Attendance unmarked",
certificate_issued: "Certificate issued",
certificate_revoked: "Certificate revoked",
event_sessions_changed: "Event sessions changed",
job_run_manual: "Background job run by hand",
payfast_config_updated: "PayFast settings changed",
payment_providers_updated: "Payment providers changed",
webhook_endpoint_changed: "Webhook added, changed or deleted",
};
// Every action that can be logged (keep in step with AdminAuditAction in schema.prisma),
// sorted by label for the filter.
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).sort((x, y) => x[1].localeCompare(y[1]));
const ROLES = ["admin", "supervisor", "staff"] as const;
interface AuditLogEntry {
id: string;
actorId: string | null;
@@ -212,16 +183,32 @@ export default function AdminAuditLogPage() {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const [actionFilter, setActionFilter] = useState<string>("");
const [roleFilter, setRoleFilter] = useState<string>("");
const [searchInput, setSearchInput] = useState<string>("");
const [search, setSearch] = useState<string>("");
const [fromFilter, setFromFilter] = useState<string>("");
const [toFilter, setToFilter] = useState<string>("");
const buildQuery = useCallback((p: number) => {
const qs = new URLSearchParams({ page: String(p), limit: String(pageSize) });
if (actionFilter) qs.set("action", actionFilter);
if (roleFilter) qs.set("role", roleFilter);
if (search) qs.set("search", search);
if (fromFilter) qs.set("from", fromFilter);
if (toFilter) qs.set("to", toFilter);
return `/api/admin/audit-log?${qs.toString()}`;
}, [actionFilter, fromFilter, toFilter]);
}, [actionFilter, roleFilter, search, fromFilter, toFilter]);
// Search as you type, once typing pauses.
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput.trim()), 350);
return () => clearTimeout(t);
}, [searchInput]);
const filtered = !!(actionFilter || roleFilter || search || fromFilter || toFilter);
const clearFilters = () => {
setActionFilter(""); setRoleFilter(""); setSearchInput(""); setSearch(""); setFromFilter(""); setToFilter("");
};
const load = useCallback(async (p = 1) => {
if (!token) return;
@@ -251,7 +238,7 @@ export default function AdminAuditLogPage() {
<div>
<BackLink href="/dashboard/admin" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Admin Audit Log</h1>
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} {filtered ? "match the filters" : "recorded"}</p>
</div>
</div>
@@ -262,25 +249,48 @@ export default function AdminAuditLogPage() {
)}
{isAdmin && token && <ImpersonationSessions token={token} />}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{error && <div role="alert" className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div className="flex-1 min-w-[14rem]">
<label htmlFor="audit-search" className="block text-xs text-gray-600 mb-1">Search</label>
<input
id="audit-search"
type="search"
className="w-full border rounded px-2 py-1.5 text-sm"
placeholder="Person, target (e.g. Event or #id) or IP"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Action</label>
<select className="border rounded px-2 py-1.5 text-sm" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
<label htmlFor="audit-action" className="block text-xs text-gray-600 mb-1">Action</label>
<select id="audit-action" className="border rounded px-2 py-1.5 text-sm max-w-[16rem]" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
<option value="">All actions</option>
{ACTIONS.map(a => <option key={a} value={a}>{ACTION_LABELS[a]}</option>)}
{ACTION_OPTIONS.map(([a, label]) => <option key={a} value={a}>{label}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
<label htmlFor="audit-role" className="block text-xs text-gray-600 mb-1">Role</label>
<select id="audit-role" className="border rounded px-2 py-1.5 text-sm" value={roleFilter} onChange={e => setRoleFilter(e.target.value)}>
<option value="">Any role</option>
{ROLES.map(r => <option key={r} value={r} className="capitalize">{r[0].toUpperCase() + r.slice(1)}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} onChange={e => setToFilter(e.target.value)} />
<label htmlFor="audit-from" className="block text-xs text-gray-600 mb-1">From</label>
<input id="audit-from" type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
</div>
<div>
<label htmlFor="audit-to" className="block text-xs text-gray-600 mb-1">To</label>
<input id="audit-to" type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} min={fromFilter || undefined} onChange={e => setToFilter(e.target.value)} />
</div>
{filtered && (
<button className="text-sm px-2 py-1.5 rounded text-brand-700 hover:bg-brand-50" onClick={clearFilters}>
Clear filters
</button>
)}
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => load(page)} disabled={fetching}>
{fetching ? "Refreshing…" : "Refresh"}
</button>
@@ -307,9 +317,9 @@ export default function AdminAuditLogPage() {
<div className="text-xs text-gray-500">{e.actor.email}</div>
</>
) : (
<span className="text-gray-400 italic">Deleted user</span>
<span className="text-gray-500 italic">Deleted user</span>
)}
<div className="text-xs text-gray-400 capitalize">{e.actorRole}</div>
<div className="text-xs text-gray-500 capitalize">{e.actorRole}</div>
</TableCell>
<TableCell>{ACTION_LABELS[e.action] || e.action}</TableCell>
<TableCell className="text-xs text-gray-600">{e.targetType}{e.targetId ? ` #${e.targetId.slice(0, 8)}` : ""}</TableCell>
@@ -323,7 +333,7 @@ export default function AdminAuditLogPage() {
)}
{fetching && (
<TableRow>
<TableCell colSpan={5} className="text-gray-400">Loading…</TableCell>
<TableCell colSpan={5} className="text-gray-500">Loading…</TableCell>
</TableRow>
)}
</TableBody>
@@ -331,7 +341,7 @@ export default function AdminAuditLogPage() {
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<span className="text-gray-500">Page {page} of {totalPages}</span>
<span className="text-gray-500">{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} · page {page} of {totalPages}</span>
<div className="flex gap-1">
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { Pager } from "@/components/shared/Pager";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Inbox, AlertTriangle } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -135,18 +136,33 @@ export default function DataRequestsPage() {
const [view, setView] = useState<"open" | "closed">("open");
const [requests, setRequests] = useState<DataRequest[] | null>(null);
const [openCount, setOpenCount] = useState(0);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const pageSize = 50;
// Search once typing pauses; a new search or tab starts at page 1.
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput.trim()), 350);
return () => clearTimeout(t);
}, [searchInput]);
useEffect(() => { setPage(1); }, [view, search]);
const [msg, setMsg] = useDismissingState<Msg>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ requests: DataRequest[]; openCount: number }>(`/api/privacy/requests?status=${view}`, { authToken: token });
const qs = new URLSearchParams({ status: view, page: String(page), limit: String(pageSize) });
if (search) qs.set("search", search);
const res = await apiFetch<{ requests: DataRequest[]; openCount: number; total: number }>(`/api/privacy/requests?${qs.toString()}`, { authToken: token });
setRequests(res.requests);
setOpenCount(res.openCount);
setTotal(res.total ?? res.requests.length);
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load requests." });
}
}, [token, view, setMsg]);
}, [token, view, page, search, setMsg]);
useEffect(() => { setRequests(null); load(); }, [load]);
return (
@@ -163,20 +179,28 @@ export default function DataRequestsPage() {
</p>
</div>
<div className="flex gap-2 mb-4">
<div className="flex flex-wrap items-center gap-2 mb-4">
{(["open", "closed"] as const).map(v => (
<button key={v} onClick={() => setView(v)} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${view === v ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
<button key={v} onClick={() => setView(v)} aria-pressed={view === v} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${view === v ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
{v === "open" ? `Open${openCount ? ` (${openCount})` : ""}` : "Closed"}
</button>
))}
<input
type="search"
aria-label="Search by name or email"
placeholder="Search by name or email"
className="ml-auto border rounded-lg px-3 py-1.5 text-sm w-full sm:w-64"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
/>
</div>
{msg && <div className={`mb-4 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{requests === null ? (
<p className="text-sm text-gray-400">Loading…</p>
<p className="text-sm text-gray-500">Loading…</p>
) : requests.length === 0 ? (
<p className="text-sm text-gray-500">{view === "open" ? "No open requests." : "No closed requests yet."}</p>
<p className="text-sm text-gray-500">{search ? "No requests match your search." : view === "open" ? "No open requests." : "No closed requests yet."}</p>
) : view === "open" ? (
<ul className="space-y-3">
{requests.map(r => <OpenRequest key={r.id} request={r} token={token!} onDone={m => { setMsg(m); load(); }} />)}
@@ -189,13 +213,14 @@ export default function DataRequestsPage() {
<span className="font-medium text-gray-900">{TYPE_LABEL[r.type]}</span>
<span className="text-gray-700">— {r.name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_TONE[r.status]}`}>{r.status}</span>
<span className="ml-auto text-xs text-gray-400">{r.closedAt ? `Closed ${fmtDate(r.closedAt)}` : ""}{r.handledBy ? ` by ${r.handledBy}` : ""}</span>
<span className="ml-auto text-xs text-gray-500">{r.closedAt ? `Closed ${fmtDate(r.closedAt)}` : ""}{r.handledBy ? ` by ${r.handledBy}` : ""}</span>
</div>
{r.response && <p className="text-gray-600 mt-1 whitespace-pre-line">{r.response}</p>}
</li>
))}
</ul>
)}
<Pager page={page} pageSize={pageSize} total={total} onPage={setPage} />
</div>
);
}
@@ -11,6 +11,7 @@ import type { Event, FormResponse, Registration } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { EventPicker } from "@/components/shared/EventPicker";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -265,13 +266,8 @@ export default function AdminRegistrationsPage() {
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded px-2 py-1.5 text-sm max-w-48" value={eventFilter} onChange={e => setEventFilter(e.target.value)}>
<option value="">All events</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="registrations-event">Event</label>
<EventPicker id="registrations-event" className="min-w-[14rem]" events={events} value={eventFilter} onChange={setEventFilter} emptyLabel="All events" />
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
@@ -728,6 +728,7 @@ function SiteSettingsPageInner() {
<div className="lg:col-span-2 lg:sticky lg:top-4">
<BrandingPreviewPanel
primaryColor={primaryColor}
onUsePrimary={setPrimaryColor}
secondaryColor={secondaryColor}
accentColor={accentColor}
logoSrc={currentLogoSrc}
@@ -12,6 +12,7 @@ import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, Ticket as TicketRecord } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
function EventTicketsContent() {
const { token, user } = useAuth();
@@ -40,7 +41,7 @@ function EventTicketsContent() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
const now = Date.now();
const active = (evs || []).filter((ev) => {
const t = new Date(ev.endDate).getTime();
@@ -169,12 +170,14 @@ function EventTicketsContent() {
)}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
<label className="text-sm text-gray-700 shrink-0" htmlFor="tickets-event">Event</label>
<EventPicker
id="tickets-event"
className="w-full sm:w-72"
events={events}
emptyLabel="All events"
value={eventId}
onChange={(e) => {
const v = e.target.value;
onChange={(v) => {
setEventId(v);
setSelectedUserId("");
router.replace(
@@ -185,14 +188,7 @@ function EventTicketsContent() {
}`
);
}}
>
<option value="">All events</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
/>
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
<select
@@ -11,6 +11,7 @@ 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";
import { EventPicker } from "@/components/shared/EventPicker";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink";
@@ -72,7 +73,6 @@ export default function TicketScanningPage() {
// Event selection state
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<Section[]>([]);
@@ -98,14 +98,12 @@ export default function TicketScanningPage() {
})();
}, [token]);
const events = useMemo(() => {
if (includePastEvents) return allEvents;
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
return allEvents.filter(ev => {
const t = new Date(ev.endDate).getTime();
return !isNaN(t) && t >= cutoff;
});
}, [allEvents, includePastEvents]);
// The picker: every event, with the offline-locked one even if it has gone from the list.
const pickerEvents = useMemo(() => {
const lock = offline.lock;
if (!lock || allEvents.some(ev => ev.id === lock.eventId)) return allEvents;
return [...allEvents, { id: lock.eventId, title: lock.eventTitle, startDate: new Date().toISOString() } as Event];
}, [allEvents, offline.lock]);
const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]);
const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]);
@@ -360,23 +358,18 @@ export default function TicketScanningPage() {
{/* Event filter */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
<label className="text-sm text-gray-700 shrink-0" htmlFor="scan-event">Event</label>
{/* Events that ended in the last 12 hours still count as current (late scanning). */}
<EventPicker
id="scan-event"
className="w-full sm:w-72"
events={pickerEvents}
value={selectedEventId === "all" ? "" : selectedEventId}
onChange={id => setSelectedEventId(id || "all")}
emptyLabel="All events"
graceHours={12}
disabled={!!offline.lock}
>
<option value="all">All events</option>
{offline.lock && !events.some(ev => ev.id === offline.lock!.eventId) && <option value={offline.lock.eventId}>{offline.lock.eventTitle}</option>}
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
Include past events
</label>
/>
{loadingEvents && <span className="text-xs text-gray-500">Loading events…</span>}
</div>
{sections.length > 0 && (
@@ -418,7 +411,7 @@ export default function TicketScanningPage() {
offline={offline}
token={token}
eventId={selectedEventId}
eventTitle={events.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
eventTitle={allEvents.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
sectionId={selectedSectionId !== "all" ? selectedSectionId : null}
sectionName={sections.find(s => s.id === selectedSectionId)?.name || null}
onError={setError}
@@ -4,13 +4,13 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { RosterSheet } from "@/components/volunteers/RosterSheet";
import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import type { RosterEvent } from "@/lib/volunteers";
import { EventPicker } from "@/components/shared/EventPicker";
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
export default function StaffVolunteersPage() {
@@ -48,13 +48,10 @@ export default function StaffVolunteersPage() {
{events && !events.length && <div className="p-3 border rounded bg-amber-50 text-amber-800 text-sm">No events have volunteer rosters yet. A supervisor adds serving roles under Supervisor → Volunteer rosters.</div>}
{events && events.length > 0 && (
<div className="flex flex-wrap gap-2 items-end print:hidden">
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[14rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{!eventId && <option value="">Choose an event…</option>}
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}</option>)}
</select>
</label>
<div className="text-sm">
<label className="block text-xs text-gray-500 mb-1" htmlFor="staff-volunteers-event">Event</label>
<EventPicker id="staff-volunteers-event" className="w-full" events={events} value={eventId} onChange={setEventId} />
</div>
{eventId && <button onClick={() => setShowQr(q => !q)} className="px-3 py-2 rounded-lg border text-sm">{showQr ? "Show the roster" : "Show the check-in QR code"}</button>}
</div>
)}
@@ -5,12 +5,12 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
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";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
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 }[] };
@@ -222,10 +222,8 @@ export default function AllocationsPage() {
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => { setEventId(e.target.value); setSetId(""); }}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="allocations-event">Event</label>
<EventPicker id="allocations-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); setSetId(""); }} />
</div>
{board && (
<div className="flex flex-wrap items-center gap-1">
@@ -18,6 +18,7 @@ import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPr
import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -426,18 +427,7 @@ export default function AtTheDoorPage() {
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">At The Door</h1>
</div>
</div>
<select
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
value={eventId}
onChange={e => setEventId(e.target.value)}
>
<option value="">Select event…</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
<EventPicker className="w-full sm:w-72" events={events} value={eventId} onChange={setEventId} placeholder="Select event" />
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 shrink-0 self-start sm:self-auto"
onClick={() => router.push("/dashboard")}
@@ -1314,9 +1304,9 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
</div>
<div className="flex items-center gap-3">
<button onClick={() => stepper(key, -1)} disabled={(quantities[key] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
<button onClick={() => stepper(key, -1)} aria-label={`Remove one ${opt.name} (${v.name})`} disabled={(quantities[key] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
<div className="w-6 text-center text-base font-semibold">{quantities[key] || 0}</div>
<button onClick={() => stepper(key, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
<button onClick={() => stepper(key, 1)} aria-label={`Add one ${opt.name} (${v.name})`} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
</div>
{picker(key, opt)}
@@ -1338,9 +1328,9 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
</div>
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
<div className="flex gap-3 items-center mt-2">
<button onClick={() => stepper(opt.id, -1)} disabled={(quantities[opt.id] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
<button onClick={() => stepper(opt.id, -1)} aria-label={`Remove one ${opt.name}`} disabled={(quantities[opt.id] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
<div className="w-6 text-center text-lg font-semibold">{quantities[opt.id] || 0}</div>
<button onClick={() => stepper(opt.id, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
<button onClick={() => stepper(opt.id, 1)} aria-label={`Add one ${opt.name}`} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
{picker(opt.id, opt)}
</div>
@@ -7,10 +7,10 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { fetchApiFile, saveBlob } from "@/lib/export";
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
@@ -154,13 +154,8 @@ function AttendancePageInner() {
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem] max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No events</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))}{ev.scheduleType === "sessions" ? ` · ${(ev.sessions || []).filter(s => !s.cancelled).length} sessions` : ""})</option>
))}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="attendance-event">Event</label>
<EventPicker id="attendance-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No events" />
</div>
<div className="relative">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
@@ -9,6 +9,7 @@ import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-reac
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
@@ -56,7 +57,6 @@ const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
// fieldCls has no width, for fields sized by their row (inputCls is full width).
const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const inputCls = `w-full ${fieldCls}`;
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
const KIND_INFO: Record<BundleKind, { label: string; hint: string; example: string }> = {
exact: { label: "Exact set", hint: "These tickets in these numbers", example: "2 adults + 3 kids for R220" },
@@ -92,7 +92,6 @@ export default function BundlesPage() {
}, [user, loading, router]);
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
const [showPast, setShowPast] = useState(false);
const [eventId, setEventId] = useState("");
const [bundles, setBundles] = useState<EventBundle[]>([]);
const [fetching, setFetching] = useState(false);
@@ -107,19 +106,12 @@ export default function BundlesPage() {
.catch(e => setError(errorMessage(e) || "Failed to load events"));
}, [token, canView, setError]);
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
const events = useMemo(() => {
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return showPast ? [...upcoming, ...past] : upcoming;
}, [allEvents, showPast]);
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
// Start on the next upcoming event (or the latest one).
useEffect(() => {
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
}, [events, eventId]);
if (!allEvents.some(e => e.id === eventId)) setEventId(defaultEventId(allEvents));
}, [allEvents, eventId]);
const event = events.find(e => e.id === eventId) || null;
const event = allEvents.find(e => e.id === eventId) || null;
const options = event?.eventOptions || [];
const load = useCallback(async () => {
@@ -194,17 +186,9 @@ export default function BundlesPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
<div className="w-full sm:w-auto">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
))}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="bundles-event">Event</label>
<EventPicker id="bundles-event" className="w-full sm:min-w-[20rem]" events={allEvents} value={eventId} onChange={setEventId} placeholder="No events" />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
</div>
{event && options.length === 0 && (
@@ -11,6 +11,7 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@
import { errorMessage } from "@/lib/utils";
import type { Event, ConsentRow, MedicalRow } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
@@ -49,7 +50,7 @@ export default function ConsentTrackerPage() {
.then(evs => {
const youth = (evs || []).filter(e => e.requiresParentalConsent);
setEvents(youth);
if (!eventId && youth.length) setEventId(youth[youth.length - 1].id);
if (!eventId && youth.length) setEventId(defaultEventId(youth));
})
.catch(e => setError(e?.message || "Failed to load events"));
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -149,11 +150,8 @@ export default function ConsentTrackerPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Youth event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No youth events</option>}
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="consent-event">Youth event</label>
<EventPicker id="consent-event" label="Youth event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No youth events" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Show</label>
@@ -12,6 +12,7 @@ import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
import { EventPicker } from "@/components/shared/EventPicker";
type Attendee = { id: string; name: string; email: string; pref: string };
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
@@ -190,16 +191,8 @@ function EmailAttendeesPageInner() {
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
const now = Date.now();
if (evIncludePast) return allEvents;
return allEvents.filter(ev => {
const t = new Date(ev.endDate).getTime();
return !isNaN(t) && t > now;
});
}, [allEvents, evIncludePast]);
// Every event; the pickers open on upcoming ones and keep past ones a click away.
const events = allEvents;
const loadEvents = useCallback(async () => {
try {
@@ -544,18 +537,9 @@ function EmailAttendeesPageInner() {
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<div className="flex gap-2 items-center">
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0 max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Select an event…</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select an event" />
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} />
Include past events
</label>
</div>
<div className="grid sm:grid-cols-3 gap-3">
@@ -740,12 +724,7 @@ function EmailAttendeesPageInner() {
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={e => setBroadcastEventId(e.target.value)}>
<option value="">No event</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={broadcastEventId} onChange={setBroadcastEventId} emptyLabel="No event" />
</div>
</div>
@@ -1122,12 +1101,7 @@ With gratitude`);
<div className="grid gap-4">
<div>
<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) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={autoEventId} onChange={setAutoEventId} placeholder="Select an event" />
</div>
<div className="grid gap-4">
@@ -1219,12 +1193,7 @@ With gratitude`);
<div className="grid sm:grid-cols-2 gap-3 mb-2">
<div>
<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) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={promoEventId} onChange={setPromoEventId} placeholder="Select event to promote" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
@@ -9,6 +9,7 @@ import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventOption } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
// 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
@@ -228,12 +229,7 @@ function EventOptionsContent() {
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-3">Select event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event…</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
@@ -10,6 +10,7 @@ import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
@@ -493,19 +494,8 @@ export default function FormsBrowserPage() {
<div className="text-base font-semibold mb-3">Filters</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 items-end">
<div className="lg:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select
className="border rounded px-2 py-1.5 text-sm w-full"
value={selectedEventId}
onChange={e => setSelectedEventId(e.target.value)}
>
<option value="">All events</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.title}{!ev.isActive ? ' (inactive)' : ''}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}
</option>
))}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="forms-view-event">Event</label>
<EventPicker id="forms-view-event" className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} emptyLabel="All events" />
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} /> Include past events
@@ -629,18 +619,9 @@ export default function FormsBrowserPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-base font-semibold mb-3">Edit form structure</div>
<div className="mb-4">
<label className="block text-xs text-gray-600 mb-1">Select event</label>
<select
className="border rounded px-2 py-1.5 text-sm w-full sm:max-w-sm"
value={selectedEventId}
onChange={e => { setSelectedEventId(e.target.value); setFormLoadedFor(""); }}
>
<option value="">Choose an event…</option>
{allEvents.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}{!ev.isActive ? ' (inactive)' : ''}</option>
))}
</select>
<div className="text-xs text-gray-400 mt-1">All events shown (including inactive/past) so you can edit historical forms.</div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="forms-manage-event">Select event</label>
<EventPicker id="forms-manage-event" className="w-full sm:max-w-sm" events={allEvents} value={selectedEventId} onChange={id => { setSelectedEventId(id); setFormLoadedFor(""); }} />
<div className="text-xs text-gray-500 mt-1">Inactive and past events are included (search, or Show past events), so you can edit historical forms.</div>
{formLoading && <span className="text-xs text-gray-400 mt-1 block">Loading form…</span>}
</div>
{!selectedEventId ? (
@@ -678,10 +659,7 @@ export default function FormsBrowserPage() {
<div className="grid sm:grid-cols-2 gap-3 mb-4 items-end">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillEventId} onChange={e => setFillEventId(e.target.value)}>
<option value="">Select event…</option>
{allEvents.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
<EventPicker className="w-full" events={allEvents} value={fillEventId} onChange={setFillEventId} placeholder="Select event" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration</label>
@@ -5,10 +5,10 @@ import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, Plus, Trash2 } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
type Settings = {
@@ -96,13 +96,10 @@ export default function KidsSetupPage() {
</div>
</div>
<label className="text-sm block max-w-md">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Choose an event…</option>
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.kidsCheckIn ? " · kids check-in on" : ""}</option>)}
</select>
</label>
<div className="text-sm block max-w-md">
<label className="block text-xs text-gray-500 mb-1" htmlFor="kids-setup-event">Event</label>
<EventPicker id="kids-setup-event" className="w-full" events={events} value={eventId} onChange={setEventId} describe={e => (e.kidsCheckIn ? "kids check-in on" : null)} />
</div>
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
@@ -16,6 +16,7 @@ import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -430,12 +431,7 @@ export default function ManualRegistrationPage() {
<div className="grid md:grid-cols-2 gap-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">1) Choose event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event…</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
@@ -4,12 +4,12 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
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";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
type Report = {
@@ -101,10 +101,8 @@ export default function OfflineScansPage() {
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="offline-scans-event">Event</label>
<EventPicker id="offline-scans-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 pb-2">
<input type="checkbox" checked={onlyOpen} onChange={e => setOnlyOpen(e.target.checked)} /> Only reports that need review
@@ -13,6 +13,7 @@ import type { Event, Payment, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
import { EventPicker } from "@/components/shared/EventPicker";
// 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.
@@ -53,6 +54,19 @@ type ProviderTx = {
note?: string | null;
};
// What each registration still owes. The backend attaches a tranche-aware totalDueComputed
// (exact even when a line spans multiple early-bird prices); otherwise estimate it here.
function computeOutstanding(list: Registration[]): Outstanding {
const now = new Date();
const map: Outstanding = {};
for (const r of list) {
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
return map;
}
function RegistrationOptions({ regs, regOutstanding }: {
regs: Registration[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
@@ -161,7 +175,11 @@ function PaymentsContent() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [payments, setPayments] = useState<Payment[]>([]);
// Not every payment ever made: the latest few for "Recent payments", today's for the totals,
// and donations with the legs that apply them (for assigning and the assigned list).
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
const [todayPayments, setTodayPayments] = useState<Payment[]>([]);
const [donationPayments, setDonationPayments] = useState<Payment[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
@@ -173,8 +191,15 @@ function PaymentsContent() {
if (!token) return;
try {
setLoadingList(true);
const list = await fetchAllPayments(token);
setPayments(list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
const start = new Date(); start.setHours(0, 0, 0, 0);
const [recent, today, donations] = await Promise.all([
apiFetch<{ data: Payment[] }>("/api/payments?page=1&limit=40", { authToken: token }),
fetchAllPayments(token, { from: start.toISOString() }),
fetchAllPayments(token, { donations: "1" }),
]);
setRecentPayments(recent?.data || []);
setTodayPayments(today);
setDonationPayments(donations);
} catch (e) {
setError(errorMessage(e) || "Failed to load payments");
} finally {
@@ -184,25 +209,16 @@ function PaymentsContent() {
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.
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/registrations
// embeds each registration's `payments`, so balances come from that in one pass. Refunds load
// the chosen person's own registrations (including paid ones) when needed.
const loadRegistrations = useCallback(async () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
const regs = await apiFetch<Registration[]>("/api/registrations?unpaid=1", { authToken: token });
const list = Array.isArray(regs) ? regs : [];
const now = new Date();
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, 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);
setRegOutstanding(computeOutstanding(list));
// Sort by createdAt desc
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
setRegistrations(sorted);
@@ -352,22 +368,20 @@ function PaymentsContent() {
if (uid) setSelectedUserId(String(uid));
}, [registrationId, registrations]);
const [eventsIncludePast, setEventsIncludePast] = useState(false);
const [eventsIncludeInactive, setEventsIncludeInactive] = useState(false);
useEffect(() => {
(async () => {
try {
if (!token) return;
const params = new URLSearchParams();
if (eventsIncludePast) params.set("includePast", "true");
// Past events too: the pickers open on upcoming ones and keep past ones a click away.
const params = new URLSearchParams({ includePast: "true" });
if (eventsIncludeInactive) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
const evs = await apiFetch<Event[]>(`/api/events/all?${params.toString()}`, { authToken: token });
setEvents(evs || []);
} catch {}
})();
}, [token, eventsIncludePast, eventsIncludeInactive]);
}, [token, eventsIncludeInactive]);
useEffect(() => {
// Keep inputs sensible when toggling donation
@@ -420,11 +434,11 @@ function PaymentsContent() {
const todayTotals = useMemo(() => {
const start = new Date(); start.setHours(0,0,0,0);
// Exclude donation-application legs — that money was already counted once, as the donation.
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
const today = todayPayments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
const revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
const donations = today.filter(p => p.isDonation).length;
return { revenue, donations, count: today.length };
}, [payments]);
}, [todayPayments]);
const [mode, setMode] = useState<'payment'|'refund'|'donation'|'reconcile'|'link'>("payment");
@@ -650,10 +664,7 @@ function PaymentsContent() {
</div>
<div>
<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={reconcileForm.eventId} onChange={e => setReconcileForm(prev => ({ ...prev, eventId: e.target.value }))}>
<option value="">Select event…</option>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
<EventPicker className="w-full" events={events} value={reconcileForm.eventId} onChange={id => setReconcileForm(prev => ({ ...prev, eventId: id }))} placeholder="Select event" />
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 rounded bg-emerald-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.eventId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
@@ -707,15 +718,8 @@ function PaymentsContent() {
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (for donations)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)} disabled={!isDonation}>
<option value="">Select event…</option>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select event" disabled={!isDonation} />
<div className="flex items-center gap-4 mt-1.5 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={eventsIncludePast} onChange={e => setEventsIncludePast(e.target.checked)} />
Include past events
</label>
{user?.role === "admin" && (
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={eventsIncludeInactive} onChange={e => setEventsIncludeInactive(e.target.checked)} />
@@ -741,10 +745,7 @@ function PaymentsContent() {
{mode === 'refund' && (
<>
<RefundSection
payments={payments}
allUsers={allUsers}
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'); }}
/>
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
@@ -754,7 +755,7 @@ function PaymentsContent() {
{mode === 'donation' && (
<>
<DonationAssignSection
payments={payments}
payments={donationPayments}
allUsers={allUsers}
registrations={registrations}
regOutstanding={regOutstanding}
@@ -762,7 +763,7 @@ function PaymentsContent() {
/>
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
<AssignedDonationsList
payments={payments}
payments={donationPayments}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }}
/>
</>
@@ -815,7 +816,7 @@ function PaymentsContent() {
{loadingList && <span className="text-xs text-gray-500">Loading…</span>}
</div>
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
{payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
{recentPayments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
return (
@@ -834,7 +835,7 @@ function PaymentsContent() {
</li>
);
})}
{payments.length === 0 && <li className="text-gray-500">No payments yet.</li>}
{recentPayments.length === 0 && <li className="text-gray-500">No payments yet.</li>}
</ul>
</div>
</div>
@@ -864,14 +865,13 @@ function PaymentsContent() {
}
type RefundSectionProps = {
payments: Payment[];
allUsers: User[];
regsForUser: (userId: string) => Registration[];
regOutstanding: Outstanding;
onDone: () => void | Promise<void>;
};
function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
// The chosen person's own payments and registrations (paid ones too) are loaded when they're
// picked, rather than every payment and registration up front.
function RefundSection({ allUsers, onDone }: RefundSectionProps) {
const { token } = useAuth();
const [userId, setUserId] = useState<string>("");
const [target, setTarget] = useState<'payment'|'registration'>('payment');
@@ -883,10 +883,28 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
const paymentsForUser = useMemo(() => {
if (!userId) return [];
return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0);
}, [payments, userId]);
const [payments, setPayments] = useState<Payment[]>([]);
const [regs, setRegs] = useState<Registration[]>([]);
const [loadingUser, setLoadingUser] = useState(false);
useEffect(() => {
setPayments([]); setRegs([]);
if (!userId || !token) return;
let cancelled = false;
setLoadingUser(true);
Promise.all([
fetchAllPayments(token, { userId }),
apiFetch<Registration[]>(`/api/registrations?userId=${encodeURIComponent(userId)}`, { authToken: token }),
]).then(([p, r]) => {
if (cancelled) return;
setPayments(p);
setRegs((Array.isArray(r) ? r : []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
}).catch((e) => { if (!cancelled) setErr(errorMessage(e) || "Couldn't load this person's payments"); })
.finally(() => { if (!cancelled) setLoadingUser(false); });
return () => { cancelled = true; };
}, [userId, token]);
const regOutstanding = useMemo(() => computeOutstanding(regs), [regs]);
const paymentsForUser = useMemo(() => payments.filter(p => (p.amount || 0) > 0), [payments]);
useEffect(() => {
// Reset dependent fields on changes
@@ -907,7 +925,6 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
}
}, [paymentId, target, payments]);
const regs = useMemo(() => regsForUser(userId), [regsForUser, userId]);
const submitRefund = async () => {
if (!token) return;
@@ -948,6 +965,7 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
<div className="grid gap-2">
<label className="text-xs text-gray-600">User</label>
<UserSearchField allUsers={allUsers} value={userId} onChange={uid => setUserId(uid)} />
{loadingUser && <div className="text-xs text-gray-500" role="status">Loading their payments…</div>}
<label className="text-xs text-gray-600 mt-2">Refund target</label>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2"><input type="radio" name="refundTarget" checked={target==='payment'} onChange={()=>setTarget('payment')} /> Payment</label>
@@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { RosterSheet } from "@/components/volunteers/RosterSheet";
import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
import { EventPicker } from "@/components/shared/EventPicker";
type Settings = {
event: { id: string; title: string; volunteerOptionId: string | null };
@@ -91,13 +91,10 @@ export default function VolunteerRostersPage() {
</div>
</div>
<label className="text-sm block max-w-md print:hidden">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Choose an event…</option>
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.roles ? ` · ${e.roles} role${e.roles === 1 ? "" : "s"}` : ""}</option>)}
</select>
</label>
<div className="text-sm block max-w-md print:hidden">
<label className="block text-xs text-gray-500 mb-1" htmlFor="volunteers-event">Event</label>
<EventPicker id="volunteers-event" className="w-full" events={events} value={eventId} onChange={setEventId} describe={e => (e.roles ? `${e.roles} role${e.roles === 1 ? "" : "s"}` : null)} />
</div>
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
import { entryLineName } from "@/lib/waitlist";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Entry = {
id: string;
@@ -117,11 +118,8 @@ export default function WaitlistPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No events with a waitlist</option>}
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="waitlist-event">Event</label>
<EventPicker id="waitlist-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No events with a waitlist" />
</div>
{selected && (
<p className="text-xs text-gray-500">
@@ -12,6 +12,7 @@ import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
import { EventPicker } from "@/components/shared/EventPicker";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -263,12 +264,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
<div className="grid gap-4">
<div>
<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) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={autoEventId} onChange={setAutoEventId} placeholder="Select an event" />
</div>
<div className="grid gap-4">
@@ -328,12 +324,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
<div className="grid sm:grid-cols-2 gap-3 mb-2">
<div>
<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) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={promoEventId} onChange={setPromoEventId} placeholder="Select event to promote" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
@@ -379,13 +370,8 @@ function WhatsAppAttendeesPageInner() {
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
const now = Date.now();
if (evIncludePast) return allEvents;
return allEvents.filter((ev) => !isNaN(new Date(ev.endDate).getTime()) && new Date(ev.endDate).getTime() > now);
}, [allEvents, evIncludePast]);
// Every event; the pickers open on upcoming ones and keep past ones a click away.
const events = allEvents;
useEffect(() => {
const loadEvents = async () => {
@@ -651,18 +637,9 @@ function WhatsAppAttendeesPageInner() {
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<div className="flex gap-2 items-center">
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0" value={eventId} onChange={(e) => setEventId(e.target.value)}>
<option value="">Select an event…</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? " (past)" : ""}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select an event" />
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={(e) => setEvIncludePast(e.target.checked)} />
Include past events
</label>
</div>
<div className="grid sm:grid-cols-3 gap-3">
@@ -814,12 +791,7 @@ function WhatsAppAttendeesPageInner() {
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={(e) => setBroadcastEventId(e.target.value)}>
<option value="">No event</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={broadcastEventId} onChange={setBroadcastEventId} emptyLabel="No event" />
</div>
</div>
@@ -83,13 +83,14 @@ export default function DonatePage() {
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
<label htmlFor="donate-event" className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select id="donate-event" className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
<label htmlFor="donate-amount" className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
<input
id="donate-amount"
type="number"
min="15"
step="1"
@@ -207,17 +207,17 @@ function FormsContent() {
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
) : f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
<select aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
<option value="">Select</option>
<option value="yes">Yes</option>
<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 => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
<input type="date" aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
<input type="number" aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
<input type="text" aria-label={f.label} className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
+29 -24
View File
@@ -20,6 +20,7 @@ import { BundleSavings } from "@/components/shared/BundleDeals";
import { MyCertificates } from "@/components/events/MyCertificates";
import { isPickOption } from "@/lib/sessions";
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
// Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
@@ -952,10 +953,10 @@ export default function UserDashboardPage() {
)}
{activeReg && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<ModalOverlay labelledBy="registration-title" closeOnBackdrop className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClose={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold flex items-center gap-2">
<h3 id="registration-title" className="text-lg font-semibold flex items-center gap-2">
<span>{activeReg.event?.title || activeReg.eventId}</span>
<EventStatusBadge event={activeReg.event} />
</h3>
@@ -1057,14 +1058,14 @@ export default function UserDashboardPage() {
className="px-3 py-1 text-sm rounded border border-brand-600 text-brand-700 hover:bg-brand-50 disabled:opacity-50"
>{codeBusy ? 'Applying…' : 'Apply'}</button>
</div>
{codeError && <p className="text-xs text-red-600 mt-1">{codeError}</p>}
{codeError && <p role="alert" className="text-xs text-red-600 mt-1">{codeError}</p>}
</div>
)}
</div>
);
})() : (
<div className="border rounded p-3 space-y-2 bg-gray-50">
{editError && <p className="text-xs text-red-600">{editError}</p>}
{editError && <p role="alert" className="text-xs text-red-600">{editError}</p>}
{editLoading ? (
<div className="text-sm text-gray-600">Loading options…</div>
) : (
@@ -1073,7 +1074,7 @@ export default function UserDashboardPage() {
{editEventOptions.map((eo) => {
const hasVariants = Array.isArray(eo.variants) && eo.variants.length > 0;
const renderQtyInput = (key: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
const renderQtyInput = (key: string, label: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
const qty = editQuantities[key] || 0;
const minQty = editMinQty[key] || 0;
const baseQty = editBaseQty[key] || 0;
@@ -1082,8 +1083,8 @@ export default function UserDashboardPage() {
: undefined;
return (
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">Qty</label>
<input type="number" min={minQty} max={maxQty} value={qty}
<label htmlFor={`qty-${key}`} className="text-xs text-gray-600">Qty</label>
<input id={`qty-${key}`} aria-label={`${label} quantity`} type="number" min={minQty} max={maxQty} value={qty}
onChange={e => {
let v = Math.max(minQty, parseInt(e.target.value || '0', 10) || 0);
if (maxQty !== undefined) v = Math.min(v, maxQty);
@@ -1113,7 +1114,7 @@ export default function UserDashboardPage() {
<div className="text-gray-500 text-[11px]">{minQty} already issued</div>
)}
</div>
{renderQtyInput(key, unitPrice, v.stockLimit, v.availableCount)}
{renderQtyInput(key, `${eo.name} (${v.name})`, unitPrice, v.stockLimit, v.availableCount)}
</li>
);
})}
@@ -1134,7 +1135,7 @@ export default function UserDashboardPage() {
<div className="text-gray-500 text-[11px]">{minQty} already issued</div>
)}
</div>
{renderQtyInput(key, unitPrice, eo.stockLimit, eo.availableCount)}
{renderQtyInput(key, eo.name, unitPrice, eo.stockLimit, eo.availableCount)}
</div>
);
})}
@@ -1243,16 +1244,18 @@ export default function UserDashboardPage() {
)}
</div>
</div>
</div>
</ModalOverlay>
)}
{channelPicker && (
<div
<ModalOverlay
labelledBy="channel-title"
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
onClick={() => setChannelPicker(null)}
onClose={() => setChannelPicker(null)}
closeOnBackdrop
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5">
<h2 id="channel-title" className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</h2>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
@@ -1266,26 +1269,28 @@ export default function UserDashboardPage() {
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
<button className="mt-3 text-xs text-gray-600 hover:underline px-1 py-1" onClick={() => setChannelPicker(null)}>Cancel</button>
</div>
</div>
</ModalOverlay>
)}
{dialog.open && (
<div
<ModalOverlay
labelledBy="send-dialog-title"
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => { if (!dialog.loading) setDialog({ open: false, message: "", loading: false }); }}
onClose={() => { if (!dialog.loading) setDialog({ open: false, message: "", loading: false }); }}
closeOnBackdrop
>
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" onClick={e => e.stopPropagation()}>
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" aria-live="polite">
{dialog.loading ? (
<div className="flex flex-col items-center">
<div className="w-10 h-10 mb-3 border-4 border-brand-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
<div className="w-10 h-10 mb-3 border-4 border-brand-600 border-t-transparent rounded-full animate-spin" aria-hidden="true" />
<div id="send-dialog-title" className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
<p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
</div>
) : (
<>
<div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
<h2 id="send-dialog-title" className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</h2>
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
<div className="flex justify-end">
<button
@@ -1296,7 +1301,7 @@ export default function UserDashboardPage() {
</>
)}
</div>
</div>
</ModalOverlay>
)}
</div>
);
@@ -123,8 +123,9 @@ export default function UserPaymentsPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<label htmlFor="pay-from" className="block text-xs text-gray-600 mb-1">From</label>
<input
id="pay-from"
type="date"
className="border rounded px-2 py-1.5 text-sm"
value={startDate}
@@ -132,8 +133,9 @@ export default function UserPaymentsPage() {
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<label htmlFor="pay-to" className="block text-xs text-gray-600 mb-1">To</label>
<input
id="pay-to"
type="date"
className="border rounded px-2 py-1.5 text-sm"
value={endDate}
@@ -141,8 +143,8 @@ export default function UserPaymentsPage() {
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<label htmlFor="pay-method" className="block text-xs text-gray-600 mb-1">Method</label>
<select id="pay-method" className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="card">Card</option>
@@ -152,8 +154,8 @@ export default function UserPaymentsPage() {
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Type</label>
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as "" | "payment" | "refund")}>
<label htmlFor="pay-type" className="block text-xs text-gray-600 mb-1">Type</label>
<select id="pay-type" 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>
@@ -205,7 +207,7 @@ export default function UserPaymentsPage() {
{payments.length === 0 && !fetching && (
<li className="text-gray-500">No payments found.</li>
)}
{fetching && <li className="text-gray-400">Loading…</li>}
{fetching && <li className="text-gray-500">Loading…</li>}
</ul>
{totalPages > 1 && (
@@ -226,7 +228,7 @@ export default function UserPaymentsPage() {
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400">…</span>
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-500">…</span>
) : (
<button
key={p}
@@ -220,7 +220,7 @@ export default function PrivacyCentrePage() {
onChange={v => setNews("whatsapp", v)}
/>
</div>
) : !loadError && <p className="text-sm text-gray-400">Loading…</p>}
) : !loadError && <p className="text-sm text-gray-500">Loading…</p>}
{newsMsg && <Alert msg={newsMsg} />}
{info && info.history.length > 0 && (
<details className="mt-4">
@@ -264,7 +264,7 @@ export default function PrivacyCentrePage() {
<div className="flex items-center gap-2">
<span className="font-medium text-gray-800">{TYPE_LABEL[r.type]}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_TONE[r.status]}`}>{r.status}</span>
<span className="text-xs text-gray-400 ml-auto">{new Date(r.createdAt).toLocaleDateString()}</span>
<span className="text-xs text-gray-500 ml-auto">{new Date(r.createdAt).toLocaleDateString()}</span>
</div>
{r.details && <p className="text-gray-600 mt-1 whitespace-pre-line">{r.details}</p>}
{r.response && <p className="text-gray-500 mt-1 text-xs whitespace-pre-line">Our answer: {r.response}</p>}
@@ -317,12 +317,12 @@ export default function PrivacyCentrePage() {
<strong>You won&apos;t be able to log in again.</strong> Download your data first if you want a copy.
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Reason (optional)</label>
<textarea className={inputCls} rows={2} maxLength={1000} value={deleteReason} onChange={e => setDeleteReason(e.target.value)} />
<label htmlFor="privacy-reason" className="block text-sm font-medium text-gray-700 mb-1">Reason (optional)</label>
<textarea id="privacy-reason" className={inputCls} rows={2} maxLength={1000} value={deleteReason} onChange={e => setDeleteReason(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input type="password" className={inputCls} value={deletePassword} onChange={e => setDeletePassword(e.target.value)} required autoComplete="current-password" />
<label htmlFor="privacy-confirm-with-your-password" className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input id="privacy-confirm-with-your-password" type="password" className={inputCls} value={deletePassword} onChange={e => setDeletePassword(e.target.value)} required autoComplete="current-password" />
</div>
<div className="flex gap-3">
<button type="button" onClick={() => { setDeleteStep("idle"); setDeletePassword(""); setDeleteReason(""); setDeleteMsg(null); }} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
@@ -328,16 +328,16 @@ export default function UserProfilePage() {
<SectionHeader icon={User} title="Personal information" />
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
<label htmlFor="profile-full-name" className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input id="profile-full-name" className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
<label htmlFor="profile-email-address" className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input id="profile-email-address" type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
<label htmlFor="profile-phone-number" className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input id="profile-phone-number" type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
</div>
{campusInfo.enabled && campusInfo.campuses.length > 0 && (
@@ -353,8 +353,8 @@ export default function UserProfilePage() {
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
<label htmlFor="profile-notification-preference" className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select id="profile-notification-preference" className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
@@ -374,7 +374,7 @@ export default function UserProfilePage() {
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Clock} title="Account activity" />
{loadingActivity && activity.length === 0 ? (
<p className="text-sm text-gray-400">Loading…</p>
<p className="text-sm text-gray-500">Loading…</p>
) : activity.length === 0 ? (
<p className="text-sm text-gray-500">No recent activity recorded yet.</p>
) : (
@@ -389,7 +389,7 @@ export default function UserProfilePage() {
</div>
<div className="min-w-0 flex-1">
<div className="text-sm text-gray-800">{meta.label(ev.device)}</div>
<div className="text-xs text-gray-400">{new Date(ev.createdAt).toLocaleString()}</div>
<div className="text-xs text-gray-500">{new Date(ev.createdAt).toLocaleString()}</div>
</div>
</li>
);
@@ -405,16 +405,16 @@ export default function UserProfilePage() {
<SectionHeader icon={Lock} title="Change password" />
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
<label htmlFor="profile-current-password" className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input id="profile-current-password" type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
<label htmlFor="profile-new-password" className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input id="profile-new-password" type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
<label htmlFor="profile-confirm-new-password" className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input id="profile-confirm-new-password" type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
</div>
<button type="submit" disabled={savingPw} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{savingPw ? "Changing…" : "Change password"}
@@ -433,8 +433,9 @@ export default function UserProfilePage() {
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. {tfEnabled ? "Enter it to switch your codes to this channel." : "Enter it to finish turning on two-factor authentication."}
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
<label htmlFor="profile-verification-code" className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
<input
id="profile-verification-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
@@ -480,8 +481,8 @@ export default function UserProfilePage() {
) : (
<form onSubmit={disableTwoFactor} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
<label htmlFor="profile-confirm-with-your-password" className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input id="profile-confirm-with-your-password" type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div className="flex gap-3">
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
@@ -502,8 +503,8 @@ export default function UserProfilePage() {
: "Add a second step to logging in: after your password, we send a 6-digit code that you need to enter."}
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
<select className={inputCls} value={tfMethod} onChange={e => setTfMethod(e.target.value as TwoFactorMethod)}>
<label htmlFor="profile-send-my-codes-by" className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
<select id="profile-send-my-codes-by" className={inputCls} value={tfMethod} onChange={e => setTfMethod(e.target.value as TwoFactorMethod)}>
<option value="email">Email</option>
{canWhatsAppCodes && <option value="whatsapp">WhatsApp</option>}
</select>
@@ -512,8 +513,8 @@ export default function UserProfilePage() {
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
<label htmlFor="profile-confirm-with-your-password-2" className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input id="profile-confirm-with-your-password-2" type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div className="flex gap-3">
{tfEnabled && (
@@ -587,10 +588,11 @@ export default function UserProfilePage() {
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="profile-confirm-with-your-password-3" className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
id="profile-confirm-with-your-password-3"
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
@@ -64,8 +64,9 @@ export default function ResetPasswordPage() {
<div className="border rounded-xl p-5 bg-white shadow-sm">
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Current password</label>
<label htmlFor="newpw-current-password" className="block text-sm font-medium mb-1">Current password</label>
<input
id="newpw-current-password"
type="password"
value={current}
onChange={e => setCurrent(e.target.value)}
@@ -76,8 +77,9 @@ export default function ResetPasswordPage() {
<p className="text-xs text-gray-500 mt-1">For your security, please confirm your current password.</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">New password</label>
<label htmlFor="newpw-new-password" className="block text-sm font-medium mb-1">New password</label>
<input
id="newpw-new-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
@@ -87,8 +89,9 @@ export default function ResetPasswordPage() {
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm new password</label>
<label htmlFor="newpw-confirm-new-password" className="block text-sm font-medium mb-1">Confirm new password</label>
<input
id="newpw-confirm-new-password"
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
+2 -2
View File
@@ -296,7 +296,7 @@ function TicketList({ event }: { event: Event }) {
return (
<div className="text-center py-6">
<Sparkles className="w-6 h-6 text-gray-300 mx-auto mb-1.5" />
<p className="text-sm text-gray-400">Free entry — no tickets required.</p>
<p className="text-sm text-gray-500">Free entry — no tickets required.</p>
</div>
);
}
@@ -367,7 +367,7 @@ function TicketList({ event }: { event: Event }) {
{activeTiers.length > 0 ? (
<>
<span className="text-brand-700 font-medium">R{displayPrice.toFixed(2)}</span>
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
<span className="text-xs text-gray-500 line-through">R{opt.price.toFixed(2)}</span>
<span className="text-xs text-brand-600">Early bird</span>
</>
) : (
+5 -5
View File
@@ -99,7 +99,7 @@ export default function FeedbackPage() {
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} value={answers[f.id] || ""} onChange={v => set(f.id, v)} />
) : f.type === "yes_no" ? (
<div className="flex gap-4 text-sm">
<div className="flex gap-4 text-sm" role="radiogroup" aria-label={f.label}>
{["yes", "no"].map(v => (
<label key={v} className="flex items-center gap-1.5 cursor-pointer">
<input type="radio" name={f.id} checked={answers[f.id] === v} onChange={() => set(f.id, v)} /> {v === "yes" ? "Yes" : "No"}
@@ -107,18 +107,18 @@ export default function FeedbackPage() {
))}
</div>
) : f.type === "numeric" ? (
<input type="number" className={`${inputCls} sm:w-40`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
<input type="number" aria-label={f.label} className={`${inputCls} sm:w-40`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
) : f.type === "date" ? (
<input type="date" className={`${inputCls} sm:w-48`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
<input type="date" aria-label={f.label} className={`${inputCls} sm:w-48`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
) : (
<textarea className={inputCls} rows={3} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} maxLength={4000} />
<textarea aria-label={f.label} className={inputCls} rows={3} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} maxLength={4000} />
)}
{f.helpText && <p className="text-xs text-gray-500 mt-1">{f.helpText}</p>}
</>
)}
</div>
))}
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{error && <div role="alert" className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<button type="submit" disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{busy ? "Sending…" : "Send feedback"}
</button>
+4 -4
View File
@@ -257,17 +257,17 @@ function FormsContent() {
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
) : f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
<select aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
<option value="">Select</option>
<option value="yes">Yes</option>
<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 => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
<input type="date" aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
<input type="number" aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
<input type="text" aria-label={f.label} className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
+38
View File
@@ -72,3 +72,41 @@
html {
scroll-behavior: smooth;
}
/*
* Accessibility (WCAG 2.2 AA, 1.50.0): a clear focus ring for keyboard users on every
* focusable element (mouse clicks don't show it), in the brand colour with a white halo
* so it shows on brand-coloured buttons too. Utilities such as `outline-none` still win.
*/
@layer base {
:focus-visible {
outline: 2px solid var(--brand-600);
outline-offset: 2px;
box-shadow: 0 0 0 2px #ffffff;
}
}
/* People who ask their device for less motion get no smooth scrolling or animations. */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Links inside a sentence are underlined, so they don't rely on colour alone (WCAG 1.4.1).
A link styled as a button inside a paragraph can opt out with `no-underline`. */
@layer base {
p a,
label a {
text-decoration-line: underline;
text-underline-offset: 2px;
}
}
+2
View File
@@ -8,6 +8,7 @@ import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { CookieConsent } from "@/components/shared/CookieConsent";
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
import { SkipLink } from "@/components/layout/SkipLink";
import { resolveToApiOrigin } from "@/lib/api";
import { getServerSettings } from "@/lib/serverSettings";
import { buildThemeCssVars } from "@/lib/colorScale";
@@ -61,6 +62,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{cssVars && (
<style id="brand-theme" dangerouslySetInnerHTML={{ __html: `:root{${cssVars}}` }} />
)}
<SkipLink />
<SiteSettingsProvider initialSettings={settings}>
<AuthProvider>
<ToastProvider>
+1 -1
View File
@@ -56,7 +56,7 @@ function PrivacyNotice() {
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-brand-700 mb-2">Privacy Policy</h1>
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
{!notice && !error && <p className="text-sm text-gray-400">Loading…</p>}
{!notice && !error && <p className="text-sm text-gray-500">Loading…</p>}
{notice && (
<>
<p className="text-sm text-gray-500 mb-6">
@@ -129,29 +129,33 @@ function StockBadge({ stockLimit, availableCount }: { stockLimit?: number; avail
}
function QtyControl({
label,
value,
onChange,
disabled,
max,
}: {
label: string;
value: number;
onChange: (v: number) => void;
disabled?: boolean;
max?: number;
}) {
return (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5" role="group" aria-label={`${label} quantity`}>
<button
type="button"
aria-label={`Remove one ${label}`}
className="w-7 h-7 flex items-center justify-center border rounded text-gray-600 hover:bg-gray-50 disabled:opacity-40"
onClick={() => onChange(Math.max(0, value - 1))}
disabled={disabled || value <= 0}
>
−
</button>
<span className="w-6 text-center text-sm font-medium">{value}</span>
<span className="w-6 text-center text-sm font-medium" aria-live="polite">{value}</span>
<button
type="button"
aria-label={`Add one ${label}`}
className="w-7 h-7 flex items-center justify-center border rounded text-gray-600 hover:bg-gray-50 disabled:opacity-40"
onClick={() => onChange(value + 1)}
disabled={disabled || (max !== undefined && max > 0 && value >= max)}
@@ -245,6 +249,7 @@ function OptionCard({
)}
</div>
<QtyControl
label={`${opt.name} (${variant.name})`}
value={qty}
onChange={(v) => onQtyChange(key, v)}
disabled={!!(isSoldOut || variantSoldOut)}
@@ -292,6 +297,7 @@ function OptionCard({
</div>
</div>
<QtyControl
label={opt.name}
value={qty}
onChange={(v) => onQtyChange(key, v)}
disabled={!!(isSoldOut)}
+4 -4
View File
@@ -48,13 +48,13 @@ function ResetPasswordContent() {
{!token && <p className="text-red-600 mb-4">Missing or invalid token.</p>}
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">New Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
<label htmlFor="reset-new-password" className="block text-sm font-medium mb-1">New Password</label>
<input id="reset-new-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
<p className="text-xs text-gray-500 mt-1">At least 8 characters.</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
<label htmlFor="reset-confirm-password" className="block text-sm font-medium mb-1">Confirm Password</label>
<input id="reset-confirm-password" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
</div>
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60 w-full font-medium transition-colors">
{loading ? "Saving..." : "Set new password"}
+68 -55
View File
@@ -11,6 +11,8 @@ import { errorMessage } from "@/lib/utils";
import type { Event, PublicBundle, Registration } from "@/types";
import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { EventPicker } from "@/components/shared/EventPicker";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -116,8 +118,7 @@ function PasswordToggleButton({ shown, onToggle }: { shown: boolean; onToggle: (
<button
type="button"
onClick={onToggle}
tabIndex={-1}
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-400 hover:text-gray-600"
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-500 hover:text-gray-700"
aria-label={shown ? "Hide password" : "Show password"}
>
{shown ? (
@@ -693,7 +694,7 @@ export default function SelfServicePage() {
// ─── Render ──────────────────────────────────────────────────────
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<main className="min-h-screen bg-gray-50 flex flex-col">
{/* Change Event button */}
{screen === "form" && (
<div className="fixed top-3 right-3 z-50">
@@ -708,15 +709,16 @@ export default function SelfServicePage() {
{/* ── Change Event Modal ─────────────────────────────────────── */}
{showChangeModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<ModalOverlay labelledBy="kiosk-change-title" onClose={() => setShowChangeModal(false)}>
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
<h2 className="text-xl font-bold text-gray-800 mb-1">Change Event</h2>
<h2 id="kiosk-change-title" className="text-xl font-bold text-gray-800 mb-1">Change Event</h2>
<p className="text-sm text-gray-500 mb-5">Re-enter your password to change the event.</p>
<form onSubmit={handleChangeEvent} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<label htmlFor="kiosk-password" className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<input
id="kiosk-password"
type={showChangePassword ? "text" : "password"}
value={changePassword}
onChange={(e) => setChangePassword(e.target.value)}
@@ -728,7 +730,7 @@ export default function SelfServicePage() {
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
</div>
</div>
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
{changeError && <p role="alert" className="text-red-600 text-sm">{changeError}</p>}
<div className="flex gap-3 pt-1">
<button
type="button"
@@ -747,7 +749,7 @@ export default function SelfServicePage() {
</div>
</form>
</div>
</div>
</ModalOverlay>
)}
{/* ── Setup Screen ───────────────────────────────────────────── */}
@@ -762,8 +764,9 @@ export default function SelfServicePage() {
{!isLoggedIn && (
<form onSubmit={handleSetupLogin} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<label htmlFor="kiosk-email" className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
id="kiosk-email"
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
@@ -774,9 +777,10 @@ export default function SelfServicePage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<label htmlFor="kiosk-password-2" className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<input
id="kiosk-password-2"
type={showSetupPassword ? "text" : "password"}
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
@@ -790,8 +794,9 @@ export default function SelfServicePage() {
</div>
{setupChallenge && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
<label htmlFor="kiosk-verification-code" className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
<input
id="kiosk-verification-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
@@ -816,7 +821,7 @@ export default function SelfServicePage() {
</p>
</div>
)}
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
{setupError && <p role="alert" className="text-red-600 text-sm">{setupError}</p>}
<button
type="submit"
disabled={setupLoading}
@@ -836,19 +841,8 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-center py-4">Loading events…</p>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">— Select an event —</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title} ({fmtDate(ev.startDate)})
</option>
))}
</select>
<label htmlFor="kiosk-event" className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<EventPicker id="kiosk-event" className="w-full text-base" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
</div>
)}
{selectedEvent && (
@@ -885,12 +879,13 @@ export default function SelfServicePage() {
<form onSubmit={handleRegisterSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="kiosk-email-address" className="block text-sm font-medium text-gray-700 mb-1">
Email Address
{!visitorPhone.trim() && <span className="text-red-500"> *</span>}
{visitorPhone.trim() && <span className="text-gray-400 font-normal"> (optional if phone provided)</span>}
{visitorPhone.trim() && <span className="text-gray-500 font-normal"> (optional if phone provided)</span>}
</label>
<input
id="kiosk-email-address"
type="email"
value={visitorEmail}
onChange={(e) => {
@@ -906,12 +901,13 @@ export default function SelfServicePage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="kiosk-cell-number" className="block text-sm font-medium text-gray-700 mb-1">
Cell Number
{!visitorEmail.trim() && <span className="text-red-500"> *</span>}
{visitorEmail.trim() && <span className="text-gray-400 font-normal"> (optional if email provided)</span>}
{visitorEmail.trim() && <span className="text-gray-500 font-normal"> (optional if email provided)</span>}
</label>
<input
id="kiosk-cell-number"
type="tel"
value={visitorPhone}
onChange={(e) => {
@@ -943,8 +939,9 @@ export default function SelfServicePage() {
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
<label htmlFor="kiosk-full-name" className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
<input
id="kiosk-full-name"
type="text"
value={visitorName}
onChange={(e) => setVisitorName(e.target.value)}
@@ -957,13 +954,14 @@ export default function SelfServicePage() {
{/* Preference selector — only shown when both channels are available */}
{visitorEmail.trim() && visitorPhone.trim() && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Send tickets via</label>
<div className="flex rounded-xl border border-gray-200 overflow-hidden text-sm font-medium">
<p id="kiosk-send-via" className="block text-sm font-medium text-gray-700 mb-2">Send tickets via</p>
<div role="group" aria-labelledby="kiosk-send-via" className="flex rounded-xl border border-gray-200 overflow-hidden text-sm font-medium">
{(["email", "whatsapp", "both"] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setNotificationPref(p)}
aria-pressed={notificationPref === p}
className={`flex-1 py-3 transition-colors ${
notificationPref === p
? p === "whatsapp" ? "bg-green-600 text-white"
@@ -982,7 +980,7 @@ export default function SelfServicePage() {
{/* Ticket options */}
{(selectedEvent.eventOptions || []).length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</label>
<h2 className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</h2>
<BundleOffers bundles={kioskBundles} className="mb-3" />
<div className="space-y-3">
{(selectedEvent.eventOptions || []).map((opt) => {
@@ -1012,14 +1010,16 @@ export default function SelfServicePage() {
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [key]: Math.max(0, (q[key] || 0) - 1) }))}
aria-label={`Remove one ${opt.name} (${v.name})`}
className="w-10 h-10 rounded-full border-2 border-gray-300 text-xl font-bold text-gray-600 hover:bg-gray-100 flex items-center justify-center transition"
>
−
</button>
<span className="w-8 text-center text-lg font-semibold">{quantities[key] || 0}</span>
<span className="w-8 text-center text-lg font-semibold" aria-live="polite">{quantities[key] || 0}</span>
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [key]: (q[key] || 0) + 1 }))}
aria-label={`Add one ${opt.name} (${v.name})`}
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
>
+
@@ -1046,14 +1046,16 @@ export default function SelfServicePage() {
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: Math.max(0, (q[opt.id] || 0) - 1) }))}
aria-label={`Remove one ${opt.name}`}
className="w-10 h-10 rounded-full border-2 border-gray-300 text-xl font-bold text-gray-600 hover:bg-gray-100 flex items-center justify-center transition"
>
−
</button>
<span className="w-8 text-center text-lg font-semibold">{quantities[opt.id] || 0}</span>
<span className="w-8 text-center text-lg font-semibold" aria-live="polite">{quantities[opt.id] || 0}</span>
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: (q[opt.id] || 0) + 1 }))}
aria-label={`Add one ${opt.name}`}
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
>
+
@@ -1119,23 +1121,30 @@ export default function SelfServicePage() {
</div>
) : (
<div className="border border-gray-200 rounded-xl px-4 py-4">
<label className="flex items-center gap-3 cursor-pointer">
<div
onClick={() => setCreateAccount((v) => !v)}
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-brand-600" : "bg-gray-300"}`}
<button
type="button"
role="switch"
aria-checked={createAccount}
onClick={() => setCreateAccount((v) => !v)}
className="flex items-center gap-3 cursor-pointer text-left w-full"
>
<span
aria-hidden="true"
className={`relative shrink-0 w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-brand-600" : "bg-gray-500"}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${createAccount ? "translate-x-6" : ""}`} />
</div>
<div>
<p className="font-medium text-gray-800">Create an account</p>
<p className="text-sm text-gray-500">Save your details for future events</p>
</div>
</label>
</span>
<span>
<span className="block font-medium text-gray-800">Create an account</span>
<span className="block text-sm text-gray-500">Save your details for future events</span>
</span>
</button>
{createAccount && (
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
<label htmlFor="kiosk-choose-a-password" className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
<div className="relative">
<input
id="kiosk-choose-a-password"
type={showVisitorPassword ? "text" : "password"}
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
@@ -1151,7 +1160,7 @@ export default function SelfServicePage() {
)}
{formError && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
<div role="alert" className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
{formError}
</div>
)}
@@ -1170,9 +1179,9 @@ export default function SelfServicePage() {
{/* ── Confirm Account Changes Modal ──────────────────────────── */}
{showUpdateConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<ModalOverlay labelledBy="kiosk-confirm-title" onClose={() => setShowUpdateConfirm(false)}>
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
<h2 className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
<h2 id="kiosk-confirm-title" className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
<p className="text-sm text-gray-500 mb-5">
These details differ from what&apos;s on file for this account. Confirm to update them.
</p>
@@ -1180,7 +1189,7 @@ export default function SelfServicePage() {
{pendingChanges.map((c) => (
<div key={c.field} className="border border-gray-200 rounded-lg px-3 py-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{c.field}</p>
<p className="text-sm text-gray-400 line-through">{c.from}</p>
<p className="text-sm text-gray-500 line-through">{c.from}</p>
<p className="text-sm text-green-700 font-medium">{c.to}</p>
</div>
))}
@@ -1202,7 +1211,7 @@ export default function SelfServicePage() {
</button>
</div>
</div>
</div>
</ModalOverlay>
)}
{/* ── Event Form Screen ──────────────────────────────────────── */}
@@ -1241,7 +1250,7 @@ export default function SelfServicePage() {
<form onSubmit={handleFormSubmit} className="space-y-5">
{answerableFields.map((field) => (
<div key={field.id}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor={`kiosk-field-${field.id}`} className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
@@ -1251,7 +1260,7 @@ export default function SelfServicePage() {
{isExtraFieldType(field.type) ? (
<ExtraFieldInput field={field} value={currentTicketAnswers[field.id] || ""} onChange={v => setCurrentTicketAnswers(a => ({ ...a, [field.id]: v }))} />
) : field.type === "yes_no" ? (
<div className="flex gap-4">
<div className="flex gap-4" role="radiogroup" aria-label={field.label}>
{["Yes", "No"].map((opt) => (
<label key={opt} className="flex items-center gap-2 cursor-pointer">
<input
@@ -1269,6 +1278,7 @@ export default function SelfServicePage() {
</div>
) : field.type === "numeric" ? (
<input
id={`kiosk-field-${field.id}`}
type="number"
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
@@ -1277,6 +1287,7 @@ export default function SelfServicePage() {
/>
) : field.type === "date" ? (
<input
id={`kiosk-field-${field.id}`}
type="date"
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
@@ -1285,6 +1296,7 @@ export default function SelfServicePage() {
/>
) : field.type === "paragraph" ? (
<textarea
id={`kiosk-field-${field.id}`}
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
@@ -1293,6 +1305,7 @@ export default function SelfServicePage() {
/>
) : (
<input
id={`kiosk-field-${field.id}`}
type="text"
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
@@ -1304,7 +1317,7 @@ export default function SelfServicePage() {
))}
{formSubmitError && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
<div role="alert" className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
{formSubmitError}
</div>
)}
@@ -1382,6 +1395,6 @@ export default function SelfServicePage() {
</div>
</div>
)}
</div>
</main>
);
}
+1
View File
@@ -426,6 +426,7 @@ export default function SetupPage() {
<div className="lg:col-span-2">
<BrandingPreviewPanel
primaryColor={primaryColor}
onUsePrimary={setPrimaryColor}
secondaryColor={secondaryColor}
accentColor={accentColor}
logoSrc={logoPreview}
@@ -2,7 +2,7 @@
import React, { useMemo, useState } from "react";
import { Monitor, Smartphone, RefreshCw } from "lucide-react";
import { buildBrandScale, buildSecondaryTokens, buildAccentTokens, contrastForeground, isValidHex } from "@/lib/colorScale";
import { accessiblePrimary, buildBrandScale, buildSecondaryTokens, buildAccentTokens, contrastForeground, contrastRatio, isValidHex, MIN_TEXT_CONTRAST } from "@/lib/colorScale";
type View = "website" | "email";
type Viewport = "desktop" | "mobile";
@@ -15,9 +15,11 @@ type Viewport = "desktop" | "mobile";
* page's own theme while the admin is still experimenting with colors.
*/
export function BrandingPreviewPanel({
primaryColor, secondaryColor, accentColor, logoSrc, orgName, orgTagline,
primaryColor, secondaryColor, accentColor, logoSrc, orgName, orgTagline, onUsePrimary,
}: {
primaryColor: string;
/** Offered when the primary colour is too light to read: switches to a darker shade. */
onUsePrimary?: (hex: string) => void;
secondaryColor: string;
accentColor: string;
logoSrc: string | null;
@@ -35,9 +37,29 @@ export function BrandingPreviewPanel({
const secondary = useMemo(() => buildSecondaryTokens(safeSecondary), [safeSecondary]);
const accent = useMemo(() => buildAccentTokens(safeAccent), [safeAccent]);
const primaryFg = useMemo(() => contrastForeground(safePrimary), [safePrimary]);
const primaryContrast = useMemo(() => contrastRatio(safePrimary, "#ffffff"), [safePrimary]);
const suggested = useMemo(() => accessiblePrimary(safePrimary), [safePrimary]);
return (
<div className="border rounded-xl bg-white shadow-sm overflow-hidden">
{primaryContrast < MIN_TEXT_CONTRAST && (
<div role="status" className="px-4 py-3 border-b bg-amber-50 text-sm text-amber-900 flex flex-wrap items-center gap-x-3 gap-y-2">
<p className="flex-1 min-w-[12rem]">
This primary colour is too light for white text and links to be easy to read
({primaryContrast.toFixed(1)}:1 — at least {MIN_TEXT_CONTRAST}:1 is needed for accessibility).
</p>
{onUsePrimary && (
<button
type="button"
onClick={() => onUsePrimary(suggested)}
className="inline-flex items-center gap-2 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100"
>
<span className="w-4 h-4 rounded border" style={{ background: suggested }} aria-hidden="true" />
Use a darker shade ({suggested.toUpperCase()})
</button>
)}
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
<div>
<h3 className="text-sm font-semibold text-gray-900">Live Preview</h3>
@@ -0,0 +1,172 @@
"use client";
import React, { useState } from "react";
import { BookOpen, ChevronDown, ChevronRight, Copy } from "lucide-react";
type EventType = { type: string; label: string; description: string; example?: unknown };
const RETRIES = "1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours";
const VERIFY_NODE = `// Node.js / Express — check the signature before trusting a request.
const crypto = require("crypto");
const SECRET = process.env.CROSSEVENTS_WEBHOOK_SECRET; // whsec_…
app.post("/crossevents-hook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-CrossEvents-Signature") || ""; // t=1767225600,v1=5f0c…
const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")));
const body = req.body.toString("utf8"); // the raw body, unparsed
const expected = crypto.createHmac("sha256", SECRET).update(\`\${t}.\${body}\`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // within 5 minutes
if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(body);
// Deliveries can repeat: skip an event.id you've already handled.
res.sendStatus(200); // answer quickly, then do the slow work
handle(event);
});`;
const VERIFY_PHP = `<?php // PHP — check the signature before trusting a request.
$secret = getenv('CROSSEVENTS_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_CROSSEVENTS_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, $secret);
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400); exit('bad signature');
}
$event = json_decode($body, true);
http_response_code(200);`;
function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
const [done, setDone] = useState(false);
return (
<button type="button" className="inline-flex items-center gap-1 px-2 py-0.5 rounded border bg-white text-[11px] hover:bg-gray-50"
onClick={() => { navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1500); }).catch(() => {}); }}>
<Copy className="w-3 h-3" aria-hidden /> {done ? "Copied" : label}
</button>
);
}
function Code({ text }: { text: string }) {
return (
<div className="relative">
<div className="absolute right-2 top-2"><CopyButton text={text} /></div>
<pre className="max-h-80 overflow-auto rounded-lg bg-gray-900 text-gray-100 p-3 pr-20 text-[11px] leading-relaxed">{text}</pre>
</div>
);
}
const H = ({ children }: { children: React.ReactNode }) => <h4 className="text-sm font-semibold text-gray-900 mt-4 mb-1.5">{children}</h4>;
/**
* Site settings → Webhooks → "How webhooks work": what arrives, when, how to check it's from us,
* and an example of every event type, for whoever builds the receiving side.
*/
export function WebhookGuide({ eventTypes, keepDays, defaultOpen = false }: { eventTypes: EventType[]; keepDays: number; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
const [openType, setOpenType] = useState<string | null>(null);
const [lang, setLang] = useState<"node" | "php">("node");
const envelope = (t: EventType) => JSON.stringify({ id: "evt_3f9c2a1e-6d4b-4c8e-9a51-2b7d0e6f1c34", type: t.type, createdAt: "2026-10-29T08:16:01.512Z", data: t.example ?? {} }, null, 2);
return (
<section className="border rounded-xl bg-white" data-testid="webhook-guide">
<button type="button" aria-expanded={open} onClick={() => setOpen(o => !o)} className="w-full flex items-center gap-2 px-4 py-3 text-left">
{open ? <ChevronDown className="w-4 h-4 text-gray-500" aria-hidden /> : <ChevronRight className="w-4 h-4 text-gray-500" aria-hidden />}
<BookOpen className="w-4 h-4 text-brand-700" aria-hidden />
<span className="font-medium text-gray-900">How webhooks work</span>
<span className="text-xs text-gray-500">what is sent, the signature, and an example of each event</span>
</button>
{open && (
<div className="px-4 pb-4 text-sm text-gray-700 border-t">
<H>What happens</H>
<ol className="list-decimal pl-5 space-y-1">
<li>Something happens here, such as a registration or a payment, that one of your webhooks is set to send.</li>
<li>A second or two later, an <code>HTTP POST</code> with a JSON body goes to that webhook&apos;s address. Everything is sent from the server; nothing comes from people&apos;s browsers.</li>
<li>Your side answers with any <code>2xx</code> status within 10 seconds, and the delivery is done.</li>
<li>
Anything else counts as a failure: another status, no answer, or a redirect (redirects aren&apos;t followed). The delivery is tried again after {RETRIES}, then marked <em>failed</em>.
You can send any delivery again from its <em>Delivery log</em>.
</li>
</ol>
<H>Using it without writing code</H>
<p>
In Zapier, use <strong>Webhooks by Zapier → Catch Hook</strong>; in Make, use <strong>Webhooks → Custom webhook</strong>. Paste the address it gives you here, choose the events, then click <em>Send test</em>. The fields in the examples below become available to map into ChurchSuite, Planning Center, a spreadsheet, Mailchimp and so on.
</p>
<H>What arrives</H>
<p className="mb-2">Every request has the same envelope. <code>data</code> depends on the type (see the examples below).</p>
<div className="overflow-x-auto">
<table className="min-w-full text-xs border rounded">
<thead className="bg-gray-50 text-left"><tr><th className="px-2 py-1.5">Field</th><th className="px-2 py-1.5">Meaning</th></tr></thead>
<tbody className="divide-y">
<tr><td className="px-2 py-1.5"><code>id</code></td><td className="px-2 py-1.5">The event&apos;s id (<code>evt_…</code>). It stays the same on retries, so use it to skip events you&apos;ve already handled.</td></tr>
<tr><td className="px-2 py-1.5"><code>type</code></td><td className="px-2 py-1.5">The event type, e.g. <code>registration.paid</code>. The <em>Send test</em> button sends <code>webhook.test</code>.</td></tr>
<tr><td className="px-2 py-1.5"><code>createdAt</code></td><td className="px-2 py-1.5">When it happened (ISO 8601, UTC).</td></tr>
<tr><td className="px-2 py-1.5"><code>data</code></td><td className="px-2 py-1.5">The details. Amounts are in rand; dates are ISO 8601 in UTC.</td></tr>
</tbody>
</table>
</div>
<p className="mt-3 mb-2">Headers:</p>
<div className="overflow-x-auto">
<table className="min-w-full text-xs border rounded">
<tbody className="divide-y">
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>Content-Type</code></td><td className="px-2 py-1.5"><code>application/json</code></td></tr>
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>User-Agent</code></td><td className="px-2 py-1.5"><code>CrossEvents-Webhooks/1</code></td></tr>
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Event</code></td><td className="px-2 py-1.5">The event type, the same as <code>type</code>.</td></tr>
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Delivery</code></td><td className="px-2 py-1.5">This delivery&apos;s id. Each webhook gets its own, and it&apos;s the same on retries.</td></tr>
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Signature</code></td><td className="px-2 py-1.5"><code>t=&lt;unix seconds&gt;,v1=&lt;signature&gt;</code>, explained below.</td></tr>
</tbody>
</table>
</div>
<H>Checking it came from here</H>
<p className="mb-2">
Anyone who knows the address could post to it, so check the signature before acting on a request. <code>v1</code> is the HMAC-SHA256, in hex, of <code>t</code>, a full stop, and the raw request body (<code>&lt;t&gt;.&lt;body&gt;</code>). It&apos;s keyed with the webhook&apos;s signing secret, the <code>whsec_…</code> shown once when you add the webhook or make a new secret.
Work it out on the raw body before parsing the JSON, compare in constant time, and reject a <code>t</code> more than 5 minutes old.
</p>
<div className="flex gap-1 mb-2" role="group" aria-label="Example language">
{(["node", "php"] as const).map(l => (
<button key={l} type="button" aria-pressed={lang === l} onClick={() => setLang(l)}
className={`px-2 py-0.5 rounded text-xs border ${lang === l ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>
{l === "node" ? "Node.js" : "PHP"}
</button>
))}
</div>
<Code text={lang === "node" ? VERIFY_NODE : VERIFY_PHP} />
<H>Good to know</H>
<ul className="list-disc pl-5 space-y-1">
<li>An event can arrive more than once, for example after a retry or a manual resend, so use <code>id</code> to skip ones you&apos;ve already handled.</li>
<li>Events can arrive out of order. Use <code>createdAt</code> and the record&apos;s <code>status</code>, not the order they arrive in.</li>
<li>Each payload is built about a second after the event, from the record as it is then, and retries send that same payload. If a registration is paid and then changed back within that second, nothing is sent.</li>
<li>Kids check-in and volunteer tickets are never sent. A registration made only of those sends nothing, and those lines are left out of <code>items</code>.</li>
<li>Only <code>https</code> addresses on the public internet are allowed. Each webhook&apos;s log keeps what was sent for {keepDays} days.</li>
<li>Turning a webhook off stops new deliveries to it. Deleting it removes its log too.</li>
</ul>
<H>Event types and examples</H>
<ul className="border rounded-lg divide-y">
{eventTypes.map(t => (
<li key={t.type}>
<button type="button" aria-expanded={openType === t.type} onClick={() => setOpenType(openType === t.type ? null : t.type)}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-gray-50">
{openType === t.type ? <ChevronDown className="w-4 h-4 mt-0.5 text-gray-500 shrink-0" aria-hidden /> : <ChevronRight className="w-4 h-4 mt-0.5 text-gray-500 shrink-0" aria-hidden />}
<span className="flex-1">
<code className="text-xs font-semibold text-gray-900">{t.type}</code> <span className="text-gray-900">· {t.label}</span>
<span className="block text-xs text-gray-500">{t.description}</span>
</span>
</button>
{openType === t.type && <div className="px-3 pb-3" data-testid={`webhook-example-${t.type}`}><Code text={envelope(t)} /></div>}
</li>
))}
<li className="px-3 py-2 text-xs text-gray-600">
<code className="font-semibold text-gray-900">webhook.test</code> · Sent by <em>Send test</em>. <code>data</code> is <code>{`{ "message": "…" }`}</code>, so your side can answer 2xx and ignore it.
</li>
</ul>
</div>
)}
</section>
);
}
+43 -7
View File
@@ -5,8 +5,9 @@ import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown,
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { WebhookGuide } from "./WebhookGuide";
type EventType = { type: string; label: string; description: string };
type EventType = { type: string; label: string; description: string; example?: unknown };
type Endpoint = {
id: string; name: string; url: string; events: string[]; isActive: boolean;
lastSuccessAt: string | null; lastFailureAt: string | null; createdAt: string;
@@ -20,6 +21,9 @@ type ListResponse = { eventTypes: EventType[]; keepDays: number; endpoints: Endp
type Draft = { id?: string; name: string; url: string; events: string[] };
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
// Deliveries shown at a time in the log ("Load older" fetches the next lot).
const PAGE = 50;
const STATUS: Record<string, string> = {
succeeded: "bg-green-50 text-green-800", failed: "bg-red-50 text-red-700", retrying: "bg-amber-50 text-amber-800",
pending: "bg-gray-100 text-gray-600", sending: "bg-blue-50 text-blue-700",
@@ -50,21 +54,46 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
const [rows, setRows] = useState<Delivery[] | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [more, setMore] = useState(false);
const fetchPage = useCallback((before?: string) => {
const qs = new URLSearchParams({ limit: String(PAGE) });
if (status) qs.set("status", status);
if (before) qs.set("before", before);
return apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries?${qs.toString()}`, { authToken: token }).catch(() => [] as Delivery[]);
}, [endpointId, token, status]);
const load = useCallback(async () => {
setRows(await apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries`, { authToken: token }).catch(() => []));
}, [endpointId, token]);
const page = await fetchPage();
setRows(page);
setMore(page.length === PAGE);
}, [fetchPage]);
useEffect(() => { load(); }, [load]);
const loadOlder = async () => {
if (!rows?.length) return;
const page = await fetchPage(rows[rows.length - 1].createdAt);
setRows([...rows, ...page]);
setMore(page.length === PAGE);
};
const resend = async (id: string) => {
setBusy(id);
await apiFetch(`/api/outbound-webhooks/deliveries/${id}/resend`, { method: "POST", authToken: token }).catch(() => {});
setBusy(null);
load();
};
if (!rows) return <p className="text-xs text-gray-400">Loading…</p>;
if (!rows.length) return <p className="text-xs text-gray-500">Nothing sent yet.</p>;
if (!rows) return <p className="text-xs text-gray-500">Loading…</p>;
if (!rows.length && !status) return <p className="text-xs text-gray-500">Nothing sent yet.</p>;
return (
<div className="space-y-1" data-testid="webhook-deliveries">
<div className="flex justify-end"><button type="button" onClick={load} className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><RefreshCw className="w-3 h-3" /> Refresh</button></div>
<div className="flex items-center justify-between gap-2">
<select aria-label="Show deliveries" className="border rounded px-2 py-1 text-xs" value={status} onChange={e => setStatus(e.target.value)}>
<option value="">All deliveries</option>
<option value="failed">Failed</option>
<option value="retrying">Retrying</option>
<option value="succeeded">Succeeded</option>
</select>
<button type="button" onClick={load} className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><RefreshCw className="w-3 h-3" aria-hidden="true" /> Refresh</button>
</div>
{!rows.length && <p className="text-xs text-gray-500">None with this status.</p>}
<ul className="divide-y border rounded-lg bg-white">
{rows.map(d => (
<li key={d.id} className="text-xs" data-testid="webhook-delivery">
@@ -89,6 +118,11 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
</li>
))}
</ul>
{more && (
<div className="flex justify-center">
<button type="button" onClick={loadOlder} className="text-xs px-3 py-1 rounded border bg-white hover:bg-gray-50">Load older</button>
</div>
)}
</div>
);
}
@@ -155,7 +189,7 @@ export function WebhooksTab({ active }: { active: boolean }) {
const toggleEvent = (type: string) => setDraft(d => d && ({ ...d, events: d.events.includes(type) ? d.events.filter(t => t !== type) : [...d.events, type] }));
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-500">Loading…</p>;
const label = (type: string) => data.eventTypes.find(t => t.type === type)?.label || type;
return (
@@ -174,6 +208,8 @@ export function WebhooksTab({ active }: { active: boolean }) {
Webhooks send personal information (names, email addresses, phone numbers) to the service you choose, so each one is added to your operator register. Make sure you have an agreement with that service. What was sent is kept {data.keepDays} days for the delivery log. Children&apos;s details from kids check-in are never sent.
</p>
<WebhookGuide eventTypes={data.eventTypes} keepDays={data.keepDays} defaultOpen={data.endpoints.length === 0} />
{msg && <div className={`p-3 border rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`} role="status">{msg.text}</div>}
{secret && <SecretBox secret={secret.value} onClose={() => setSecret(null)} />}
+13 -8
View File
@@ -129,8 +129,9 @@ export function LoginForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1">Verification code</label>
<label htmlFor="login-code" className="block text-sm font-medium mb-1">Verification code</label>
<input
id="login-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
@@ -153,7 +154,7 @@ export function LoginForm() {
<span>Trust this device for 30 days. Only tick this on a device that&apos;s yours.</span>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<button
type="submit"
@@ -188,8 +189,10 @@ export function LoginForm() {
</p>
)}
<div>
<label className="block text-sm font-medium mb-1">Email or phone</label>
<label htmlFor="login-email" className="block text-sm font-medium mb-1">Email or phone</label>
<input
id="login-email"
autoComplete="username"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -199,9 +202,11 @@ export function LoginForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<label htmlFor="login-password" className="block text-sm font-medium mb-1">Password</label>
<div className="relative">
<input
id="login-password"
autoComplete="current-password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -211,7 +216,7 @@ export function LoginForm() {
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-brand-600"
className="absolute right-1 top-1/2 -translate-y-1/2 text-sm text-brand-600 px-2 py-1 min-h-6"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? "Hide" : "Show"}
@@ -236,7 +241,7 @@ export function LoginForm() {
</div>
{error && (
<div className="text-sm text-red-600">
<div role="alert" className="text-sm text-red-600">
<p>{error}</p>
{error.toLowerCase().includes('not yet active') && (
<p className="mt-1 text-gray-600">Check your inbox for an activation email. If you didn&apos;t receive it, try logging in again to resend it.</p>
@@ -257,7 +262,7 @@ export function LoginForm() {
<Link
href="/legal/terms"
target="_blank"
className="text-brand-600 hover:underline"
className="text-brand-700 underline hover:no-underline"
>
Terms of Use
</Link>{" "}
@@ -265,7 +270,7 @@ export function LoginForm() {
<Link
href="/legal/privacy"
target="_blank"
className="text-brand-600 hover:underline"
className="text-brand-700 underline hover:no-underline"
>
Privacy Policy
</Link>.
+18 -8
View File
@@ -68,8 +68,10 @@ export function RegisterForm() {
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
<div>
<label className="block text-sm font-medium mb-1">Full Name</label>
<label htmlFor="signup-full-name" className="block text-sm font-medium mb-1">Full Name</label>
<input
id="signup-full-name"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border rounded px-3 py-2"
@@ -78,8 +80,10 @@ export function RegisterForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<label htmlFor="signup-email" className="block text-sm font-medium mb-1">Email</label>
<input
id="signup-email"
autoComplete="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -89,8 +93,10 @@ export function RegisterForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<label htmlFor="signup-password" className="block text-sm font-medium mb-1">Password</label>
<input
id="signup-password"
autoComplete="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -100,8 +106,11 @@ export function RegisterForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone Number (optional)</label>
<label htmlFor="signup-phone-number" className="block text-sm font-medium mb-1">Phone Number (optional)</label>
<input
id="signup-phone-number"
type="tel"
autoComplete="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="e.g. 082 123 4567"
@@ -111,8 +120,9 @@ export function RegisterForm() {
{hasValidPhone && (
<div>
<label className="block text-sm font-medium mb-1">Notification Preference</label>
<label htmlFor="signup-notification-preference" className="block text-sm font-medium mb-1">Notification Preference</label>
<select
id="signup-notification-preference"
value={notificationPreference}
onChange={(e) => setNotificationPreference(e.target.value as "email" | "whatsapp" | "both")}
className="w-full border rounded px-3 py-2"
@@ -138,7 +148,7 @@ export function RegisterForm() {
<Link
href="/legal/terms"
target="_blank"
className="text-brand-600 hover:underline"
className="text-brand-700 underline hover:no-underline"
>
Terms of Use
</Link>{" "}
@@ -146,7 +156,7 @@ export function RegisterForm() {
<Link
href="/legal/privacy"
target="_blank"
className="text-brand-600 hover:underline"
className="text-brand-700 underline hover:no-underline"
>
Privacy Policy
</Link>.
@@ -175,7 +185,7 @@ export function RegisterForm() {
</a>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<button
type="submit"
@@ -2,6 +2,7 @@
import { useState } from "react";
import { X, Phone, Mail, User } from "lucide-react";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
type ContactButtonProps = {
contactName?: string | null;
@@ -25,15 +26,17 @@ export function ContactButton({ contactName, contactPhone, contactEmail, classNa
{label}
</button>
{open && (
<div
<ModalOverlay
labelledBy="contact-title"
onClose={() => setOpen(false)}
closeOnBackdrop
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => setOpen(false)}
>
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Contact us</h3>
<h3 id="contact-title" className="text-lg font-semibold">Contact us</h3>
<button
className="p-1 rounded hover:bg-gray-100 text-gray-500"
className="p-1.5 rounded hover:bg-gray-100 text-gray-500"
onClick={() => setOpen(false)}
aria-label="Close"
>
@@ -44,19 +47,19 @@ export function ContactButton({ contactName, contactPhone, contactEmail, classNa
<div className="space-y-2 text-sm">
{contactName && (
<div className="flex items-center gap-2 text-gray-800">
<User className="w-4 h-4 text-gray-400 shrink-0" />
<User className="w-4 h-4 text-gray-500 shrink-0" />
<span>{contactName}</span>
</div>
)}
{contactPhone && (
<div className="flex items-center gap-2">
<Phone className="w-4 h-4 text-gray-400 shrink-0" />
<Phone className="w-4 h-4 text-gray-500 shrink-0" />
<a href={`tel:${contactPhone}`} className="text-brand-600 hover:underline">{contactPhone}</a>
</div>
)}
{contactEmail && (
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400 shrink-0" />
<Mail className="w-4 h-4 text-gray-500 shrink-0" />
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">{contactEmail}</a>
</div>
)}
@@ -65,7 +68,7 @@ export function ContactButton({ contactName, contactPhone, contactEmail, classNa
<p className="text-sm text-gray-500">No contact details have been provided for this event.</p>
)}
</div>
</div>
</ModalOverlay>
)}
</>
);
@@ -5,6 +5,7 @@ import { ListPlus, X } from "lucide-react";
import { apiFetch, ApiError } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { WaitlistLine } from "@/lib/waitlist";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
export type { WaitlistLine };
@@ -72,11 +73,11 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
</button>
{open && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<form onSubmit={submit} className="bg-white rounded-xl shadow-xl w-full max-w-md p-5 space-y-4" onClick={e => e.stopPropagation()}>
<ModalOverlay labelledBy="waitlist-title" onClose={() => setOpen(false)} closeOnBackdrop className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4">
<form onSubmit={submit} className="bg-white rounded-xl shadow-xl w-full max-w-md p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Join the waitlist</h2>
<button type="button" onClick={() => setOpen(false)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
<h2 id="waitlist-title" className="font-semibold">Join the waitlist</h2>
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-1 rounded hover:bg-gray-100"><X className="w-5 h-5 text-gray-500" /></button>
</div>
{done ? (
@@ -93,16 +94,16 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
<p className="text-sm text-gray-600">If a place opens up, we&apos;ll email or WhatsApp you a link to claim it. We hold it for you for a limited time.</p>
{lines.length > 1 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ticket</label>
<select className={inputCls} value={lineKey} onChange={e => setLineKey(e.target.value)}>
<label htmlFor="waitlist-ticket" className="block text-sm font-medium text-gray-700 mb-1">Ticket</label>
<select id="waitlist-ticket" className={inputCls} value={lineKey} onChange={e => setLineKey(e.target.value)}>
{lines.map(l => <option key={keyOf(l)} value={keyOf(l)}>{l.label}</option>)}
</select>
</div>
)}
{lines.length === 1 && <p className="text-sm"><span className="text-gray-500">Ticket:</span> {line.label}</p>}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">How many?</label>
<input type="number" min={1} max={20} className={`${inputCls} w-24`} value={qty} onChange={e => setQty(Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 1)))} />
<label htmlFor="waitlist-qty" className="block text-sm font-medium text-gray-700 mb-1">How many?</label>
<input id="waitlist-qty" type="number" min={1} max={20} className={`${inputCls} w-24`} value={qty} onChange={e => setQty(Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 1)))} />
</div>
{!loggedIn && (
<div className="space-y-2">
@@ -111,14 +112,14 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={guest.phone} onChange={e => setGuest({ ...guest, phone: e.target.value })} aria-label="Phone" />
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="w-full px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{busy ? "Joining…" : "Join waitlist"}
</button>
</>
)}
</form>
</div>
</ModalOverlay>
)}
</>
);
@@ -7,7 +7,7 @@ export function LocationMap({ address, className }: { address?: string | null; c
<div className={className}>
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-gray-700 flex items-start gap-1.5">
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-400" />
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-500" />
<span>{address}</span>
</p>
<a
@@ -34,7 +34,7 @@ export function SessionPicker({ event, option, selected, onChange, quantity = 1,
const full = s.left !== null && s.left < Math.max(1, quantity);
const name = sessionLabel(s, event.sessions);
return (
<div key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
<div key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-500" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
<label className="flex items-center gap-2 flex-1 min-w-0">
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
<span className="font-medium">{name}</span>

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