Compare commits

...
Author SHA1 Message Date
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
joshua 5efb4cd6dd Merge branch 'feat/saved-filters' 2026-10-28 14:57:34 +02:00
joshuaandClaude Opus 5.5 e40803cbbc Saved filters on list pages (1.49.2)
Manage registrations, Users, Events and Reports: save the current
filters under a name, apply one with a click, star one as the default
for the page (not applied over a deep link). Per person, stored on the
server, removed when an account is anonymised. /api/saved-filters.
Migration 20261028180000_saved_filters. Completes roadmap section 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:57:34 +02:00
joshua fc393a90c1 Merge branch 'feat/bulk-actions' 2026-10-28 14:43:01 +02:00
joshuaandClaude Opus 5.5 c1211db8cf Bulk actions on registrations (1.49.1)
Manage registrations: tick registrations (or all shown) and message
them (email/WhatsApp event update), mark them paid (the balance, by
method), move them to another ticket (same tickets and QR codes,
repriced, places checked), put their people in a room or group, export
to Excel, or cancel. Each runs the same handlers as doing it one at a
time, with a result per registration. POST /api/registrations/bulk,
campus-checked per id. Migration 20261028170000_registrations_bulk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:43:00 +02:00
81 changed files with 1953 additions and 272 deletions
+68
View File
@@ -7,6 +7,74 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
when the page opens (but not over a link that already says what to show, such as the
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
an account is anonymised.
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
`20261028180000_saved_filters`.
## [1.49.1] - 2026-10-28
### Added
- **Bulk actions on registrations** (Admin → Manage registrations): tick registrations, or "Select
all shown" after filtering, and act on them at once —
- **Send a message** by email or WhatsApp, as an event update to the person who registered
(placeholders as on Email attendees);
- **Mark paid** — records what each still owes, by cash, EFT, card or other; paid registrations get
their tickets by email, as with a single payment;
- **Move to another ticket** (one event) — the same lines, issued tickets and QR codes move with
them, repriced at the new ticket's current price, so the difference shows as owing or overpaid;
places on the new ticket are checked first;
- **Put in a room or group** (one event) — their named people, with the group's capacity and
gender rules;
- **Export to Excel** (recorded in the access log, like other exports);
- **Cancel** — coupon uses released and the waitlist offered the places; no refund (record refunds
on the Payments page).
Each runs the same code as doing it one at a time (closed events, payment caps, coupons,
waitlist, privacy rules on messages), with a result per registration. Up to 500 at a time.
- `POST /api/registrations/bulk` (campus-checked for every registration; payments and ticket moves
refused in licence read-only mode); `registrationIds` filter on the email and WhatsApp attendee
endpoints. Audit action `registrations_bulk` (migration `20261028170000_registrations_bulk`).
## [1.49.0] - 2026-10-28
### Added
+26 -6
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)
---
@@ -476,7 +477,14 @@ logout controls. Disconnected sessions are recovered automatically.
[Privacy](#privacy-popia)).
- **Registrations** — all registrations, with filters (event, status, text),
status counts, revenue and outstanding totals, and per-registration options,
form answers and payments (with who recorded each one).
form answers and payments (with who recorded each one). **Bulk actions** on
ticked registrations (or all shown): message (email/WhatsApp), mark paid
(records the balance by method), move to another ticket (same tickets and QR
codes, repriced), put in a room or group, export to Excel, cancel — each
through the same rules as one at a time, with a result per registration.
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
current filters, apply one with a click, star one as the default for the page
(not applied over a deep link). Per person, on the server.
- **Cashup** — see below.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
@@ -538,7 +546,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 |
@@ -777,6 +785,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
+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. |
+25
View File
@@ -12,6 +12,31 @@ 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.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
under a name and get back to them with one click. Star one and it's ready every time you open the
page. They're yours and follow you to any device.
## 1.49.1
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
shown after filtering) and send them a message, mark them paid, move them to another ticket, put
them in a room or group, export them to Excel, or cancel them.
- Moving people to another ticket keeps their tickets and QR codes; the price follows the new
ticket, so any difference shows as owing.
- You see what happened to each one, and anything that couldn't be done says why.
## 1.49.0
- **Today, at a glance.** The dashboard now starts with what's on today: each event with how many
+25
View File
@@ -933,6 +933,31 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
role linked to the room); checked-in ones are counted in `leaders`.
## Saved filters
Per person, on list pages `registrations`, `users`, `events`, `reports` (staff+; your own only — someone
else's id answers 404).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/saved-filters?page=` | Mine for the page, default first |
| POST | `/api/saved-filters` | `{ page, name, params, isDefault? }` — params: strings, numbers, booleans, lists of strings (≤ 4 KB); same name replaces; ≤ 30 per page |
| PUT | `/api/saved-filters/:id` | `{ name?, isDefault? }` (one default per page) |
| DELETE | `/api/saved-filters/:id` | Delete |
## Bulk actions on registrations
`POST /api/registrations/bulk` (supervisor+): `{ action, registrationIds: [...] (≤ 500), ...params }` →
`{ action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }`. Every id is campus-checked.
| action | params | What happens |
|--------|--------|--------------|
| `markPaid` | `method`: cash/eft/card/other, `paidAt?` | A payment for what's still owed (as `POST /api/payments`); refused in licence read-only mode |
| `cancel` | — | As cancelling one registration (coupons released, waitlist offered); no refund |
| `moveOption` | `fromOptionId`, `toOptionId` (same event) | The lines, tickets and attendees move to the new ticket, repriced; status recalculated; places checked; refused in read-only mode |
| `allocate` | `setId`, `groupId` | Each named person placed, as `PUT /api/allocations/sets/:id/assignments` |
| `message` | `channel`: email/whatsapp, `subject` (email), `text` | Per event, as `POST /api/events/:id/email-attendees` / `whatsapp-attendees` with `filter.registrationIds`, kind "update" |
## Dashboard and command palette
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.49.0",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.49.0",
"version": "1.50.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.49.0",
"version": "1.50.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'registrations_bulk';
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "SavedFilter" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"page" TEXT NOT NULL,
"name" TEXT NOT NULL,
"params" JSONB NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
-- CreateIndex
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
-- AddForeignKey
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+19
View File
@@ -164,6 +164,7 @@ model User {
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
seriesFollows SeriesFollow[]
seasonPassPurchases SeasonPassPurchase[]
savedFilters SavedFilter[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -818,6 +819,7 @@ enum AdminAuditAction {
series_deleted
season_pass_changed
webhook_endpoint_changed
registrations_bulk
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -2296,3 +2298,20 @@ model WebhookDelivery {
@@index([userId])
@@index([createdAt])
}
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
/// events, reports), `params` the filter values. At most one default per person and page.
model SavedFilter {
id String @id @default(uuid())
userId String
page String
name String
params Json
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, page, name])
@@index([userId, page])
}
@@ -1602,6 +1602,9 @@ const emailEventAttendees = async (req, res) => {
// Prefer server-side filtering when possible
where.userId = { in: attendeeIds };
}
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
if (registrationIds.length > 0) where.id = { in: registrationIds };
// Name/email substring filter (kept for backward compatibility)
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
@@ -1870,6 +1873,9 @@ const whatsappEventAttendees = async (req, res) => {
if (attendeeIds.length > 0) {
where.userId = { in: attendeeIds };
}
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
if (registrationIds.length > 0) where.id = { in: registrationIds };
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
const phoneQ = (filter && filter.phone) ? String(filter.phone).trim() : '';
+11
View File
@@ -189,6 +189,7 @@ app.use('/api/series', require('./routes/seriesRoutes'));
app.use('/api/season-passes', require('./routes/seasonPassRoutes'));
app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes'));
app.use('/api/dashboard', require('./routes/dashboardRoutes'));
app.use('/api/saved-filters', require('./routes/savedFilterRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -682,6 +683,8 @@ app.get('/docs', async (req, res) => {
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
{ method:'POST', path:'/api/registrations/bulk', auth:'supervisor+', desc:'Bulk actions on ticked registrations (up to 500), each run through the same code as doing it one at a time: action "markPaid" { method: cash|eft|card|other, paidAt? } records the balance; "cancel"; "moveOption" { fromOptionId, toOptionId } (same event); "allocate" { setId, groupId } places their named people; "message" { channel: email|whatsapp, subject?, text } sends an event update. Answers { action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }. Campus-checked for every id; payments and ticket changes refused in licence read-only mode. Audited as registrations_bulk',
request:{ body:{ action:'markPaid', registrationIds:['reg-uuid-1','reg-uuid-2'], method:'cash' } } },
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
@@ -1013,6 +1016,14 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
]},
{ title: 'Saved filters', base: '/api/saved-filters', endpoints: [
{ method:'GET', path:'/api/saved-filters?page=', auth:'staff+', desc:'My saved filters for a list page (registrations, users, events, reports), default first' },
{ method:'POST', path:'/api/saved-filters', auth:'staff+', desc:'Save (or replace by name) a filter: { page, name, params, isDefault? }. Up to 30 per page; making one the default clears the old default',
request:{ body:{ page:'registrations', name:'Camp — unpaid', params:{ event:'ev-uuid', status:'pending' }, isDefault:false } } },
{ method:'PUT', path:'/api/saved-filters/:id', auth:'staff+', desc:'Rename it, or make it (or stop it being) my default — my own only' },
{ method:'DELETE', path:'/api/saved-filters/:id', auth:'staff+', desc:'Delete one of my saved filters' },
]},
{ title: 'Dashboard and command palette', base: '/api/dashboard', endpoints: [
{ method:'GET', path:'/api/dashboard/today', auth:'staff+', desc:'Events on today (or with a session today): places, people checked in today, children in now, volunteers rostered/checked in. Supervisors and admins also get money in (today, last 7 days) and "waiting" items: unpaid registrations for the next two weeks, parental consent, waitlist, cover requests, roster gaps, and for admins cashups due, provider payments to reconcile, privacy requests, failed webhooks. Campus-scoped' },
{ method:'GET', path:'/api/dashboard/search?q=', auth:'staff+', desc:'Command palette search: events; supervisors also payments by amount ("R450"); admins also people and registrations by reference. { results: [{ type, id, title, subtitle, href }] }' },
+5 -1
View File
@@ -17,7 +17,7 @@ const {
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
const { scopeEvent, scopeList, scopeBody } = require('../utils/campusScope');
const { notOnSeasonPass } = require('../utils/seasonPass');
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
@@ -46,5 +46,9 @@ router.get('/', protect, staff, scopeList, getRegistrations);
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
// Bulk actions (1.49.1): every ticked registration must be one this person may manage.
router.post('/bulk', protect, supervisor, scopeBody([['registrationIds', 'registration']], { orphan: 'deny' }), async (req, res) => {
try { res.json(await require('../utils/bulkRegistrations').runBulk(req, req.body || {})); } catch (e) { res.status(e.statusCode || 400).json({ message: e.message }); }
});
module.exports = router;
+24
View File
@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const filters = require('../utils/savedFilters');
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
router.get('/', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
});
router.post('/', protect, staff, OWN, async (req, res) => {
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.put('/:id', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.delete('/:id', protect, staff, OWN, async (req, res) => {
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
});
module.exports = router;
+192
View File
@@ -0,0 +1,192 @@
/**
* Bulk actions on registrations (1.49.1): the registrations list lets a supervisor or admin tick
* many and act on them at once — mark paid, cancel, move to another ticket, put in a room or
* group, or send a message.
*
* Each action runs the same code as doing it one at a time (the controllers' own handlers, called
* in-process), so every rule still applies: closed events, payment caps and overpayments becoming
* donations, tickets and emails on payment, coupons released and the waitlist offered on
* cancelling, pricing on a ticket change, room capacity and gender, and the privacy rules and
* footers on messages. Results come back per registration; one failing doesn't stop the rest.
*/
const prisma = require('../config/db');
const MAX = 500;
const ACTIONS = ['markPaid', 'cancel', 'moveOption', 'allocate', 'message'];
const METHODS = ['cash', 'eft', 'card', 'other'];
function bulkError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** Calls an Express handler in-process; resolves to { ok, status, body }. */
function call(handler, req) {
return new Promise((resolve) => {
const res = {
statusCode: 200,
status(code) { this.statusCode = code; return this; },
json(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
send(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
};
Promise.resolve(handler({ query: {}, params: {}, body: {}, headers: {}, ip: req.ip, ...req }, res))
.catch(e => resolve({ ok: false, status: 500, body: { message: e?.message || String(e) } }));
});
}
/** Who is asking, with their request's headers and socket (so audit entries keep the real IP). */
const from = req => ({ user: req.user, headers: req.headers || {}, socket: req.socket });
const messageOf = r => r.body?.message || (r.ok ? 'Done' : `Failed (${r.status})`);
async function markPaid(req, regs, { method, paidAt }) {
if (!METHODS.includes(method)) throw bulkError('Choose how it was paid (cash, EFT, card or other)');
const { createPayment } = require('../controllers/paymentController');
const { refreshPricingForRegistration, computeRegistrationTotalDue } = require('./pricing');
const out = [];
for (const r of regs) {
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled — not paid' }); continue; }
try { await refreshPricingForRegistration(r.id); } catch { /* the payment code refreshes too */ }
const fresh = await prisma.registration.findUnique({ where: { id: r.id }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } });
const owed = Math.round((computeRegistrationTotalDue(fresh, new Date()) - fresh.payments.reduce((s, p) => s + p.amount, 0)) * 100) / 100;
if (owed <= 0) { out.push({ id: r.id, ok: true, skipped: true, message: 'Already paid' }); continue; }
const res = await call(createPayment, { ...from(req), body: { amount: owed, method, registrationId: r.id, userId: r.userId, ...(paidAt ? { paidAt } : {}) } });
out.push({ id: r.id, ok: res.ok, message: res.ok ? `R${owed.toFixed(2)} recorded` : messageOf(res) });
}
return out;
}
async function cancel(req, regs) {
const { cancelRegistration } = require('../controllers/registrationController');
const out = [];
for (const r of regs) {
if (r.status === 'cancelled') { out.push({ id: r.id, ok: true, skipped: true, message: 'Already cancelled' }); continue; }
const res = await call(cancelRegistration, { ...from(req), params: { id: r.id } });
out.push({ id: r.id, ok: res.ok, message: res.ok ? 'Cancelled' : messageOf(res) });
}
return out;
}
/**
* Moves every place on `fromOptionId` to `toOptionId`, keeping the same lines, tickets (and their
* QR codes) and named people — the editing screen can't take issued tickets away, but a move
* doesn't need to. Each moved line is repriced at the new ticket's current price (early birds and
* staff prices as for a new registration); the status follows what's now paid against owed, so
* moving up shows the difference owing and moving down shows the overpayment. Places on the new
* ticket are checked first.
*/
async function moveOption(req, regs, { fromOptionId, toOptionId }) {
if (!fromOptionId || !toOptionId || fromOptionId === toOptionId) throw bulkError('Choose the ticket to move from and the one to move to');
const options = await prisma.eventOption.findMany({ where: { id: { in: [fromOptionId, toOptionId] } }, include: { earlyBirdTiers: true, event: { select: { id: true, cashupStatus: true, kidsOptionId: true, volunteerOptionId: true } } } });
if (options.length !== 2 || options[0].eventId !== options[1].eventId) throw bulkError('Both tickets must belong to the same event');
const target = options.find(o => o.id === toOptionId);
const event = target.event;
if (event.cashupStatus === 'closed') throw bulkError('This event is closed. Reopen it (admin only) before making changes.');
if ([event.kidsOptionId, event.volunteerOptionId].includes(toOptionId)) throw bulkError('The kids and volunteer tickets are handed out by staff, not moved to');
const { resolveOptionPrice } = require('./pricing');
const { remainingFor } = require('./stock');
const { updateRegistrationStatus } = require('../payments/webhook');
const out = [];
for (const r of regs) {
if (r.eventId !== event.id) { out.push({ id: r.id, ok: false, message: 'Not for this event' }); continue; }
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled' }); continue; }
if (r.seasonPassPurchaseId) { out.push({ id: r.id, ok: false, message: 'Part of a season pass — tickets can\'t be changed' }); continue; }
const lines = await prisma.registrationOption.findMany({ where: { registrationId: r.id, eventOptionId: fromOptionId, sessionId: null }, select: { id: true, quantity: true } });
if (!lines.length) { out.push({ id: r.id, ok: true, skipped: true, message: 'No such ticket to move' }); continue; }
const qty = lines.reduce((s, l) => s + l.quantity, 0);
const left = await remainingFor(target, null);
if (left !== null && left < qty) { out.push({ id: r.id, ok: false, message: `Only ${left} place${left === 1 ? '' : 's'} left on ${target.name}` }); continue; }
try {
const owner = await prisma.user.findUnique({ where: { id: r.userId }, select: { role: true } });
const { price, tierId } = await resolveOptionPrice(target, qty, owner);
await prisma.$transaction(async tx => {
for (const line of lines) {
await tx.registrationOption.update({ where: { id: line.id }, data: { eventOptionId: toOptionId, variantId: null, priceSnapshot: price, appliedTierId: tierId || null } });
await tx.registrationOptionTranche.deleteMany({ where: { registrationOptionId: line.id } });
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: line.quantity, priceSnapshot: price, appliedTierId: tierId || null } });
await tx.attendee.updateMany({ where: { registrationOptionId: line.id }, data: { eventOptionId: toOptionId, variantId: null } });
}
});
const updated = await updateRegistrationStatus(r.id);
const diff = Math.round(((updated.totalDue || 0) - (updated.totalPaid || 0)) * 100) / 100;
out.push({ id: r.id, ok: true, message: diff > 0 ? `Moved — R${diff.toFixed(2)} now owing` : diff < 0 ? `Moved — R${(-diff).toFixed(2)} overpaid` : 'Moved' });
} catch (e) {
out.push({ id: r.id, ok: false, message: e?.message || 'Couldn\'t move' });
}
}
return out;
}
async function allocate(req, regs, { setId, groupId }) {
if (!setId || !groupId) throw bulkError('Choose the group to put them in');
const { assign } = require('../controllers/allocationController');
const out = [];
for (const r of regs) {
const people = await prisma.attendee.findMany({ where: { registrationId: r.id }, select: { id: true, name: true } });
if (!people.length) { out.push({ id: r.id, ok: false, message: 'No named people on this registration to place' }); continue; }
const fails = [];
for (const a of people) {
const res = await call(assign, { ...from(req), params: { id: setId }, body: { attendeeId: a.id, groupId } });
if (!res.ok) fails.push(`${a.name}: ${messageOf(res)}`);
}
out.push({ id: r.id, ok: fails.length === 0, message: fails.length ? fails.join('; ') : `${people.length} placed` });
}
return out;
}
async function message(req, regs, { channel, subject, text }) {
if (!['email', 'whatsapp'].includes(channel)) throw bulkError('Choose email or WhatsApp');
if (!String(text || '').trim()) throw bulkError('Write the message');
if (channel === 'email' && !String(subject || '').trim()) throw bulkError('Give the email a subject');
const { emailEventAttendees, whatsappEventAttendees } = require('../controllers/eventController');
const byEvent = new Map();
for (const r of regs) byEvent.set(r.eventId, [...(byEvent.get(r.eventId) || []), r.id]);
const out = [];
for (const [eventId, ids] of byEvent) {
// An event update to the people ticked (they registered, so it goes even to those who opted out of news).
const body = channel === 'email'
? { subject, text, kind: 'update', filter: { registrationIds: ids } }
: { message: text, kind: 'update', filter: { registrationIds: ids } };
const res = await call(channel === 'email' ? emailEventAttendees : whatsappEventAttendees, { ...from(req), params: { id: eventId }, body });
for (const id of ids) out.push({ id, ok: res.ok, message: res.ok ? `Sent with ${res.body?.sent ?? res.body?.matched ?? ids.length} for this event` : messageOf(res) });
}
return out;
}
/**
* Runs one bulk action. Returns { action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }.
*/
async function runBulk(req, { action, registrationIds, ...params }) {
if (!ACTIONS.includes(action)) throw bulkError('Unknown bulk action');
const ids = [...new Set((Array.isArray(registrationIds) ? registrationIds : []).map(String))];
if (!ids.length) throw bulkError('Tick at least one registration');
if (ids.length > MAX) throw bulkError(`At most ${MAX} at a time`);
// Read-only licence: no new payments or ticket changes (cancelling and messaging still work).
if (['markPaid', 'moveOption'].includes(action)) {
const { licenceStatus } = require('../hub/licence');
const status = await licenceStatus().catch(() => null);
if (status?.readOnly) throw bulkError('This site is read-only (licence), so payments and ticket changes can\'t be recorded.', 403);
}
const regs = await prisma.registration.findMany({ where: { id: { in: ids } }, include: { registrationOptions: { select: { eventOptionId: true, variantId: true, quantity: true, sessionId: true } } } });
const missing = ids.filter(id => !regs.some(r => r.id === id)).map(id => ({ id, ok: false, message: 'Not found' }));
const run = { markPaid, cancel, moveOption, allocate, message }[action];
const results = [...missing, ...(await run(req, regs, params))];
const { logAdminAction } = require('./adminAudit');
const { getClientIp } = require('./requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'registrations_bulk', targetType: 'Registration',
metadata: { bulk: action, registrations: ids.length, done: results.filter(r => r.ok && !r.skipped).length, failed: results.filter(r => !r.ok).length },
ip: getClientIp({ headers: {}, ...req }),
});
return {
action,
done: results.filter(r => r.ok && !r.skipped).length,
skipped: results.filter(r => r.skipped).length,
failed: results.filter(r => !r.ok).length,
results,
};
}
module.exports = { runBulk, ACTIONS, MAX };
+2
View File
@@ -467,6 +467,8 @@ async function anonymiseUser(userId, db = prisma) {
await tx.seriesFollow.deleteMany({ where: { userId } });
// Webhook deliveries about them (kept 30 days for the delivery log).
await tx.webhookDelivery.deleteMany({ where: { userId } });
// Their saved list filters (can hold search text, such as names).
await tx.savedFilter.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Saved filters (1.49.2): a person names the filters they use on a list page and applies them
* with one click; one per page can be their default (applied when the page opens). Stored per
* person on the server, so they follow them to any device. Only their own are ever read or changed.
*/
const prisma = require('../config/db');
// The list pages that have saved filters, and who can open them.
const PAGES = { registrations: 'admin', users: 'admin', events: 'supervisor', reports: 'supervisor' };
const MAX_PER_PAGE = 30;
const MAX_PARAMS_BYTES = 4000;
function filterError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
function cleanPage(page) {
const p = String(page || '');
if (!PAGES[p]) throw filterError('Unknown list page');
return p;
}
function cleanName(name) {
const n = String(name || '').trim().replace(/\s+/g, ' ');
if (!n) throw filterError('Give the filter a name');
return n.slice(0, 60);
}
/** The filter values: a plain object of strings, numbers, booleans and lists of strings. Pure. */
function cleanParams(params) {
if (!params || typeof params !== 'object' || Array.isArray(params)) throw filterError('Nothing to save');
const out = {};
for (const [k, v] of Object.entries(params)) {
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(k)) continue;
if (typeof v === 'string') out[k] = v.slice(0, 500);
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
else if (typeof v === 'boolean') out[k] = v;
else if (Array.isArray(v)) out[k] = v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 100));
}
if (JSON.stringify(out).length > MAX_PARAMS_BYTES) throw filterError('Too much to save in one filter');
return out;
}
const view = f => ({ id: f.id, page: f.page, name: f.name, params: f.params, isDefault: f.isDefault, updatedAt: f.updatedAt });
async function list(userId, page) {
const rows = await prisma.savedFilter.findMany({ where: { userId, page: cleanPage(page) }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] });
return rows.map(view);
}
/** Saves (or replaces, by name) a filter. Making it the default clears the old default. */
async function save(userId, { page, name, params, isDefault = false }) {
const p = cleanPage(page);
const n = cleanName(name);
const data = cleanParams(params);
const existing = await prisma.savedFilter.findUnique({ where: { userId_page_name: { userId, page: p, name: n } } });
if (!existing && (await prisma.savedFilter.count({ where: { userId, page: p } })) >= MAX_PER_PAGE) {
throw filterError(`You can keep up to ${MAX_PER_PAGE} filters on a page — delete one first`);
}
return prisma.$transaction(async tx => {
if (isDefault) await tx.savedFilter.updateMany({ where: { userId, page: p, isDefault: true }, data: { isDefault: false } });
const row = existing
? await tx.savedFilter.update({ where: { id: existing.id }, data: { params: data, ...(isDefault ? { isDefault: true } : {}) } })
: await tx.savedFilter.create({ data: { userId, page: p, name: n, params: data, isDefault: !!isDefault } });
return view(row);
});
}
async function own(userId, id) {
const row = await prisma.savedFilter.findUnique({ where: { id: String(id) } });
if (!row || row.userId !== userId) throw filterError('Saved filter not found', 404);
return row;
}
/** Rename, or make (or stop being) the default. */
async function update(userId, id, { name, isDefault }) {
const row = await own(userId, id);
return prisma.$transaction(async tx => {
if (isDefault === true) await tx.savedFilter.updateMany({ where: { userId, page: row.page, isDefault: true, NOT: { id: row.id } }, data: { isDefault: false } });
try {
return view(await tx.savedFilter.update({
where: { id: row.id },
data: { ...(name !== undefined ? { name: cleanName(name) } : {}), ...(isDefault !== undefined ? { isDefault: isDefault === true } : {}) },
}));
} catch (e) {
if (e?.code === 'P2002') throw filterError('You already have a filter with that name on this page');
throw e;
}
});
}
async function remove(userId, id) {
const row = await own(userId, id);
await prisma.savedFilter.delete({ where: { id: row.id } });
}
module.exports = { PAGES, MAX_PER_PAGE, cleanParams, cleanName, cleanPage, list, save, update, remove };
+95
View File
@@ -0,0 +1,95 @@
const mockDb = {
registration: { findMany: jest.fn(), findUnique: jest.fn() },
eventOption: { findMany: jest.fn() },
attendee: { findMany: jest.fn(), updateMany: jest.fn() },
registrationOption: { findMany: jest.fn(), update: jest.fn() },
registrationOptionTranche: { deleteMany: jest.fn(), create: jest.fn() },
user: { findUnique: jest.fn().mockResolvedValue({ role: "user" }) },
$transaction: jest.fn(fn => fn(mockDb)),
};
const mockLeft = jest.fn().mockResolvedValue(null);
jest.mock("../src/utils/stock", () => ({ remainingFor: (...a) => mockLeft(...a) }));
jest.mock("../src/utils/pricing", () => ({ resolveOptionPrice: jest.fn().mockResolvedValue({ price: 150, tierId: null }) }));
const mockStatus = jest.fn();
jest.mock("../src/payments/webhook", () => ({ updateRegistrationStatus: (...a) => mockStatus(...a) }));
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn() }));
const mockLicence = jest.fn().mockResolvedValue({ readOnly: false });
jest.mock('../src/hub/licence', () => ({ licenceStatus: (...a) => mockLicence(...a) }));
const mockCancel = jest.fn();
const mockUpdate = jest.fn();
jest.mock('../src/controllers/registrationController', () => ({
cancelRegistration: (req, res) => mockCancel(req, res),
updateRegistrationOptions: (req, res) => mockUpdate(req, res),
}));
const mockAssign = jest.fn();
jest.mock('../src/controllers/allocationController', () => ({ assign: (req, res) => mockAssign(req, res) }));
const mockEmail = jest.fn();
jest.mock('../src/controllers/eventController', () => ({ emailEventAttendees: (req, res) => mockEmail(req, res), whatsappEventAttendees: jest.fn() }));
const bulk = require('../src/utils/bulkRegistrations');
const { isBlocked } = require('../src/hub/readOnly');
const req = { user: { id: 'sup', role: 'supervisor' } };
const reg = (id, extra = {}) => ({ id, userId: `u-${id}`, eventId: 'ev1', status: 'pending', seasonPassPurchaseId: null, registrationOptions: [{ eventOptionId: 'adult', variantId: null, quantity: 1, sessionId: null }], ...extra });
beforeEach(() => jest.clearAllMocks());
test('the input is checked before anything runs', async () => {
await expect(bulk.runBulk(req, { action: 'explode', registrationIds: ['a'] })).rejects.toThrow('Unknown');
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: [] })).rejects.toThrow('Tick at least one');
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: Array.from({ length: 501 }, (_, i) => `r${i}`) })).rejects.toThrow('At most 500');
});
test('cancelling runs the normal cancel for each; already-cancelled ones are skipped; a refusal is reported', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { status: 'cancelled' }), reg('c')]);
mockCancel.mockImplementation((r, res) => (r.params.id === 'c' ? res.status(403).json({ message: 'has payments' }) : res.json({ message: 'Registration cancelled' })));
const out = await bulk.runBulk(req, { action: 'cancel', registrationIds: ['a', 'b', 'c', 'gone'] });
expect(out).toMatchObject({ done: 1, skipped: 1, failed: 2 });
expect(out.results.find(r => r.id === 'c')).toMatchObject({ ok: false, message: 'has payments' });
expect(out.results.find(r => r.id === 'gone')).toMatchObject({ ok: false, message: 'Not found' });
expect(mockCancel).toHaveBeenCalledTimes(2);
expect(mockCancel.mock.calls[0][0].user).toBe(req.user);
});
test('moving keeps the same lines and tickets, repriced; checks places; not other events or season passes', async () => {
mockDb.eventOption.findMany.mockResolvedValue([
{ id: 'adult', eventId: 'ev1', name: 'Adult', event: { id: 'ev1', cashupStatus: 'open' } },
{ id: 'vip', eventId: 'ev1', name: 'VIP', event: { id: 'ev1', cashupStatus: 'open' } },
]);
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { eventId: 'ev2' }), reg('c', { seasonPassPurchaseId: 'p1' }), reg('d')]);
mockDb.registrationOption.findMany.mockResolvedValue([{ id: 'line1', quantity: 1 }]);
mockLeft.mockResolvedValueOnce(null).mockResolvedValueOnce(0);
mockStatus.mockResolvedValue({ totalDue: 150, totalPaid: 100 });
const out = await bulk.runBulk(req, { action: 'moveOption', registrationIds: ['a', 'b', 'c', 'd'], fromOptionId: 'adult', toOptionId: 'vip' });
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: true, message: 'Moved — R50.00 now owing' });
expect(mockDb.registrationOption.update).toHaveBeenCalledWith({ where: { id: 'line1' }, data: { eventOptionId: 'vip', variantId: null, priceSnapshot: 150, appliedTierId: null } });
expect(mockDb.attendee.updateMany).toHaveBeenCalledWith({ where: { registrationOptionId: 'line1' }, data: { eventOptionId: 'vip', variantId: null } });
expect(out.results.find(r => r.id === 'b').message).toBe('Not for this event');
expect(out.results.find(r => r.id === 'c').message).toMatch(/season pass/);
expect(out.results.find(r => r.id === 'd')).toMatchObject({ ok: false, message: 'Only 0 places left on VIP' });
});
test('payments and ticket changes are refused in licence read-only mode; cancelling is not', async () => {
mockLicence.mockResolvedValueOnce({ readOnly: true });
await expect(bulk.runBulk(req, { action: 'markPaid', registrationIds: ['a'], method: 'cash' })).rejects.toMatchObject({ statusCode: 403 });
expect(isBlocked('POST', '/api/registrations/bulk')).toBe(false);
});
test('allocating places each named person; a registration without names says so', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b')]);
mockDb.attendee.findMany.mockImplementation(({ where }) => Promise.resolve(where.registrationId === 'a' ? [{ id: 'p1', name: 'Amy' }, { id: 'p2', name: 'Ben' }] : []));
mockAssign.mockImplementation((r, res) => (r.body.attendeeId === 'p2' ? res.status(400).json({ message: 'Cabin 3 is full' }) : res.json({})));
const out = await bulk.runBulk(req, { action: 'allocate', registrationIds: ['a', 'b'], setId: 's1', groupId: 'g1' });
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: false, message: 'Ben: Cabin 3 is full' });
expect(out.results.find(r => r.id === 'b').message).toMatch(/No named people/);
});
test('messages go per event as an event update to exactly the ticked registrations', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b'), reg('c', { eventId: 'ev2' })]);
mockEmail.mockImplementation((r, res) => res.json({ sent: r.body.filter.registrationIds.length }));
const out = await bulk.runBulk(req, { action: 'message', registrationIds: ['a', 'b', 'c'], channel: 'email', subject: 'Bus times', text: 'The bus leaves at 7:30.' });
expect(out.done).toBe(3);
expect(mockEmail.mock.calls.map(c => [c[0].params.id, c[0].body.filter.registrationIds, c[0].body.kind])).toEqual([['ev1', ['a', 'b'], 'update'], ['ev2', ['c'], 'update']]);
await expect(bulk.runBulk(req, { action: 'message', registrationIds: ['a'], channel: 'email', text: 'x' })).rejects.toThrow('subject');
});
+41
View File
@@ -0,0 +1,41 @@
const mockDb = {
savedFilter: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), delete: jest.fn() },
$transaction: jest.fn(fn => fn(mockDb)),
};
jest.mock('../src/config/db', () => mockDb);
const filters = require('../src/utils/savedFilters');
beforeEach(() => jest.clearAllMocks());
test('only simple filter values are kept', () => {
expect(filters.cleanParams({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b', 3], n: 2, nested: { x: 1 }, 'bad key': 'x', fn: null })).toEqual({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b'], n: 2 });
expect(() => filters.cleanParams(null)).toThrow('Nothing to save');
expect(() => filters.cleanParams({ q: 'x'.repeat(500), a: 'x'.repeat(500), b: 'x'.repeat(500), c: 'x'.repeat(500), d: 'x'.repeat(500), e: 'x'.repeat(500), f: 'x'.repeat(500), g: 'x'.repeat(500), h: 'x'.repeat(500) })).toThrow('Too much');
});
test('pages and names are checked', async () => {
await expect(filters.list('u1', 'secrets')).rejects.toThrow('Unknown list page');
expect(() => filters.cleanName(' ')).toThrow('name');
expect(filters.cleanName(' Camp — unpaid ')).toBe('Camp — unpaid');
});
test('saving a default clears the old default; saving the same name replaces it', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f1' });
mockDb.savedFilter.update.mockImplementation(async ({ data }) => ({ id: 'f1', page: 'registrations', name: 'Unpaid', ...data }));
await filters.save('u1', { page: 'registrations', name: 'Unpaid', params: { status: 'pending' }, isDefault: true });
expect(mockDb.savedFilter.updateMany).toHaveBeenCalledWith({ where: { userId: 'u1', page: 'registrations', isDefault: true }, data: { isDefault: false } });
expect(mockDb.savedFilter.create).not.toHaveBeenCalled();
});
test('at most 30 per page', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue(null);
mockDb.savedFilter.count.mockResolvedValue(30);
await expect(filters.save('u1', { page: 'events', name: 'One more', params: { q: 'x' } })).rejects.toThrow('up to 30');
});
test('someone else\'s saved filter is "not found"', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f9', userId: 'someone-else', page: 'users' });
await expect(filters.remove('u1', 'f9')).rejects.toMatchObject({ statusCode: 404 });
await expect(filters.update('u1', 'f9', { name: 'Mine now' })).rejects.toMatchObject({ statusCode: 404 });
expect(mockDb.savedFilter.delete).not.toHaveBeenCalled();
});
+49 -3
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.49.0** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-28, after **1.50.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,9 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 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 |
| 1.48.2 | Four demo seeds (`npm run demo:small|events|ministry|multicampus`, `demo:list`); fixes `set-demo`, broken since 1.43.0 by TRUNCATE CASCADE through `User.homeCampusId` |
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
@@ -56,8 +59,12 @@ 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.49.1 — bulk actions** on registration/attendee lists (message, export, mark paid, move option,
cancel, allocate), **1.49.2 — saved filters** (§9), then 1.50.0 Afrikaans and 1.51.0 accessibility.
1. **1.50.1 — pagination and search on long lists**: check the Audit log and the other pages whose
data grows large (registrations, users, payments, messages, access/consent logs, background
jobs, webhooks deliveries …) for server-side paging, search and filters, and add them where
missing (Joshua, 2026-10-28).
2. **1.51.0 — TypeScript backend, OpenAPI and `/api/v2`** (§14), then **2.0.0 — AI helpers** (§15).
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
## Working on this repo
@@ -390,6 +397,45 @@ a test server (both done 2026-10-28).
- Deep links read `window.location.search` once on load (no Suspense needed).
- Tests: `tests/dashboard.test.js`, e2e `dashboard.spec.ts`.
## Where bulk actions live (1.49.1)
- `backend/src/utils/bulkRegistrations.js` — `runBulk` checks the input (≤ 500, licence read-only for
payments and moves) and runs one action per registration, collecting a result for each. Most
actions **call the existing Express handlers in-process** (`call(handler, req)`, with the original
request's user, headers and socket so audits keep the IP): `createPayment`, `cancelRegistration`,
`allocationController.assign`, `emailEventAttendees` / `whatsappEventAttendees` (new
`filter.registrationIds`). **Change a rule in those handlers and bulk follows.**
- Moving tickets is its own code (`moveOption`): the edit handler refuses to remove issued tickets,
so the lines are re-pointed in place (tickets and QR codes kept), repriced with `resolveOptionPrice`,
attendees moved, then `updateRegistrationStatus`.
- Frontend: `components/admin/RegistrationBulkBar.tsx` on `app/dashboard/admin/registrations`. Export
goes through `/api/reports/excel` (access-logged). Tests: `tests/bulkRegistrations.test.js`, e2e
`bulk.spec.ts`.
## Saved filters (1.49.2)
- `backend/src/utils/savedFilters.js` (`PAGES` is the list of pages with saved filters; values are
cleaned to simple types) + `routes/savedFilterRoutes.js`. Removed in `anonymiseUser`.
- `components/shared/SavedFilters.tsx` takes the page's current filter values and an `onApply`;
`skipDefault` when the URL already says what to show. **Adding it to another list page:** add the
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.
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+15 -7
View File
@@ -36,7 +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. **Next: 1.49.1 bulk actions, then 1.49.2 saved filters.**
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. **Next: 1.50.1 pagination and search on long lists** (Audit log and other
large lists), then 1.51.0 TypeScript backend / `/api/v2`.
**Repositories:**
@@ -58,7 +61,7 @@ shipped. **Next: 1.49.1 bulk actions, then 1.49.2 saved filters.**
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)
@@ -518,7 +521,7 @@ register; deliveries kept 30 days.)
## 9. Admin dashboard, command palette, bulk actions
Three releases (2026-10-28): **1.49.0** dashboard (Today, Waiting for you) and the Ctrl+K command
palette ✅; **1.49.1** bulk actions; **1.49.2** saved filters.
palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters ✅.
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
far, money in (today/week), pending items (unreconciled payments, consent
@@ -533,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.
@@ -549,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)
@@ -939,8 +947,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)
@@ -971,8 +978,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 | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | 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/`),
@@ -1023,6 +1030,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) |
+136
View File
@@ -0,0 +1,136 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { test, expect, seed, loginAs } 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");
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.locator("select").selectOption(value!);
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();
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.getByLabel("Event").selectOption(value!);
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();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { test, expect, loginAs, api } from "./fixtures";
// Bulk actions on the registrations list (1.49.1): tick several, mark them paid, move them to
// another ticket, cancel — each through the same rules as one at a time.
type Ev = { id: string; eventOptions?: { id: string; name: string; isMainTicket?: boolean }[] };
type Reg = { id: string; status: string; registrationOptions?: { eventOptionId: string; quantity: number }[]; payments?: { amount: number; method: string }[] };
test.describe.serial("bulk actions on registrations", () => {
let event: Ev;
let main: string;
let vip: string;
const regs: string[] = [];
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
const start = new Date(Date.now() + 20 * 864e5);
event = await api<Ev>(page, "admin", "POST", "/api/events", {
title: "E2E Bulk Night", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3 * 3600e3).toISOString(),
price: 100, location: "Church hall",
});
const full = await api<Ev>(page, "admin", "GET", `/api/events/${event.id}`);
main = full.eventOptions!.find(o => o.isMainTicket)!.id;
vip = (await api<{ id: string }>(page, "admin", "POST", `/api/events/${event.id}/options`, { name: "VIP", price: 150, isMainTicket: true })).id;
for (const role of ["staff", "supervisor", "user"] as const) {
regs.push((await api<{ id: string }>(page, role, "POST", "/api/registrations", { eventId: event.id, options: [{ eventOptionId: main, quantity: 1 }] })).id);
}
await page.close();
});
const openList = async (page: import("@playwright/test").Page) => {
await loginAs(page, "admin");
await page.goto(`/dashboard/admin/registrations?event=${event.id}`);
await expect(page.getByText("3 of", { exact: false })).toBeVisible();
};
test("tick all and mark them paid in cash", async ({ page }) => {
await openList(page);
await page.getByLabel("Select all shown").check();
const bar = page.getByTestId("bulk-bar");
await expect(bar).toContainText("3 selected");
await bar.getByLabel("Bulk action").selectOption("markPaid");
await bar.getByLabel("Paid by").selectOption("cash");
page.once("dialog", d => d.accept());
await bar.getByRole("button", { name: /Mark paid/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("3 done");
for (const id of regs) {
const r = await api<Reg>(page, "admin", "GET", `/api/registrations/${id}`);
expect(r.status).toBe("paid");
expect(r.payments?.map(p => [p.amount, p.method])).toEqual([[100, "cash"]]);
}
});
test("move two to the VIP ticket; cancel the third", async ({ page }) => {
await openList(page);
const rows = page.locator("li").filter({ has: page.getByRole("checkbox", { name: /^Select / }) });
await rows.nth(0).getByRole("checkbox").check();
await rows.nth(1).getByRole("checkbox").check();
const bar = page.getByTestId("bulk-bar");
await bar.getByLabel("Bulk action").selectOption("moveOption");
await bar.getByLabel("Move from").selectOption(main);
await bar.getByLabel("Move to").selectOption(vip);
page.once("dialog", d => d.accept());
await bar.getByRole("button", { name: /Move to another ticket/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("2 done");
const moved = await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)));
const onVip = moved.filter(r => r.registrationOptions?.some(o => o.eventOptionId === vip));
expect(onVip).toHaveLength(2);
// R150 now, R100 paid: they owe the difference.
expect(onVip.every(r => r.status === "partial_paid")).toBe(true);
await bar.getByRole("button", { name: "Clear selection" }).click();
const left = moved.find(r => !r.registrationOptions?.some(o => o.eventOptionId === vip))!;
await page.getByRole("checkbox", { name: /^Select / }).nth(2).check();
const selectedId = (await api<Reg>(page, "admin", "GET", `/api/registrations/${left.id}`)).id;
expect(selectedId).toBe(left.id);
const bar2 = page.getByTestId("bulk-bar");
await bar2.getByLabel("Bulk action").selectOption("cancel");
page.once("dialog", d => d.accept());
await bar2.getByRole("button", { name: /Cancel — 1/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("1 done");
const cancelled = (await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)))).filter(r => r.status === "cancelled");
expect(cancelled).toHaveLength(1);
});
});
+3 -3
View File
@@ -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();
@@ -80,7 +80,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/);
+33
View File
@@ -0,0 +1,33 @@
import { test, expect, loginAs } from "./fixtures";
// Saved filters on list pages (1.49.2): save the current filters, make them the default (applied
// when the page opens), and delete them again.
test("an admin saves a registrations filter as the default, and deletes it", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/registrations");
const status = page.locator("select").filter({ has: page.locator("option", { hasText: "All statuses" }) });
await status.selectOption("cancelled");
const saved = page.getByTestId("saved-filters");
await saved.getByRole("button", { name: /Saved filters/ }).click();
await saved.getByLabel("Save the current filters as").fill("Cancelled ones");
await saved.getByLabel("Use when this page opens").check();
await saved.getByRole("button", { name: "Save", exact: true }).click();
await expect(saved.getByRole("button", { name: "Cancelled ones" }).first()).toBeVisible();
// The default is applied when the page opens…
await page.reload();
await expect(status).toHaveValue("cancelled");
await expect(saved.getByRole("button", { name: /Cancelled ones/ }).first()).toBeVisible();
// …but not over a link that says what to show.
await page.goto("/dashboard/admin/registrations?status=paid");
await expect(status).toHaveValue("paid");
await saved.getByRole("button", { name: /Saved filters/ }).click();
page.once("dialog", d => d.accept());
await saved.getByRole("button", { name: "Delete Cancelled ones" }).click();
await expect(saved.getByText("No saved filters yet")).toBeVisible();
await page.goto("/dashboard/admin/registrations");
await expect(status).toHaveValue("");
});
+1 -1
View File
@@ -88,7 +88,7 @@ test.describe("supervisor tools", () => {
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();
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.49.0",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.49.0",
"version": "1.50.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.0",
"version": "1.50.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">
@@ -121,6 +121,7 @@ const ACTION_LABELS: Record<string, string> = {
series_updated: "Event series changed",
series_deleted: "Event series deleted",
season_pass_changed: "Season pass created, changed or deleted",
registrations_bulk: "Bulk action on registrations",
};
interface AuditLogEntry {
@@ -9,6 +9,8 @@ import { ClipboardList } from "lucide-react";
import { errorMessage } from "@/lib/utils";
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";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -64,6 +66,10 @@ export default function AdminRegistrationsPage() {
if (params.get("event")) setEventFilter(params.get("event") || "");
}, []);
// Ticked for a bulk action (1.49.1).
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggleSelected = (id: string) => setSelected(prev => { const s = new Set(prev); if (s.has(id)) s.delete(id); else s.add(id); return s; });
// Expanded rows + form responses cache
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [formResponses, setFormResponses] = useState<Record<string, FormResponse[]>>({});
@@ -173,6 +179,10 @@ export default function AdminRegistrationsPage() {
});
}, [registrations, query, eventFilter, statusFilter, eventIds]);
// Only ticked registrations still shown by the filters are acted on.
const selectedRegs = useMemo(() => filtered.filter(r => selected.has(r.id)), [filtered, selected]);
const allShownSelected = filtered.length > 0 && filtered.every(r => selected.has(r.id));
const statusColor = (s: string) => {
if (s === "paid") return "text-green-700 bg-green-50";
if (s === "confirmed") return "text-blue-700 bg-blue-50";
@@ -283,11 +293,30 @@ export default function AdminRegistrationsPage() {
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={loadRegistrations} disabled={loadingRegs}>
{loadingRegs ? "Loading…" : "Refresh"}
</button>
<SavedFilters
page="registrations"
value={{ q: query, event: eventFilter, status: statusFilter, past: includePastEvents, inactive: includeInactiveEvents }}
onApply={p => {
setQuery(String(p.q || "")); setEventFilter(String(p.event || "")); setStatusFilter(String(p.status || ""));
setIncludePastEvents(p.past === true); setIncludeInactiveEvents(p.inactive === true);
}}
skipDefault={typeof window !== "undefined" && /[?&](q|status|event)=/.test(window.location.search)}
/>
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} of {registrations.length} registrations</div>
</div>
{token && selectedRegs.length > 0 && (
<RegistrationBulkBar selected={selectedRegs} events={events} token={token} onClear={() => setSelected(new Set())} onDone={loadRegistrations} />
)}
<div className="border rounded-xl bg-white shadow-sm">
{filtered.length > 0 && (
<label className="flex items-center gap-2 px-3 py-2 border-b text-xs text-gray-600 cursor-pointer">
<input type="checkbox" aria-label="Select all shown" checked={allShownSelected} onChange={e => setSelected(e.target.checked ? new Set(filtered.map(r => r.id)) : new Set())} />
Select all {filtered.length} shown
</label>
)}
<ul className="divide-y text-sm">
{filtered.map((r) => {
const totalDue = totalDueFor(r);
@@ -301,6 +330,10 @@ export default function AdminRegistrationsPage() {
<li key={r.id} className="hover:bg-gray-50">
<div className="p-3 cursor-pointer" onClick={() => toggleExpand(r)}>
<div className="flex items-start justify-between gap-2">
<input
type="checkbox" className="mt-1 shrink-0" aria-label={`Select ${r.user?.name || r.userId}`}
checked={selected.has(r.id)} onClick={e => e.stopPropagation()} onChange={() => toggleSelected(r.id)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{r.user?.name || r.userId}</span>
@@ -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 { errorMessage } from "@/lib/utils";
import type { Paginated } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { SavedFilters } from "@/components/shared/SavedFilters";
interface UserItem {
id: string;
@@ -359,6 +360,17 @@ export default function AdminUsersPage() {
<option value="false">Inactive only</option>
</select>
</div>
<SavedFilters
page="users" className="self-end"
value={{ q: query, role: roleFilter, active: activeFilter }}
onApply={p => {
const q = String(p.q || "");
setQuery(q); setDebouncedQuery(q);
setRoleFilter(String(p.role || "") as Role | "");
setActiveFilter(String(p.active || "") as "" | "true" | "false");
}}
skipDefault={typeof window !== "undefined" && /[?&]q=/.test(window.location.search)}
/>
<div>
<label className="block text-xs text-gray-600 mb-1">Per page</label>
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
@@ -1314,9 +1314,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 +1338,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>
@@ -10,6 +10,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useMyCampuses } from "@/hooks/useCampuses";
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { Calendar, Copy } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
@@ -2159,6 +2160,14 @@ export default function ManageEventsPage() {
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showClosed} onChange={e => setShowClosed(e.target.checked)} /> Show closed
</label>
<SavedFilters
page="events"
value={{ q: search, campus, past: includePast, inactive: showInactive, closed: showClosed }}
onApply={p => {
setSearch(String(p.q || "")); setCampus(String(p.campus || ""));
setIncludePast(p.past === true); setShowInactive(p.inactive === true); setShowClosed(p.closed === true);
}}
/>
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
</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"}
+67 -43
View File
@@ -11,6 +11,7 @@ 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";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -116,8 +117,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 +693,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 +708,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 +729,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 +748,7 @@ export default function SelfServicePage() {
</div>
</form>
</div>
</div>
</ModalOverlay>
)}
{/* ── Setup Screen ───────────────────────────────────────────── */}
@@ -762,8 +763,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 +776,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 +793,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 +820,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,8 +840,9 @@ 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>
<label htmlFor="kiosk-event" className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select
id="kiosk-event"
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"
@@ -885,12 +890,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 +912,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 +950,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 +965,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 +991,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 +1021,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 +1057,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 +1132,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 +1171,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 +1190,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 +1200,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 +1222,7 @@ export default function SelfServicePage() {
</button>
</div>
</div>
</div>
</ModalOverlay>
)}
{/* ── Event Form Screen ──────────────────────────────────────── */}
@@ -1241,7 +1261,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 +1271,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 +1289,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 +1298,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 +1307,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 +1316,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 +1328,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 +1406,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,198 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { CheckSquare, X, Download } from "lucide-react";
import { apiFetch, API_BASE } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { downloadReportExcel } from "@/lib/export";
import type { Event, Registration } from "@/types";
type Action = "" | "message" | "markPaid" | "moveOption" | "allocate" | "cancel" | "export";
type Result = { id: string; ok: boolean; skipped?: boolean; message: string };
type BulkResponse = { action: string; done: number; skipped: number; failed: number; results: Result[] };
type AllocationSet = { id: string; name: string; groups: { id: string; name: string }[] };
const ACTIONS: [Action, string][] = [
["message", "Send a message"],
["markPaid", "Mark paid (record the balance)"],
["moveOption", "Move to another ticket"],
["allocate", "Put in a room or group"],
["export", "Export to Excel"],
["cancel", "Cancel"],
];
const owing = (r: Registration) => {
const due = Number(r.totalDueComputed ?? 0);
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return Math.max(0, due - paid);
};
/**
* The bar above the registrations list while some are ticked (1.49.1): one action for all of
* them, run through the same rules as doing it one at a time, with a result per registration.
*/
export function RegistrationBulkBar({ selected, events, token, onClear, onDone }: {
selected: Registration[]; events: Event[]; token: string; onClear: () => void; onDone: () => void;
}) {
const [action, setAction] = useState<Action>("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<BulkResponse | null>(null);
// Per-action inputs.
const [channel, setChannel] = useState<"email" | "whatsapp">("email");
const [subject, setSubject] = useState("");
const [text, setText] = useState("");
const [method, setMethod] = useState("cash");
const [fromOptionId, setFromOptionId] = useState("");
const [toOptionId, setToOptionId] = useState("");
const [sets, setSets] = useState<AllocationSet[]>([]);
const [groupKey, setGroupKey] = useState("");
const eventIds = useMemo(() => [...new Set(selected.map(r => r.eventId))], [selected]);
const oneEvent = eventIds.length === 1 ? events.find(e => e.id === eventIds[0]) || null : null;
const options = useMemo(() => (oneEvent?.eventOptions || []).filter(o => o.id !== oneEvent?.kidsOptionId && o.id !== oneEvent?.volunteerOptionId), [oneEvent]);
const totalOwing = useMemo(() => selected.filter(r => r.status !== "cancelled").reduce((s, r) => s + owing(r), 0), [selected]);
useEffect(() => {
if (action !== "allocate" || !oneEvent) { setSets([]); return; }
apiFetch<{ sets?: AllocationSet[] }>(`/api/allocations/event/${oneEvent.id}`, { authToken: token })
.then(b => setSets((b.sets || []).filter(s => s.groups.length)))
.catch(() => setSets([]));
}, [action, oneEvent, token]);
const needsOneEvent = (action === "moveOption" || action === "allocate") && !oneEvent;
const exportSelected = async () => {
await downloadReportExcel(API_BASE, token, {
title: "Registrations", subtitle: `${selected.length} selected · ${new Date().toLocaleString()}`, kind: "table", orientation: "landscape",
table: {
columns: ["Reference", "Name", "Email", "Phone", "Event", "Status", "Total", "Paid", "Owing", "Tickets"],
rows: selected.map(r => {
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return [
String(r.id).slice(0, 8), r.user?.name || "", r.user?.email || "", r.user?.phoneNumber || "", r.event?.title || "", r.status,
`R ${Number(r.totalDueComputed ?? 0).toFixed(2)}`, `R ${paid.toFixed(2)}`, `R ${owing(r).toFixed(2)}`,
(r.registrationOptions || []).map(o => `${o.quantity} × ${o.eventOption?.name || ""}${o.variant?.name ? ` (${o.variant.name})` : ""}`).join(", "),
];
}),
},
});
};
const run = async () => {
setError(null); setResult(null);
if (action === "export") {
setBusy(true);
try { await exportSelected(); } catch (e) { setError(errorMessage(e) || "Couldn't export"); } finally { setBusy(false); }
return;
}
const confirmText: Record<string, string> = {
cancel: `Cancel ${selected.length} registration(s)? Cancelling doesn't refund — record refunds on the Payments page. Coupon uses are released and the waitlist is offered the places.`,
markPaid: `Record ${method.toUpperCase()} payments for what ${selected.length} registration(s) still owe (about R ${totalOwing.toFixed(2)})? Paid registrations get their tickets by email.`,
moveOption: `Move ${selected.length} registration(s) to the other ticket? Tickets already issued move with them (same QR code). The price follows the new ticket, so anything now owing or overpaid shows on each registration.`,
message: `Send this ${channel === "email" ? "email" : "WhatsApp"} to the people on ${selected.length} registration(s)?`,
allocate: `Put the named people on ${selected.length} registration(s) in this group?`,
};
if (!confirm(confirmText[action])) return;
const [setId, groupId] = groupKey.split("::");
const params = action === "message" ? { channel, subject, text }
: action === "markPaid" ? { method }
: action === "moveOption" ? { fromOptionId, toOptionId }
: action === "allocate" ? { setId, groupId }
: {};
setBusy(true);
try {
const res = await apiFetch<BulkResponse>("/api/registrations/bulk", { method: "POST", authToken: token, body: { action, registrationIds: selected.map(r => r.id), ...params } });
setResult(res);
onDone();
} catch (e) {
setError(errorMessage(e) || "Couldn't do that");
} finally {
setBusy(false);
}
};
const ready = !busy && !needsOneEvent && (
action === "export" || action === "cancel"
|| (action === "markPaid" && !!method)
|| (action === "message" && text.trim() && (channel === "whatsapp" || subject.trim()))
|| (action === "moveOption" && fromOptionId && toOptionId && fromOptionId !== toOptionId)
|| (action === "allocate" && !!groupKey)
);
const nameOf = (id: string) => selected.find(r => r.id === id)?.user?.name || id.slice(0, 8);
return (
<div className="border border-brand-200 bg-brand-50/60 rounded-xl p-3 mb-3 space-y-3" data-testid="bulk-bar">
<div className="flex flex-wrap items-center gap-2">
<CheckSquare className="w-4 h-4 text-brand-700" />
<span className="text-sm font-medium text-brand-900">{selected.length} selected</span>
<select aria-label="Bulk action" className="border rounded px-2 py-1.5 text-sm bg-white" value={action} onChange={e => { setAction(e.target.value as Action); setResult(null); setError(null); }}>
<option value="">Choose an action…</option>
{ACTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<button type="button" onClick={onClear} className="ml-auto inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><X className="w-3.5 h-3.5" /> Clear selection</button>
</div>
{needsOneEvent && <p className="text-xs text-amber-800">Tick registrations from one event only (filter by event) to {action === "moveOption" ? "move tickets" : "put people in a group"}.</p>}
{action === "message" && (
<div className="space-y-2">
<div className="flex gap-3 text-sm">
<label className="flex items-center gap-1"><input type="radio" checked={channel === "email"} onChange={() => setChannel("email")} /> Email</label>
<label className="flex items-center gap-1"><input type="radio" checked={channel === "whatsapp"} onChange={() => setChannel("whatsapp")} /> WhatsApp</label>
</div>
{channel === "email" && <input aria-label="Subject" className="w-full border rounded px-3 py-1.5 text-sm" placeholder="Subject" value={subject} onChange={e => setSubject(e.target.value)} />}
<textarea aria-label="Message" rows={4} className="w-full border rounded px-3 py-1.5 text-sm" placeholder="Hi {name}, …" value={text} onChange={e => setText(e.target.value)} />
<p className="text-[11px] text-gray-500">Sent as an event update to the person who registered. You can use {"{name}"}, {"{eventTitle}"} and {"{eventStart}"}.</p>
</div>
)}
{action === "markPaid" && (
<label className="text-sm flex items-center gap-2">Paid by
<select aria-label="Paid by" className="border rounded px-2 py-1.5 text-sm bg-white" value={method} onChange={e => setMethod(e.target.value)}>
<option value="cash">Cash</option><option value="eft">EFT</option><option value="card">Card (card machine)</option><option value="other">Other</option>
</select>
<span className="text-xs text-gray-500">Records what each still owes (about R {totalOwing.toFixed(2)} in all).</span>
</label>
)}
{action === "moveOption" && oneEvent && (
<div className="flex flex-wrap gap-2 items-center text-sm">
<select aria-label="Move from" className="border rounded px-2 py-1.5 bg-white" value={fromOptionId} onChange={e => setFromOptionId(e.target.value)}>
<option value="">From ticket…</option>{options.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
→
<select aria-label="Move to" className="border rounded px-2 py-1.5 bg-white" value={toOptionId} onChange={e => setToOptionId(e.target.value)}>
<option value="">To ticket…</option>{options.filter(o => o.id !== fromOptionId).map(o => <option key={o.id} value={o.id}>{o.name} (R{Number(o.price).toFixed(0)})</option>)}
</select>
</div>
)}
{action === "allocate" && oneEvent && (
sets.length === 0 ? <p className="text-xs text-gray-600">This event has no rooms or groups yet — set them up under Rooms &amp; groups.</p> : (
<select aria-label="Group" className="border rounded px-2 py-1.5 text-sm bg-white" value={groupKey} onChange={e => setGroupKey(e.target.value)}>
<option value="">Choose a group…</option>
{sets.map(s => <optgroup key={s.id} label={s.name}>{s.groups.map(g => <option key={g.id} value={`${s.id}::${g.id}`}>{g.name}</option>)}</optgroup>)}
</select>
)
)}
{action === "cancel" && <p className="text-xs text-gray-700">Cancelling doesn&apos;t refund anyone — record refunds on the Payments page.</p>}
{action && (
<button type="button" disabled={!ready} onClick={run} className={`inline-flex items-center gap-1.5 px-4 py-1.5 text-sm rounded font-medium disabled:opacity-50 ${action === "cancel" ? "bg-red-600 text-white hover:bg-red-700" : "bg-brand-600 text-white hover:bg-brand-700"}`}>
{action === "export" && <Download className="w-4 h-4" />}
{busy ? "Working…" : `${ACTIONS.find(a => a[0] === action)?.[1]} — ${selected.length}`}
</button>
)}
{error && <div className="p-2 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{result && (
<div className="p-2 border rounded bg-white text-sm" role="status" data-testid="bulk-result">
<div><strong>{result.done}</strong> done{result.skipped ? `, ${result.skipped} skipped` : ""}{result.failed ? `, ${result.failed} not done` : ""}.</div>
{result.results.filter(r => !r.ok || r.skipped).length > 0 && (
<ul className="mt-1 text-xs text-gray-700 max-h-40 overflow-auto">
{result.results.filter(r => !r.ok || r.skipped).map(r => <li key={r.id}><span className={r.ok ? "text-gray-500" : "text-red-700"}>{nameOf(r.id)}</span>: {r.message}</li>)}
</ul>
)}
</div>
)}
</div>
);
}
+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>
@@ -24,9 +24,9 @@ export function SessionSchedule({ sessions, defaultLocation }: { sessions: Event
const isNext = next?.id === s.id;
const place = s.location && s.location !== defaultLocation ? s.location : null;
return (
<li key={s.id} className={`flex flex-wrap items-baseline gap-x-3 gap-y-0.5 rounded-lg px-3 py-2 text-sm ${isNext ? "bg-brand-50 border border-brand-200" : "border border-transparent"} ${past ? "text-gray-400" : "text-gray-700"}`}>
<span className={`font-medium ${cancelled ? "line-through text-gray-400" : ""}`}>{cancelled ? (s.label || "Session") : sessionLabel(s, sessions)}</span>
<span className={cancelled ? "line-through text-gray-400" : ""}>{formatSessionWhen(s)}</span>
<li key={s.id} className={`flex flex-wrap items-baseline gap-x-3 gap-y-0.5 rounded-lg px-3 py-2 text-sm ${isNext ? "bg-brand-50 border border-brand-200" : "border border-transparent"} ${past ? "text-gray-500" : "text-gray-700"}`}>
<span className={`font-medium ${cancelled ? "line-through text-gray-500" : ""}`}>{cancelled ? (s.label || "Session") : sessionLabel(s, sessions)}</span>
<span className={cancelled ? "line-through text-gray-500" : ""}>{formatSessionWhen(s)}</span>
{cancelled && <span className="text-xs font-medium text-amber-700">Cancelled</span>}
{isNext && <span className="text-xs font-medium text-brand-700">Next</span>}
{place && !cancelled && <span className="flex items-center gap-1 text-xs text-gray-500"><MapPin className="w-3 h-3" />{place}</span>}
+14 -13
View File
@@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
@@ -97,18 +98,18 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
)}
{open && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setOpen(false)}>
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<ModalOverlay labelledBy="sponsor-title" onClose={() => setOpen(false)} closeOnBackdrop className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2"><HeartHandshake className="w-5 h-5 text-rose-600" /> Sponsor a spot</h3>
<h3 id="sponsor-title" className="text-lg font-semibold flex items-center gap-2"><HeartHandshake className="w-5 h-5 text-rose-600" aria-hidden="true" /> Sponsor a spot</h3>
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-1 rounded hover:bg-gray-100"><X className="w-4 h-4" /></button>
</div>
<p className="text-sm text-gray-600">Help someone come to {eventTitle} who couldn&apos;t otherwise afford it. Your gift goes into a sponsorship pool that the organisers use to cover places.</p>
{price ? (
<div className="flex gap-2 text-sm">
<button type="button" onClick={() => setMode("spots")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "spots" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Whole spots</button>
<button type="button" onClick={() => setMode("amount")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "amount" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Any amount</button>
<button type="button" onClick={() => setMode("spots")} aria-pressed={mode === "spots"} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "spots" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Whole spots</button>
<button type="button" onClick={() => setMode("amount")} aria-pressed={mode === "amount"} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "amount" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Any amount</button>
</div>
) : null}
{mode === "spots" && price ? (
@@ -118,32 +119,32 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
</div>
) : (
<div>
<label className="block text-xs text-gray-600 mb-1">Amount (R)</label>
<input type="number" min={15} step="0.01" className={inputCls} value={amount} onChange={e => setAmount(e.target.value)} placeholder="e.g. 200" required />
<label htmlFor="sponsor-amount" className="block text-xs text-gray-600 mb-1">Amount (R)</label>
<input id="sponsor-amount" type="number" min={15} step="0.01" className={inputCls} value={amount} onChange={e => setAmount(e.target.value)} placeholder="e.g. 200" required />
<p className="text-xs text-gray-500 mt-1">Minimum R15.</p>
</div>
)}
{!loggedIn && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Your name" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required />
<input type="email" className={inputCls} placeholder="Email (for your receipt)" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required />
<input className={inputCls} placeholder="Your name" aria-label="Your name" autoComplete="name" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required />
<input type="email" className={inputCls} placeholder="Email (for your receipt)" aria-label="Email (for your receipt)" autoComplete="email" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required />
</div>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={anonymous} onChange={e => setAnonymous(e.target.checked)} /> Give anonymously
</label>
{!anonymous && (
<input className={inputCls} placeholder={`Name to show the organisers (e.g. "The Smith family")`} value={sponsorName} onChange={e => setSponsorName(e.target.value)} maxLength={100} />
<input className={inputCls} placeholder={`Name to show the organisers (e.g. "The Smith family")`} aria-label="Name to show the organisers" value={sponsorName} onChange={e => setSponsorName(e.target.value)} maxLength={100} />
)}
<textarea className={inputCls} rows={2} placeholder="A message for the organisers (optional)" value={message} onChange={e => setMessage(e.target.value)} maxLength={300} />
<textarea className={inputCls} rows={2} placeholder="A message for the organisers (optional)" aria-label="A message for the organisers (optional)" value={message} onChange={e => setMessage(e.target.value)} maxLength={300} />
<p className="text-xs text-gray-500">You&apos;ll get a receipt by email. We never share who is helped by sponsorships.</p>
{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 || !(total >= 15)} className="w-full px-4 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 font-medium">
{busy ? "Opening payment…" : `Pay ${total > 0 ? rand(total) : ""} securely`}
</button>
</form>
</div>
</ModalOverlay>
)}
</div>
);
@@ -0,0 +1,27 @@
"use client";
/**
* "Skip to main content" — the first thing a keyboard user reaches on every page. Hidden
* until focused. Pages put their content in a <main> of their own, so the link finds it
* when used rather than relying on a fixed id.
*/
export function SkipLink() {
const skip = (e: React.MouseEvent<HTMLAnchorElement>) => {
const main = document.querySelector("main");
if (!main) return;
e.preventDefault();
if (!main.hasAttribute("tabindex")) main.setAttribute("tabindex", "-1");
main.focus();
main.scrollIntoView();
};
return (
<a
href="#main"
onClick={skip}
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-md focus:bg-white focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-gray-900 focus:shadow-lg"
>
Skip to main content
</a>
);
}
+24 -9
View File
@@ -15,6 +15,7 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { SavedFilters } from "@/components/shared/SavedFilters";
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
@@ -1512,15 +1513,29 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<CampusFilter
value={campus}
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
withChurchWide
everyLabel="All campuses (church-wide total)"
className="mb-3"
id="reports-campus"
mine
/>
<div className="flex flex-wrap items-start gap-3 mb-3">
<CampusFilter
value={campus}
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
withChurchWide
everyLabel="All campuses (church-wide total)"
id="reports-campus"
mine
/>
<SavedFilters
page="reports"
value={{ report, events: selectedEventIds, from: dateFrom, to: dateTo, campus, past: showPastEvents, inactive: showInactiveEvents, closed: showClosedEvents }}
onApply={p => {
if (typeof p.report === "string" && REPORTS.some(r => r.key === p.report)) setReport(p.report as ReportKey);
setCampus(String(p.campus || ""));
setShowPastEvents(p.past === true); setShowInactiveEvents(p.inactive === true); setShowClosedEvents(p.closed !== false);
setSelectedEventIds(Array.isArray(p.events) ? p.events : []);
setDateFrom(String(p.from || "")); setDateTo(String(p.to || ""));
setReady(false);
}}
skipDefault={!!searchParams.get("report")}
/>
</div>
<ReportsShell
events={filteredEvents}
@@ -172,7 +172,7 @@ export function AttendeeNamesEditor({
)}
{consent && (
<div className="mt-2">
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
<p aria-hidden="true" className="block text-xs text-gray-600 mb-1">Date of birth</p>
<input type="date" className={`${inputCls} sm:w-48`} value={e.dateOfBirth || ""} max={new Date().toISOString().slice(0, 10)} onChange={ev => set(s.key, "dateOfBirth", ev.target.value)} aria-label={`${s.label} date of birth`} />
{isMinorOn(e.dateOfBirth, consentEventStart) && (
<div className="mt-2 rounded-lg bg-amber-50 border border-amber-200 p-2">
@@ -115,7 +115,7 @@ export function CouponCodeField({
return (
<div>
{!compact && <label className="block text-sm font-medium text-gray-700 mb-1">Discount code</label>}
{!compact && <p aria-hidden="true" className="block text-sm font-medium text-gray-700 mb-1">Discount code</p>}
<div className="flex gap-2">
<input
value={code}
@@ -0,0 +1,83 @@
"use client";
import { useEffect, useRef, useState } from "react";
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* The dark backdrop for the app's own popups, with what a screen reader and keyboard need:
* announced as a dialog (named by `labelledBy`, the id of its heading), focus moved inside
* when it opens and kept there with Tab, Escape closes it (when `onClose` is given), and
* focus goes back to whatever opened it afterwards.
*/
export function ModalOverlay({
labelledBy,
onClose,
closeOnBackdrop = false,
className = "fixed inset-0 z-50 flex items-center justify-center bg-black/40",
children,
}: {
labelledBy: string;
onClose?: () => void;
/** Also close on a click on the dark backdrop (outside the popup). */
closeOnBackdrop?: boolean;
className?: string;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const closeRef = useRef(onClose);
closeRef.current = onClose;
// Taken while rendering: an autoFocus inside moves focus before the effect runs.
const [opener] = useState(() => (typeof document === "undefined" ? null : (document.activeElement as HTMLElement | null)));
useEffect(() => {
const box = ref.current;
// Respect an autoFocus inside; otherwise focus the first control, or the dialog itself.
if (box && !box.contains(document.activeElement)) {
(box.querySelector<HTMLElement>(FOCUSABLE) ?? box).focus();
}
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && closeRef.current) {
e.stopPropagation();
closeRef.current();
return;
}
if (e.key !== "Tab" || !box) return;
const items = Array.from(box.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.offsetParent !== null);
if (items.length === 0) {
e.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && (document.activeElement === first || !box.contains(document.activeElement))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (document.activeElement === last || !box.contains(document.activeElement))) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
if (opener && document.contains(opener)) opener.focus();
};
}, [opener]);
return (
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
tabIndex={-1}
className={className}
onClick={closeOnBackdrop && onClose ? (e) => { if (e.target === e.currentTarget) onClose(); } : undefined}
>
{children}
</div>
);
}
@@ -0,0 +1,116 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Star, Trash2, Check } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
export type FilterValues = Record<string, string | number | boolean | string[]>;
type Saved = { id: string; name: string; params: FilterValues; isDefault: boolean };
/**
* Saved filters on a list page (1.49.2): apply one with a click, save the current filters under a
* name, star one as the default (applied when the page opens, unless the link already says what
* to show — `skipDefault`). Stored per person on the server.
*/
export function SavedFilters({ page, value, onApply, skipDefault = false, className = "" }: {
page: "registrations" | "users" | "events" | "reports";
value: FilterValues;
onApply: (params: FilterValues) => void;
skipDefault?: boolean;
className?: string;
}) {
const { token } = useAuth();
const [list, setList] = useState<Saved[]>([]);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState("");
const [asDefault, setAsDefault] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applied, setApplied] = useState<string | null>(null);
const defaultDone = useRef(false);
const boxRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const rows = await apiFetch<Saved[]>(`/api/saved-filters?page=${page}`, { authToken: token });
setList(rows);
// The default, once, when the page opens.
if (!defaultDone.current) {
defaultDone.current = true;
const def = rows.find(r => r.isDefault);
if (def && !skipDefault) { onApply(def.params); setApplied(def.id); }
}
} catch { /* the page works without them */ }
// onApply/skipDefault are read once, for the default.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, page]);
useEffect(() => { load(); }, [load]);
// Close when clicking elsewhere.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const act = async (fn: () => Promise<unknown>) => {
setError(null);
try { await fn(); await load(); } catch (e) { setError(errorMessage(e) || "Couldn't save that"); }
};
const save = () => act(async () => {
setSaving(true);
try {
const row = await apiFetch<Saved>("/api/saved-filters", { method: "POST", authToken: token || undefined, body: { page, name, params: value, isDefault: asDefault } });
setApplied(row.id); setName(""); setAsDefault(false);
} finally { setSaving(false); }
});
const toggleDefault = (f: Saved) => act(() => apiFetch(`/api/saved-filters/${f.id}`, { method: "PUT", authToken: token || undefined, body: { isDefault: !f.isDefault } }));
const remove = (f: Saved) => act(async () => {
if (!confirm(`Delete the saved filter "${f.name}"?`)) return;
await apiFetch(`/api/saved-filters/${f.id}`, { method: "DELETE", authToken: token || undefined });
});
const current = list.find(f => f.id === applied);
return (
<div className={`relative inline-block ${className}`} ref={boxRef} data-testid="saved-filters">
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50">
<Bookmark className="w-4 h-4" /> {current ? current.name : "Saved filters"}{list.length > 0 && !current ? ` (${list.length})` : ""}
</button>
{open && (
<div className="absolute z-30 mt-1 w-72 right-0 sm:left-0 sm:right-auto rounded-xl border bg-white shadow-lg p-2 space-y-2" role="dialog" aria-label="Saved filters">
{list.length === 0 ? (
<p className="text-xs text-gray-500 px-1">No saved filters yet. Set the filters the way you like them and save them here.</p>
) : (
<ul className="max-h-60 overflow-y-auto">
{list.map(f => (
<li key={f.id} className="flex items-center gap-1 rounded hover:bg-gray-50">
<button type="button" className="flex-1 text-left text-sm px-2 py-1.5 truncate" onClick={() => { onApply(f.params); setApplied(f.id); setOpen(false); }}>
{applied === f.id && <Check className="inline w-3.5 h-3.5 mr-1 text-brand-600" />}{f.name}
</button>
<button type="button" onClick={() => toggleDefault(f)} title={f.isDefault ? "Default — click to stop" : "Make this the default when the page opens"} aria-label={f.isDefault ? `${f.name} is the default` : `Make ${f.name} the default`} className="p-1">
<Star className={`w-4 h-4 ${f.isDefault ? "fill-amber-400 text-amber-500" : "text-gray-300 hover:text-gray-500"}`} />
</button>
<button type="button" onClick={() => remove(f)} aria-label={`Delete ${f.name}`} className="p-1 text-gray-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</li>
))}
</ul>
)}
<div className="border-t pt-2 space-y-1.5">
<label className="block text-xs text-gray-600" htmlFor={`save-filter-${page}`}>Save the current filters as</label>
<div className="flex gap-1.5">
<input id={`save-filter-${page}`} className="flex-1 min-w-0 border rounded px-2 py-1 text-sm" placeholder="e.g. Camp — unpaid" value={name} onChange={e => setName(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && name.trim()) save(); }} />
<button type="button" disabled={saving || !name.trim()} onClick={save} className="px-2.5 py-1 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Save</button>
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-600"><input type="checkbox" checked={asDefault} onChange={e => setAsDefault(e.target.checked)} /> Use when this page opens</label>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
</div>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { ArrowRightLeft } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
type TransferTicket = { id: string; isUsed?: boolean; quantity?: number; usages?: unknown[] };
type TransferEvent = {
@@ -60,11 +61,11 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
const input = "w-full border rounded-lg px-3 py-2 text-sm";
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<ModalOverlay labelledBy="transfer-title" onClose={onClose} closeOnBackdrop className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto">
<div className="flex items-center gap-2">
<ArrowRightLeft className="w-5 h-5 text-brand-600" />
<h3 className="text-lg font-semibold">Transfer ticket</h3>
<ArrowRightLeft className="w-5 h-5 text-brand-600" aria-hidden="true" />
<h3 id="transfer-title" className="text-lg font-semibold">Transfer ticket</h3>
</div>
<p className="text-sm text-gray-600">
We&apos;ll send them a link to accept the ticket. Nothing changes until they accept — then{" "}
@@ -74,23 +75,23 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
{" "}The booking and payment stay with the person who booked.
</p>
<div>
<label className="block text-xs text-gray-600 mb-1">New holder&apos;s name</label>
<input className={input} value={f.name} onChange={set("name")} required maxLength={100} />
<label htmlFor="transfer-new-holders-name" className="block text-xs text-gray-600 mb-1">New holder&apos;s name</label>
<input id="transfer-new-holders-name" className={input} value={f.name} onChange={set("name")} required maxLength={100} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Email</label>
<input type="email" className={input} value={f.email} onChange={set("email")} />
<label htmlFor="transfer-email" className="block text-xs text-gray-600 mb-1">Email</label>
<input id="transfer-email" type="email" className={input} value={f.email} onChange={set("email")} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
<input type="tel" className={input} value={f.phone} onChange={set("phone")} />
<label htmlFor="transfer-phone" className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
<input id="transfer-phone" type="tel" className={input} value={f.phone} onChange={set("phone")} />
</div>
</div>
{event?.collectGender && (
<div>
<label className="block text-xs text-gray-600 mb-1">Gender</label>
<select className={input} value={f.gender} onChange={set("gender")} required>
<label htmlFor="transfer-gender" className="block text-xs text-gray-600 mb-1">Gender</label>
<select id="transfer-gender" className={input} value={f.gender} onChange={set("gender")} required>
<option value="">Choose…</option>
<option value="male">Male</option>
<option value="female">Female</option>
@@ -101,22 +102,22 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
{youth && (
<div className="space-y-2 border-t pt-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
<input type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
<label htmlFor="transfer-date-of-birth" className="block text-xs text-gray-600 mb-1">Date of birth</label>
<input id="transfer-date-of-birth" type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
</div>
{minor && (
<>
<p className="text-xs text-amber-800 bg-amber-50 rounded p-2">They&apos;re under 18, so a parent or guardian will be asked for consent before they can check in.</p>
<input className={input} placeholder="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
<input className={input} placeholder="Parent or guardian's name" aria-label="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input type="email" className={input} placeholder="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
<input type="tel" className={input} placeholder="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
<input type="email" className={input} placeholder="Parent's email" aria-label="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
<input type="tel" className={input} placeholder="Parent's phone" aria-label="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
</div>
</>
)}
</div>
)}
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{error && <div role="alert" className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={busy || !f.name.trim() || (!f.email.trim() && !f.phone.trim())} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">
@@ -124,6 +125,6 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
</button>
</div>
</form>
</div>
</ModalOverlay>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { ClipboardList, Filter, Ban } from "lucide-react";
import { ClipboardList, Filter, Ban, CheckSquare, Bookmark } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -26,6 +26,12 @@ export const dashboardAdminRegistrationsHelpContent: HelpContent = {
<div className="space-y-4">
<p>Click a row to expand it and see ticket options (with early-bird flags where applied), the payment history, and any submitted form responses.</p>
<div className="space-y-4">
<GuideItem icon={Bookmark} title="Saved filters" tone="amber">
Set the filters the way you use them, then Saved filters → name them and Save. Click one to apply it; the star makes it the one used when the page opens. They&apos;re yours (other people don&apos;t see them) and follow you to any device. The Users, Events and Reports pages have them too.
</GuideItem>
<GuideItem icon={CheckSquare} title="Doing many at once" tone="violet">
Tick registrations (or &quot;Select all shown&quot; after filtering) and choose an action: send a message (email or WhatsApp, as an event update), mark paid (records what each still owes, in cash, EFT, card or other — tickets go out by email), move to another ticket (their tickets and QR codes stay the same; the price follows the new ticket), put their named people in a room or group, export to Excel, or cancel. Each one follows the same rules as doing it one at a time, and you see what happened to each. Moving tickets and groups need registrations from one event.
</GuideItem>
<GuideItem icon={ClipboardList} title="Changing status" tone="brand">
Use the status dropdown inside the expanded row to move a registration between pending, confirmed, partially paid, paid, or cancelled.
</GuideItem>
+22 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Search, UserPlus, CreditCard, Calendar, Ticket, LogIn, KeyRound, MessageCircleQuestion, Phone } from "lucide-react";
import { Search, UserPlus, CreditCard, Calendar, Ticket, LogIn, KeyRound, MessageCircleQuestion, Phone, Keyboard } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -84,6 +84,27 @@ export const generalHelpContent: HelpContent = {
</div>
),
},
{
key: "accessibility",
label: "Keyboard & screen readers",
icon: Keyboard,
content: (
<div className="space-y-4">
<p>You can use the site without a mouse, and with a screen reader.</p>
<div className="space-y-4">
<GuideItem icon={Keyboard} title="Keyboard" tone="brand">
Press Tab to move through links, fields and buttons (Shift+Tab goes back); an outline shows where you are. The first Tab on any page offers &quot;Skip to main content&quot;. Enter or Space presses a button, and Escape closes a popup.
</GuideItem>
<GuideItem icon={Ticket} title="Choosing tickets" tone="green">
The + and − buttons next to each ticket are read out as &quot;Add one …&quot; and &quot;Remove one …&quot;, and the new number is announced.
</GuideItem>
<GuideItem icon={MessageCircleQuestion} title="Something hard to use?" tone="amber">
Let us know through the Contact page — we&apos;ll help you book and fix the problem.
</GuideItem>
</div>
</div>
),
},
{
key: "help",
label: "Still need help?",
+1 -1
View File
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
Preview your report, adjust filters, and choose how you want the data to appear.
</GuideItem>
<GuideItem icon={Download} title="Export or email" tone="amber">
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series&apos; registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
Export your report to Excel, PDF, or send it by email or WhatsApp. Saved filters (next to the campus filter) keep a report with its events, dates and options under a name — star one to have it ready when the page opens. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series&apos; registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
</GuideItem>
</div>
</div>
+6 -3
View File
@@ -65,9 +65,12 @@ export const siteSettingsHelpContent: HelpContent = {
icon: Palette,
content: (
<div className="space-y-4">
<p>Your logo and accent color.</p>
<GuideItem icon={Palette} title="Accent color" tone="violet">
This only recolors the organisation name next to the logo in the top navigation bar. Every other button, link, and highlight across the site uses a fixed color scheme by design — changing this setting won&apos;t change anything else.
<p>Your logo and colours, with a live preview of the website and emails.</p>
<GuideItem icon={Palette} title="Primary colour" tone="violet">
Used for buttons, links and highlights across the site and in emails. Buttons have white text on it and links use it on white, so it must be dark enough to read. If it isn&apos;t (under 4.5:1 contrast), the preview shows a warning and a <strong>Use a darker shade</strong> button with the closest readable shade of your colour.
</GuideItem>
<GuideItem icon={Palette} title="Secondary and accent" tone="brand">
Light tints for backgrounds and hover states — they stay light whatever you pick.
</GuideItem>
</div>
),
+34
View File
@@ -120,6 +120,40 @@ export function contrastForeground(hex: string): "#ffffff" | "#0a0a0a" {
return luminance > 0.45 ? "#0a0a0a" : "#ffffff";
}
function relativeLuminance(hex: string): number {
const h6 = normalizeHex(hex);
const chan = (v: number) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * chan(parseInt(h6.slice(1, 3), 16)) + 0.7152 * chan(parseInt(h6.slice(3, 5), 16)) + 0.0722 * chan(parseInt(h6.slice(5, 7), 16));
}
/** WCAG contrast ratio between two colours (1 to 21). */
export function contrastRatio(a: string, b: string): number {
const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
/** WCAG AA for normal-size text. */
export const MIN_TEXT_CONTRAST = 4.5;
/**
* The site puts white text on the primary colour (buttons, chips) and uses it for links
* on white, so it needs 4.5:1 against white. Returns the picked colour if it has that, or
* the closest darker shade (same hue and saturation) that does.
*/
export function accessiblePrimary(hex: string): string {
const picked = normalizeHex(hex);
if (contrastRatio(picked, "#ffffff") >= MIN_TEXT_CONTRAST) return picked;
const { h, s, l } = hexToHsl(picked);
for (let ll = Math.floor(l); ll >= 0; ll--) {
const shade = hslToHex(h, s, ll);
if (contrastRatio(shade, "#ffffff") >= MIN_TEXT_CONTRAST) return shade;
}
return "#000000";
}
/** Matches the shadcn-token foreground convention used elsewhere in globals.css (near-white / near-black HSL triples, not pure #fff/#000). */
export function contrastForegroundTriple(hex: string): string {
return contrastForeground(hex) === "#ffffff" ? "210 40% 98%" : "222.2 47.4% 11.2%";
+29 -2
View File
@@ -1,18 +1,35 @@
{
"name": "hope-events",
"version": "1.49.0",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.49.0",
"version": "1.50.0",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
"@playwright/test": "^1.64.0",
"concurrently": "^9.2.1"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.14.0.tgz",
"integrity": "sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.14.0"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
@@ -55,6 +72,16 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/axe-core": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz",
"integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.49.0",
"version": "1.50.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
@@ -23,6 +23,7 @@
"license": "ISC",
"description": "",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
"@playwright/test": "^1.64.0",
"concurrently": "^9.2.1"
},