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
73 changed files with 1271 additions and 269 deletions
+44
View File
@@ -7,6 +7,50 @@ 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
+21 -5
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)
---
@@ -481,6 +482,9 @@ logout controls. Disconnected sessions are recovered automatically.
(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
@@ -542,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 |
@@ -781,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. |
+16
View File
@@ -12,6 +12,22 @@ 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
+12
View File
@@ -933,6 +933,18 @@ 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 }` →
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.49.1",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.49.1",
"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.1",
"version": "1.50.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -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;
+18
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")
@@ -2297,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])
}
+9
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);
@@ -1015,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 }] }' },
+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;
+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 };
+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();
});
+33 -2
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.49.1** (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,8 @@ 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` |
@@ -57,7 +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.2 — saved filters** per user on list pages (§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
@@ -405,6 +412,30 @@ a test server (both done 2026-10-28).
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; 1.49.1 **bulk actions** shipped. **Next: 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; 1.49.1 **bulk actions** shipped. **Next: 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();
});
});
+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.1",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.49.1",
"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.1",
"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">
@@ -10,6 +10,7 @@ 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;
@@ -292,6 +293,15 @@ 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>
@@ -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>
+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, CheckSquare } 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,9 @@ 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>
+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.1",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.49.1",
"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.1",
"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"
},