Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6f90a151 | ||
|
|
dacd49652b |
@@ -7,6 +7,30 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.50.2] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Searchable event picker** (`components/shared/EventPicker.tsx`) instead of the plain event
|
||||
dropdowns on staff and supervisor pages (scanner, event tickets, at the door, payments, manual,
|
||||
email and WhatsApp attendees, forms, attendance, consent, waitlist, allocations, bundles, offline
|
||||
scans, kids and volunteer set-up, manage registrations, the kiosk). It opens on upcoming events
|
||||
(soonest first); past ones are behind **Show past events** (latest first), and typing searches
|
||||
every event by name, campus or date. Keyboard: arrows, Page Up/Down, Enter, Escape.
|
||||
- **Reports:** the events list can be searched; "Select these N" / "Clear these" act on the matches.
|
||||
- **Site settings → Webhooks → How webhooks work:** what is sent and when, the envelope and
|
||||
headers, retries and duplicates, checking the signature (Node.js and PHP), Zapier/Make set-up,
|
||||
and a copyable example of every event type. `GET /api/outbound-webhooks` event types now carry
|
||||
an `example`; `tests/webhooks.test.js` checks each one has the same shape as the real payload.
|
||||
|
||||
### Changed
|
||||
|
||||
- Pages that listed every event ever made in their dropdown (email and WhatsApp attendees, forms,
|
||||
bundles, scanner, payments) now open on upcoming ones; their separate "Include past events"
|
||||
checkboxes are gone (the picker has its own). Event tickets and Payments can now reach past
|
||||
events too.
|
||||
- e2e: `pickEvent()` in `e2e/fixtures.ts`; `e2e/event-picker.spec.ts`.
|
||||
|
||||
## [1.50.1] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
+11
-1
@@ -415,6 +415,12 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
## Supervisor tools
|
||||
|
||||
- **Choosing an event** (staff and supervisor pages: scanner, event tickets, at the door, payments,
|
||||
manual registration, email and WhatsApp, forms, attendance, consent, waitlist, allocations,
|
||||
bundles, offline scans, kids and volunteer set-up, manage registrations, kiosk): a searchable
|
||||
picker instead of a long dropdown. It opens on upcoming events, soonest first; **Show past
|
||||
events** lists past ones (latest first), and typing searches every event by name, campus or date.
|
||||
Arrow keys, Enter and Escape work. `components/shared/EventPicker.tsx`.
|
||||
- **Dashboard** — **Today** (each event or session on today: checked in so far
|
||||
against places, children in now, volunteers here; money in today and this
|
||||
week) and **Waiting for you** (unpaid registrations for the next two weeks,
|
||||
@@ -516,7 +522,7 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
## Reports
|
||||
|
||||
A full-width workspace with shared filters (events, date range with presets,
|
||||
A full-width workspace with shared filters (events, searchable, with "Select these N" for the matches; date range with presets,
|
||||
include past/inactive/closed) and a searchable grid of reports, each opening in
|
||||
a viewer with its own filters, stat tiles and bar charts.
|
||||
|
||||
@@ -822,6 +828,10 @@ Site settings → **Webhooks** (admins): send what happens to other systems as i
|
||||
extension); rolled-back changes send nothing. Kids check-in and volunteer tickets are never sent.
|
||||
- https and public addresses only (DNS checked at each send, redirects not followed). Each endpoint
|
||||
is added to the operator register; deliveries are deleted after 30 days and with an account.
|
||||
- **How webhooks work** (1.50.2), a guide on the tab (open until the first webhook is added): the
|
||||
envelope and headers, retries and duplicates, checking the signature (Node.js and PHP examples),
|
||||
Zapier/Make set-up, and a copyable example message for every event type. The examples come from
|
||||
`EVENT_TYPES[].example` in `utils/webhooks.js`; a test keeps them the same shape as what is sent.
|
||||
|
||||
## Background jobs
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ 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.2
|
||||
|
||||
- **Finding an event quickly.** Wherever staff and supervisors choose an event (the scanner, at
|
||||
the door, payments, messages, forms, attendance and more) there is now a search box. The list
|
||||
opens on upcoming events, past ones are one click away, and typing finds any event by name,
|
||||
campus or date. The events list in Reports can be searched too.
|
||||
- **Webhook guide.** Site settings → Webhooks has a "How webhooks work" guide for whoever connects
|
||||
another system: what is sent and when, how to check it came from you, and an example message for
|
||||
every kind of event.
|
||||
|
||||
## 1.50.1
|
||||
|
||||
- **Finding things in long lists.** The audit log can be searched by person, what was changed or
|
||||
|
||||
@@ -999,7 +999,7 @@ where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anythi
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types |
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types (each with an `example` of its `data`) |
|
||||
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
|
||||
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
|
||||
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -34,6 +34,48 @@ const EVENT_TYPES = [
|
||||
];
|
||||
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
|
||||
|
||||
// What `data` looks like for each type — shown in Site settings → Webhooks so whoever builds the
|
||||
// receiving side knows what arrives. tests/webhooks.test.js checks these match buildPayload.
|
||||
const EXAMPLE_EVENT = { id: 'c1a2b3c4-0000-4000-8000-000000000001', title: 'Youth Camp 2026', startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z' };
|
||||
const EXAMPLE_PERSON = { id: 'u1a2b3c4-0000-4000-8000-000000000002', name: 'Thandi Mokoena', email: 'thandi@example.com', phone: '+27821234567' };
|
||||
const exampleRegistration = (status, amountPaid) => ({
|
||||
registration: {
|
||||
id: 'r1a2b3c4-0000-4000-8000-000000000003', status, createdAt: '2026-10-29T08:15:00.000Z', totalDue: 1200, amountPaid,
|
||||
seasonPassPurchaseId: null,
|
||||
items: [{ ticket: 'Camper', variant: 'Bunk bed', quantity: 2, unitPrice: 600 }],
|
||||
},
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
});
|
||||
const examplePayment = (amount) => ({
|
||||
payment: { id: 'p1a2b3c4-0000-4000-8000-000000000004', amount, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1a2b3c4-0000-4000-8000-000000000003', createdAt: '2026-10-29T08:16:00.000Z' },
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
});
|
||||
const EXAMPLES = {
|
||||
'registration.created': exampleRegistration('pending', 0),
|
||||
'registration.paid': exampleRegistration('paid', 1200),
|
||||
'registration.cancelled': exampleRegistration('cancelled', 0),
|
||||
'payment.received': examplePayment(1200),
|
||||
// Refunds are recorded as negative payments.
|
||||
'payment.refunded': examplePayment(-600),
|
||||
'ticket.checked_in': {
|
||||
checkIn: { id: 't1a2b3c4-0000-4000-8000-000000000005', scannedAt: '2026-12-04T16:20:00.000Z', people: 1, manual: false, offline: false, session: { id: 's1a2b3c4-0000-4000-8000-000000000006', label: 'Friday evening', startsAt: '2026-12-04T16:00:00.000Z' } },
|
||||
ticket: { id: 'k1a2b3c4-0000-4000-8000-000000000007', registrationOptionId: 'o1a2b3c4-0000-4000-8000-000000000008', holderName: 'Thandi Mokoena' },
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
},
|
||||
'user.created': { person: { ...EXAMPLE_PERSON, role: 'user', createdAt: '2026-10-29T08:10:00.000Z' } },
|
||||
'event.published': {
|
||||
event: {
|
||||
...EXAMPLE_EVENT, description: 'A weekend away for grades 8–12.', location: 'Camp Joy, Magaliesburg', price: 600,
|
||||
goLiveAt: '2026-10-29T06:00:00.000Z', url: 'https://events.example.org/events/c1a2b3c4-0000-4000-8000-000000000001',
|
||||
campus: { name: 'North', slug: 'north' }, series: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const t of EVENT_TYPES) t.example = EXAMPLES[t.type];
|
||||
|
||||
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
|
||||
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
|
||||
const KEEP_DAYS = 30;
|
||||
|
||||
@@ -3,8 +3,12 @@ const mockDb = {
|
||||
webhookDelivery: { updateMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findUnique: jest.fn() },
|
||||
payment: { findUnique: jest.fn() },
|
||||
ticketUsage: { findUnique: jest.fn() },
|
||||
event: { findUnique: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: () => 1200 }));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const webhooks = require('../src/utils/webhooks');
|
||||
@@ -112,4 +116,25 @@ describe('outbound webhooks', () => {
|
||||
expect(made[0].eventId).toBe(made[1].eventId);
|
||||
expect(made[0].payload.data.person).toMatchObject({ id: 'u1', email: 't@test.local' });
|
||||
});
|
||||
// The examples shown in Site settings → Webhooks must have the same shape as what is sent.
|
||||
test('each event type has an example shaped like the real payload', async () => {
|
||||
const shape = (v) => (v && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.keys(v).sort().map(k => [k, shape(v[k])]))
|
||||
: Array.isArray(v) ? v.map(shape) : '·');
|
||||
const ev = { id: 'e1', title: 'Camp', startDate: new Date(), endDate: new Date(), kidsOptionId: null, volunteerOptionId: null };
|
||||
const user = { id: 'u1', name: 'T', email: 't@x', phoneNumber: '+27' };
|
||||
const reg = (status) => ({ id: 'r1', status, createdAt: new Date(), userId: 'u1', seasonPassPurchaseId: null, event: ev, user, payments: [{ amount: 1200 }],
|
||||
registrationOptions: [{ eventOptionId: 'o1', quantity: 2, priceSnapshot: 600, eventOption: { id: 'o1', name: 'Camper' }, variant: { name: 'Bunk' }, tranches: [] }] });
|
||||
mockDb.payment.findUnique.mockResolvedValue({ id: 'p1', amount: 1200, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1', createdAt: new Date(), userId: 'u1', user, event: ev });
|
||||
mockDb.ticketUsage.findUnique.mockResolvedValue({ id: 't1', scannedAt: new Date(), quantityRedeemed: 1, manual: false, offline: false, session: { id: 's1', label: 'Fri', startsAt: new Date() },
|
||||
ticket: { id: 'k1', userId: 'u1', registrationOptionId: 'o1', event: ev, user, holder: null, attendee: { name: 'T' }, registrationOption: { eventOptionId: 'o1' } } });
|
||||
mockDb.user.findUnique.mockResolvedValue({ ...user, role: 'user', createdAt: new Date() });
|
||||
mockDb.event.findUnique.mockResolvedValue({ ...ev, description: 'd', location: 'l', price: 600, isActive: true, isHidden: false, goLiveAt: new Date(), campus: { name: 'N', slug: 'n' }, series: null });
|
||||
for (const t of webhooks.EVENT_TYPES) {
|
||||
const status = { 'registration.paid': 'paid', 'registration.cancelled': 'cancelled' }[t.type] || 'pending';
|
||||
mockDb.registration.findUnique.mockResolvedValue(reg(status));
|
||||
const built = await webhooks.buildPayload(t.type, 'x');
|
||||
expect([t.type, shape(t.example)]).toEqual([t.type, shape(built.data)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+17
-3
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.50.1** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-29, after **1.50.2** (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,7 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
|
||||
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
|
||||
| 1.50.0 | **Accessibility pass** (§11, WCAG 2.2 AA) on public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labelled fields, named quantity buttons, announced errors, `ModalOverlay` popups (focus trap, Escape), contrast and underlined in-text links, reduce-motion; Branding warns about a too-light primary colour. axe + keyboard e2e in `e2e/accessibility.spec.ts` |
|
||||
| 1.49.2 | **Saved filters** (§9 part 3) on registrations, users, events and reports (`/api/saved-filters`, `components/shared/SavedFilters.tsx`). Migration `20261028180000_saved_filters` |
|
||||
@@ -443,8 +444,21 @@ a test server (both done 2026-10-28).
|
||||
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
|
||||
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
|
||||
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
|
||||
picker would replace them; event pickers using `/api/events/all?includePast=true`; the coupons
|
||||
list; `GET /api/registrations` without `page` (other callers still use the plain array).
|
||||
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
|
||||
|
||||
## Event picker and webhook guide (1.50.2)
|
||||
|
||||
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
|
||||
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
|
||||
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
|
||||
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
|
||||
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
|
||||
`pickEvent(page, title)` from `e2e/fixtures.ts`.
|
||||
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
|
||||
a slim `?fields=` mode would help if a church gets into the thousands.
|
||||
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
|
||||
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
|
||||
shape.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** shipped. **Next: 1.51.0 TypeScript
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. **Next: 1.51.0 TypeScript
|
||||
backend / `/api/v2`.**
|
||||
|
||||
**Repositories:**
|
||||
@@ -978,7 +978,7 @@ 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.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 |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 | 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 |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect, seed, loginAs } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
|
||||
|
||||
// WCAG 2.2 AA checks (axe-core) on the pages visitors and members use, so the
|
||||
// accessibility pass (1.50.0) doesn't regress.
|
||||
@@ -75,8 +75,7 @@ test.describe("accessibility (WCAG 2.2 AA)", () => {
|
||||
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 pickEvent(page, "E2E Free Evening");
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
|
||||
await check(page, "kiosk form");
|
||||
@@ -106,8 +105,7 @@ test.describe("keyboard", () => {
|
||||
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 pickEvent(page, "E2E Free Evening");
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
|
||||
const opener = page.getByRole("button", { name: "Change Event" });
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { test, expect, loginAs, api } from "./fixtures";
|
||||
|
||||
// 1.50.2: event dropdowns are a searchable picker — upcoming events first, past ones a click
|
||||
// away, and typing searches them all.
|
||||
test.describe("event picker", () => {
|
||||
test("opens on upcoming events; past ones are behind a button or found by searching", async ({ page }) => {
|
||||
const start = new Date(Date.now() - 40 * 864e5);
|
||||
await api(page, "supervisor", "POST", "/api/events", {
|
||||
title: "E2E Picker Old Gathering", description: "A past event for the picker test.", price: 0, location: "1 Test Street, Pretoria",
|
||||
startDate: start.toISOString(), endDate: new Date(start.getTime() + 2 * 3600e3).toISOString(),
|
||||
});
|
||||
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/email-attendees");
|
||||
await page.getByRole("button", { name: /^Event: Select an event/ }).click();
|
||||
const list = page.getByRole("listbox", { name: "Events" });
|
||||
await expect(list.getByRole("option", { name: /E2E Free Evening/ })).toBeVisible();
|
||||
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toHaveCount(0);
|
||||
// The open picker passes the WCAG 2.2 AA checks.
|
||||
const axe = await new AxeBuilder({ page }).include("[data-testid=event-picker]").withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze();
|
||||
expect(axe.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: /^Show past events/ }).click();
|
||||
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Hide past events" }).click();
|
||||
|
||||
// Searching finds past events too; the keyboard chooses.
|
||||
const search = page.getByRole("combobox", { name: "Search events" });
|
||||
await search.fill("old gathering");
|
||||
await expect(list.getByRole("option")).toHaveCount(1);
|
||||
await search.press("Enter");
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeFocused();
|
||||
|
||||
// Escape closes without changing anything.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(list).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(list).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the reports event list can be searched", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/supervisor/reports");
|
||||
await page.getByRole("button", { name: /^Events:/ }).click();
|
||||
await page.getByRole("button", { name: "Clear", exact: true }).click();
|
||||
await page.getByLabel("Search events").fill("alpha");
|
||||
await page.getByRole("button", { name: /^Select these 1$/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Events: E2E Alpha Course" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -51,4 +51,12 @@ export async function api<T = unknown>(page: Page, role: Role, method: string, u
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
/** Chooses an event in an EventPicker (the first on the page, or the one labelled `label`) by searching its title. */
|
||||
export async function pickEvent(page: Page, title: string, label?: string) {
|
||||
const button = label ? page.getByRole("button", { name: new RegExp(`^${label}:`) }) : page.getByTestId("event-picker").first().getByRole("button").first();
|
||||
await button.click();
|
||||
await page.getByRole("combobox", { name: /^Search/ }).fill(title);
|
||||
await page.getByRole("option", { name: new RegExp(title) }).first().click();
|
||||
}
|
||||
|
||||
export { expect };
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
|
||||
|
||||
// Kids check-in (1.45.0): a supervisor sets it up, a parent adds a child, the desk checks the child
|
||||
// in (pickup code + sticker panel), the parent sees the code, and a wrong code is refused before
|
||||
@@ -7,7 +7,7 @@ test.describe.serial("kids check-in", () => {
|
||||
test("a supervisor sets up the kids ticket, a room and switches it on", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/kids");
|
||||
await page.getByRole("combobox").first().selectOption(seed().events.kids);
|
||||
await pickEvent(page, "E2E Kids Church");
|
||||
await page.getByRole("button", { name: /Add a free "Kids check-in" ticket/ }).click();
|
||||
await expect(page.getByLabel("Kids ticket")).not.toHaveValue("");
|
||||
await page.getByRole("button", { name: /Add "Kids rooms"/ }).click();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
|
||||
|
||||
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
|
||||
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
|
||||
@@ -67,12 +67,11 @@ test.describe("pick sessions", () => {
|
||||
test("at the door: book a class for a walk-in", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/at-the-door");
|
||||
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
|
||||
// The page picks a default event once its list loads; choose ours after that.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("select").first().selectOption(value!);
|
||||
await pickEvent(page, "E2E Drop-in Class");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator("select").first()).toHaveValue(value!);
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Drop-in Class/ })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
|
||||
await page.getByText('New attendee "Door Walk In"…').click();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
|
||||
|
||||
test.describe("multi-day events", () => {
|
||||
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
|
||||
@@ -44,7 +44,7 @@ test.describe("multi-day events", () => {
|
||||
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
|
||||
await loginAs(page, "staff");
|
||||
await page.goto("/dashboard/staff/ticket-scanning");
|
||||
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
|
||||
await pickEvent(page, "E2E Alpha Course");
|
||||
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
|
||||
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
|
||||
|
||||
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
|
||||
|
||||
@@ -80,8 +80,7 @@ test.describe("supervisor tools", () => {
|
||||
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
|
||||
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
|
||||
await page.locator("select").selectOption(value!);
|
||||
await pickEvent(page, "E2E Free Evening");
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
|
||||
|
||||
@@ -67,4 +67,20 @@ test.describe.serial("outbound webhooks", () => {
|
||||
await endpoint.getByRole("button", { name: "Delivery log" }).click();
|
||||
await expect(endpoint.getByTestId("webhook-delivery").filter({ hasText: "user.created" })).toContainText("succeeded");
|
||||
});
|
||||
|
||||
test("the guide explains the signature and shows an example of each event", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=webhooks");
|
||||
const guide = page.getByTestId("webhook-guide");
|
||||
const toggle = guide.getByRole("button", { name: /How webhooks work/ });
|
||||
await expect(toggle).toBeVisible();
|
||||
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click();
|
||||
await expect(guide).toContainText("X-CrossEvents-Signature");
|
||||
await guide.getByRole("button", { name: /registration.paid/ }).click();
|
||||
const example = guide.getByTestId("webhook-example-registration.paid");
|
||||
await expect(example).toContainText("\"type\": \"registration.paid\"");
|
||||
await expect(example).toContainText("\"amountPaid\"");
|
||||
await guide.getByRole("button", { name: "PHP" }).click();
|
||||
await expect(guide).toContainText("hash_hmac");
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Event, FormResponse, Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -265,13 +266,8 @@ export default function AdminRegistrationsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm max-w-48" value={eventFilter} onChange={e => setEventFilter(e.target.value)}>
|
||||
<option value="">All events</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="registrations-event">Event</label>
|
||||
<EventPicker id="registrations-event" className="min-w-[14rem]" events={events} value={eventFilter} onChange={setEventFilter} emptyLabel="All events" />
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, Ticket as TicketRecord } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
function EventTicketsContent() {
|
||||
const { token, user } = useAuth();
|
||||
@@ -40,7 +41,7 @@ function EventTicketsContent() {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const now = Date.now();
|
||||
const active = (evs || []).filter((ev) => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
@@ -169,12 +170,14 @@ function EventTicketsContent() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
|
||||
<label className="text-sm text-gray-700 shrink-0">Event</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
|
||||
<label className="text-sm text-gray-700 shrink-0" htmlFor="tickets-event">Event</label>
|
||||
<EventPicker
|
||||
id="tickets-event"
|
||||
className="w-full sm:w-72"
|
||||
events={events}
|
||||
emptyLabel="All events"
|
||||
value={eventId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onChange={(v) => {
|
||||
setEventId(v);
|
||||
setSelectedUserId("");
|
||||
router.replace(
|
||||
@@ -185,14 +188,7 @@ function EventTicketsContent() {
|
||||
}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
|
||||
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
|
||||
<select
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
|
||||
import type { SnapshotTicket } from "@/lib/offlineScan";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, RecentScan, ScanStats, Section } from "@/types";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
@@ -72,7 +73,6 @@ export default function TicketScanningPage() {
|
||||
|
||||
// Event selection state
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("all");
|
||||
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
@@ -98,14 +98,12 @@ export default function TicketScanningPage() {
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
const events = useMemo(() => {
|
||||
if (includePastEvents) return allEvents;
|
||||
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
|
||||
return allEvents.filter(ev => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
return !isNaN(t) && t >= cutoff;
|
||||
});
|
||||
}, [allEvents, includePastEvents]);
|
||||
// The picker: every event, with the offline-locked one even if it has gone from the list.
|
||||
const pickerEvents = useMemo(() => {
|
||||
const lock = offline.lock;
|
||||
if (!lock || allEvents.some(ev => ev.id === lock.eventId)) return allEvents;
|
||||
return [...allEvents, { id: lock.eventId, title: lock.eventTitle, startDate: new Date().toISOString() } as Event];
|
||||
}, [allEvents, offline.lock]);
|
||||
|
||||
const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]);
|
||||
const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]);
|
||||
@@ -360,23 +358,18 @@ export default function TicketScanningPage() {
|
||||
|
||||
{/* Event filter */}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-3">
|
||||
<label className="text-sm text-gray-700 shrink-0">Event</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
<label className="text-sm text-gray-700 shrink-0" htmlFor="scan-event">Event</label>
|
||||
{/* Events that ended in the last 12 hours still count as current (late scanning). */}
|
||||
<EventPicker
|
||||
id="scan-event"
|
||||
className="w-full sm:w-72"
|
||||
events={pickerEvents}
|
||||
value={selectedEventId === "all" ? "" : selectedEventId}
|
||||
onChange={id => setSelectedEventId(id || "all")}
|
||||
emptyLabel="All events"
|
||||
graceHours={12}
|
||||
disabled={!!offline.lock}
|
||||
>
|
||||
<option value="all">All events</option>
|
||||
{offline.lock && !events.some(ev => ev.id === offline.lock!.eventId) && <option value={offline.lock.eventId}>{offline.lock.eventTitle}</option>}
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
/>
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading events…</span>}
|
||||
</div>
|
||||
{sections.length > 0 && (
|
||||
@@ -418,7 +411,7 @@ export default function TicketScanningPage() {
|
||||
offline={offline}
|
||||
token={token}
|
||||
eventId={selectedEventId}
|
||||
eventTitle={events.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
|
||||
eventTitle={allEvents.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
|
||||
sectionId={selectedSectionId !== "all" ? selectedSectionId : null}
|
||||
sectionName={sections.find(s => s.id === selectedSectionId)?.name || null}
|
||||
onError={setError}
|
||||
|
||||
@@ -4,13 +4,13 @@ import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RosterSheet } from "@/components/volunteers/RosterSheet";
|
||||
import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import type { RosterEvent } from "@/lib/volunteers";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
|
||||
export default function StaffVolunteersPage() {
|
||||
@@ -48,13 +48,10 @@ export default function StaffVolunteersPage() {
|
||||
{events && !events.length && <div className="p-3 border rounded bg-amber-50 text-amber-800 text-sm">No events have volunteer rosters yet. A supervisor adds serving roles under Supervisor → Volunteer rosters.</div>}
|
||||
{events && events.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 items-end print:hidden">
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[14rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{!eventId && <option value="">Choose an event…</option>}
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-sm">
|
||||
<label className="block text-xs text-gray-500 mb-1" htmlFor="staff-volunteers-event">Event</label>
|
||||
<EventPicker id="staff-volunteers-event" className="w-full" events={events} value={eventId} onChange={setEventId} />
|
||||
</div>
|
||||
{eventId && <button onClick={() => setShowQr(q => !q)} className="px-3 py-2 rounded-lg border text-sm">{showQr ? "Show the roster" : "Show the check-in QR code"}</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
|
||||
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
|
||||
@@ -222,10 +222,8 @@ export default function AllocationsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => { setEventId(e.target.value); setSetId(""); }}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="allocations-event">Event</label>
|
||||
<EventPicker id="allocations-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); setSetId(""); }} />
|
||||
</div>
|
||||
{board && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPr
|
||||
import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
|
||||
@@ -426,18 +427,7 @@ export default function AtTheDoorPage() {
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">At The Door</h1>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
|
||||
value={eventId}
|
||||
onChange={e => setEventId(e.target.value)}
|
||||
>
|
||||
<option value="">Select event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full sm:w-72" events={events} value={eventId} onChange={setEventId} placeholder="Select event" />
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 shrink-0 self-start sm:self-auto"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { fetchApiFile, saveBlob } from "@/lib/export";
|
||||
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
|
||||
|
||||
@@ -154,13 +154,8 @@ function AttendancePageInner() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem] max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No events</option>}
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))}{ev.scheduleType === "sessions" ? ` · ${(ev.sessions || []).filter(s => !s.cancelled).length} sessions` : ""})</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="attendance-event">Event</label>
|
||||
<EventPicker id="attendance-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No events" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-reac
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
|
||||
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
|
||||
|
||||
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
|
||||
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
|
||||
@@ -56,7 +57,6 @@ const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
// fieldCls has no width, for fields sized by their row (inputCls is full width).
|
||||
const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
const inputCls = `w-full ${fieldCls}`;
|
||||
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
|
||||
|
||||
const KIND_INFO: Record<BundleKind, { label: string; hint: string; example: string }> = {
|
||||
exact: { label: "Exact set", hint: "These tickets in these numbers", example: "2 adults + 3 kids for R220" },
|
||||
@@ -92,7 +92,6 @@ export default function BundlesPage() {
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [bundles, setBundles] = useState<EventBundle[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
@@ -107,19 +106,12 @@ export default function BundlesPage() {
|
||||
.catch(e => setError(errorMessage(e) || "Failed to load events"));
|
||||
}, [token, canView, setError]);
|
||||
|
||||
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
|
||||
const events = useMemo(() => {
|
||||
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
return showPast ? [...upcoming, ...past] : upcoming;
|
||||
}, [allEvents, showPast]);
|
||||
|
||||
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
|
||||
// Start on the next upcoming event (or the latest one).
|
||||
useEffect(() => {
|
||||
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
|
||||
}, [events, eventId]);
|
||||
if (!allEvents.some(e => e.id === eventId)) setEventId(defaultEventId(allEvents));
|
||||
}, [allEvents, eventId]);
|
||||
|
||||
const event = events.find(e => e.id === eventId) || null;
|
||||
const event = allEvents.find(e => e.id === eventId) || null;
|
||||
const options = event?.eventOptions || [];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -194,17 +186,9 @@ export default function BundlesPage() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
|
||||
<div className="w-full sm:w-auto">
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="bundles-event">Event</label>
|
||||
<EventPicker id="bundles-event" className="w-full sm:min-w-[20rem]" events={allEvents} value={eventId} onChange={setEventId} placeholder="No events" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
|
||||
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{event && options.length === 0 && (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, ConsentRow, MedicalRow } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
|
||||
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
@@ -49,7 +50,7 @@ export default function ConsentTrackerPage() {
|
||||
.then(evs => {
|
||||
const youth = (evs || []).filter(e => e.requiresParentalConsent);
|
||||
setEvents(youth);
|
||||
if (!eventId && youth.length) setEventId(youth[youth.length - 1].id);
|
||||
if (!eventId && youth.length) setEventId(defaultEventId(youth));
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -149,11 +150,8 @@ export default function ConsentTrackerPage() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Youth event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No youth events</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="consent-event">Youth event</label>
|
||||
<EventPicker id="consent-event" label="Youth event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No youth events" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Show</label>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
@@ -190,16 +191,8 @@ function EmailAttendeesPageInner() {
|
||||
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
|
||||
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
|
||||
const events = useMemo(() => {
|
||||
const now = Date.now();
|
||||
if (evIncludePast) return allEvents;
|
||||
return allEvents.filter(ev => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
return !isNaN(t) && t > now;
|
||||
});
|
||||
}, [allEvents, evIncludePast]);
|
||||
// Every event; the pickers open on upcoming ones and keep past ones a click away.
|
||||
const events = allEvents;
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
@@ -544,18 +537,9 @@ function EmailAttendeesPageInner() {
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0 max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select an event" />
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
|
||||
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
@@ -740,12 +724,7 @@ function EmailAttendeesPageInner() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={e => setBroadcastEventId(e.target.value)}>
|
||||
<option value="">No event</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={broadcastEventId} onChange={setBroadcastEventId} emptyLabel="No event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1122,12 +1101,7 @@ With gratitude`);
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={e => setAutoEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{(events||[]).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={autoEventId} onChange={setAutoEventId} placeholder="Select an event" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
@@ -1219,12 +1193,7 @@ With gratitude`);
|
||||
<div className="grid sm:grid-cols-2 gap-3 mb-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={e => setPromoEventId(e.target.value)}>
|
||||
<option value="">Select event to promote…</option>
|
||||
{(events||[]).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={promoEventId} onChange={setPromoEventId} placeholder="Select event to promote" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Send at</label>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventOption } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
||||
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
||||
@@ -228,12 +229,7 @@ function EventOptionsContent() {
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-3">Select event</div>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
|
||||
{selectedEventId && (
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
{(() => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
|
||||
|
||||
@@ -493,19 +494,8 @@ export default function FormsBrowserPage() {
|
||||
<div className="text-base font-semibold mb-3">Filters</div>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 items-end">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1.5 text-sm w-full"
|
||||
value={selectedEventId}
|
||||
onChange={e => setSelectedEventId(e.target.value)}
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}{!ev.isActive ? ' (inactive)' : ''}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="forms-view-event">Event</label>
|
||||
<EventPicker id="forms-view-event" className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} emptyLabel="All events" />
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} /> Include past events
|
||||
@@ -629,18 +619,9 @@ export default function FormsBrowserPage() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-base font-semibold mb-3">Edit form structure</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-gray-600 mb-1">Select event</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1.5 text-sm w-full sm:max-w-sm"
|
||||
value={selectedEventId}
|
||||
onChange={e => { setSelectedEventId(e.target.value); setFormLoadedFor(""); }}
|
||||
>
|
||||
<option value="">Choose an event…</option>
|
||||
{allEvents.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}{!ev.isActive ? ' (inactive)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-gray-400 mt-1">All events shown (including inactive/past) so you can edit historical forms.</div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="forms-manage-event">Select event</label>
|
||||
<EventPicker id="forms-manage-event" className="w-full sm:max-w-sm" events={allEvents} value={selectedEventId} onChange={id => { setSelectedEventId(id); setFormLoadedFor(""); }} />
|
||||
<div className="text-xs text-gray-500 mt-1">Inactive and past events are included (search, or Show past events), so you can edit historical forms.</div>
|
||||
{formLoading && <span className="text-xs text-gray-400 mt-1 block">Loading form…</span>}
|
||||
</div>
|
||||
{!selectedEventId ? (
|
||||
@@ -678,10 +659,7 @@ export default function FormsBrowserPage() {
|
||||
<div className="grid sm:grid-cols-2 gap-3 mb-4 items-end">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillEventId} onChange={e => setFillEventId(e.target.value)}>
|
||||
<option value="">Select event…</option>
|
||||
{allEvents.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={allEvents} value={fillEventId} onChange={setFillEventId} placeholder="Select event" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Registration</label>
|
||||
|
||||
@@ -5,10 +5,10 @@ import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Baby, Plus, Trash2 } from "lucide-react";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
|
||||
type Settings = {
|
||||
@@ -96,13 +96,10 @@ export default function KidsSetupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="text-sm block max-w-md">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.kidsCheckIn ? " · kids check-in on" : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-sm block max-w-md">
|
||||
<label className="block text-xs text-gray-500 mb-1" htmlFor="kids-setup-event">Event</label>
|
||||
<EventPicker id="kids-setup-event" className="w-full" events={events} value={eventId} onChange={setEventId} describe={e => (e.kidsCheckIn ? "kids check-in on" : null)} />
|
||||
</div>
|
||||
|
||||
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { choosesSessions, isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -430,12 +431,7 @@ export default function ManualRegistrationPage() {
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-lg font-semibold mb-3">1) Choose event</div>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
|
||||
{selectedEventId && (
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
{(() => {
|
||||
|
||||
@@ -4,12 +4,12 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
|
||||
type Report = {
|
||||
@@ -101,10 +101,8 @@ export default function OfflineScansPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="offline-scans-event">Event</label>
|
||||
<EventPicker id="offline-scans-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 pb-2">
|
||||
<input type="checkbox" checked={onlyOpen} onChange={e => setOnlyOpen(e.target.checked)} /> Only reports that need review
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Event, Payment, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// A donation is never mutated once created — assigning it to a registration creates a separate
|
||||
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
||||
@@ -367,22 +368,20 @@ function PaymentsContent() {
|
||||
if (uid) setSelectedUserId(String(uid));
|
||||
}, [registrationId, registrations]);
|
||||
|
||||
const [eventsIncludePast, setEventsIncludePast] = useState(false);
|
||||
const [eventsIncludeInactive, setEventsIncludeInactive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const params = new URLSearchParams();
|
||||
if (eventsIncludePast) params.set("includePast", "true");
|
||||
// Past events too: the pickers open on upcoming ones and keep past ones a click away.
|
||||
const params = new URLSearchParams({ includePast: "true" });
|
||||
if (eventsIncludeInactive) params.set("includeInactive", "true");
|
||||
const qs = params.toString() ? `?${params.toString()}` : "";
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all?${params.toString()}`, { authToken: token });
|
||||
setEvents(evs || []);
|
||||
} catch {}
|
||||
})();
|
||||
}, [token, eventsIncludePast, eventsIncludeInactive]);
|
||||
}, [token, eventsIncludeInactive]);
|
||||
|
||||
useEffect(() => {
|
||||
// Keep inputs sensible when toggling donation
|
||||
@@ -665,10 +664,7 @@ function PaymentsContent() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.eventId} onChange={e => setReconcileForm(prev => ({ ...prev, eventId: e.target.value }))}>
|
||||
<option value="">Select event…</option>
|
||||
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={reconcileForm.eventId} onChange={id => setReconcileForm(prev => ({ ...prev, eventId: id }))} placeholder="Select event" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 rounded bg-emerald-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.eventId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
|
||||
@@ -722,15 +718,8 @@ function PaymentsContent() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event (for donations)</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)} disabled={!isDonation}>
|
||||
<option value="">Select event…</option>
|
||||
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select event" disabled={!isDonation} />
|
||||
<div className="flex items-center gap-4 mt-1.5 text-xs text-gray-500">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={eventsIncludePast} onChange={e => setEventsIncludePast(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
{user?.role === "admin" && (
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={eventsIncludeInactive} onChange={e => setEventsIncludeInactive(e.target.checked)} />
|
||||
|
||||
@@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RosterSheet } from "@/components/volunteers/RosterSheet";
|
||||
import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Settings = {
|
||||
event: { id: string; title: string; volunteerOptionId: string | null };
|
||||
@@ -91,13 +91,10 @@ export default function VolunteerRostersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="text-sm block max-w-md print:hidden">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.roles ? ` · ${e.roles} role${e.roles === 1 ? "" : "s"}` : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-sm block max-w-md print:hidden">
|
||||
<label className="block text-xs text-gray-500 mb-1" htmlFor="volunteers-event">Event</label>
|
||||
<EventPicker id="volunteers-event" className="w-full" events={events} value={eventId} onChange={setEventId} describe={e => (e.roles ? `${e.roles} role${e.roles === 1 ? "" : "s"}` : null)} />
|
||||
</div>
|
||||
|
||||
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import { entryLineName } from "@/lib/waitlist";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -117,11 +118,8 @@ export default function WaitlistPage() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No events with a waitlist</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="waitlist-event">Event</label>
|
||||
<EventPicker id="waitlist-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No events with a waitlist" />
|
||||
</div>
|
||||
{selected && (
|
||||
<p className="text-xs text-gray-500">
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -263,12 +264,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={(e) => setAutoEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{(events || []).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={autoEventId} onChange={setAutoEventId} placeholder="Select an event" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
@@ -328,12 +324,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
|
||||
<div className="grid sm:grid-cols-2 gap-3 mb-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={(e) => setPromoEventId(e.target.value)}>
|
||||
<option value="">Select event to promote…</option>
|
||||
{(events || []).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={promoEventId} onChange={setPromoEventId} placeholder="Select event to promote" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Send at</label>
|
||||
@@ -379,13 +370,8 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
|
||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
|
||||
const events = useMemo(() => {
|
||||
const now = Date.now();
|
||||
if (evIncludePast) return allEvents;
|
||||
return allEvents.filter((ev) => !isNaN(new Date(ev.endDate).getTime()) && new Date(ev.endDate).getTime() > now);
|
||||
}, [allEvents, evIncludePast]);
|
||||
// Every event; the pickers open on upcoming ones and keep past ones a click away.
|
||||
const events = allEvents;
|
||||
|
||||
useEffect(() => {
|
||||
const loadEvents = async () => {
|
||||
@@ -651,18 +637,9 @@ function WhatsAppAttendeesPageInner() {
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0" value={eventId} onChange={(e) => setEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? " (past)" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select an event" />
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
|
||||
<input type="checkbox" checked={evIncludePast} onChange={(e) => setEvIncludePast(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
@@ -814,12 +791,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={(e) => setBroadcastEventId(e.target.value)}>
|
||||
<option value="">No event</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={broadcastEventId} onChange={setBroadcastEventId} emptyLabel="No event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Event, PublicBundle, Registration } from "@/types";
|
||||
import { choosesSessions, isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -841,19 +842,7 @@ export default function SelfServicePage() {
|
||||
) : (
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
<option value="">— Select an event —</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title} ({fmtDate(ev.startDate)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker id="kiosk-event" className="w-full text-base" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent && (
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { BookOpen, ChevronDown, ChevronRight, Copy } from "lucide-react";
|
||||
|
||||
type EventType = { type: string; label: string; description: string; example?: unknown };
|
||||
|
||||
const RETRIES = "1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours";
|
||||
|
||||
const VERIFY_NODE = `// Node.js / Express — check the signature before trusting a request.
|
||||
const crypto = require("crypto");
|
||||
const SECRET = process.env.CROSSEVENTS_WEBHOOK_SECRET; // whsec_…
|
||||
|
||||
app.post("/crossevents-hook", express.raw({ type: "application/json" }), (req, res) => {
|
||||
const header = req.get("X-CrossEvents-Signature") || ""; // t=1767225600,v1=5f0c…
|
||||
const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")));
|
||||
const body = req.body.toString("utf8"); // the raw body, unparsed
|
||||
const expected = crypto.createHmac("sha256", SECRET).update(\`\${t}.\${body}\`).digest("hex");
|
||||
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // within 5 minutes
|
||||
if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
|
||||
return res.status(400).send("bad signature");
|
||||
}
|
||||
const event = JSON.parse(body);
|
||||
// Deliveries can repeat: skip an event.id you've already handled.
|
||||
res.sendStatus(200); // answer quickly, then do the slow work
|
||||
handle(event);
|
||||
});`;
|
||||
|
||||
const VERIFY_PHP = `<?php // PHP — check the signature before trusting a request.
|
||||
$secret = getenv('CROSSEVENTS_WEBHOOK_SECRET');
|
||||
$body = file_get_contents('php://input');
|
||||
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_CROSSEVENTS_SIGNATURE'] ?? ''), $sig);
|
||||
$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, $secret);
|
||||
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
|
||||
http_response_code(400); exit('bad signature');
|
||||
}
|
||||
$event = json_decode($body, true);
|
||||
http_response_code(200);`;
|
||||
|
||||
function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button type="button" className="inline-flex items-center gap-1 px-2 py-0.5 rounded border bg-white text-[11px] hover:bg-gray-50"
|
||||
onClick={() => { navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1500); }).catch(() => {}); }}>
|
||||
<Copy className="w-3 h-3" aria-hidden /> {done ? "Copied" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute right-2 top-2"><CopyButton text={text} /></div>
|
||||
<pre className="max-h-80 overflow-auto rounded-lg bg-gray-900 text-gray-100 p-3 pr-20 text-[11px] leading-relaxed">{text}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const H = ({ children }: { children: React.ReactNode }) => <h4 className="text-sm font-semibold text-gray-900 mt-4 mb-1.5">{children}</h4>;
|
||||
|
||||
/**
|
||||
* Site settings → Webhooks → "How webhooks work": what arrives, when, how to check it's from us,
|
||||
* and an example of every event type, for whoever builds the receiving side.
|
||||
*/
|
||||
export function WebhookGuide({ eventTypes, keepDays, defaultOpen = false }: { eventTypes: EventType[]; keepDays: number; defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [openType, setOpenType] = useState<string | null>(null);
|
||||
const [lang, setLang] = useState<"node" | "php">("node");
|
||||
const envelope = (t: EventType) => JSON.stringify({ id: "evt_3f9c2a1e-6d4b-4c8e-9a51-2b7d0e6f1c34", type: t.type, createdAt: "2026-10-29T08:16:01.512Z", data: t.example ?? {} }, null, 2);
|
||||
|
||||
return (
|
||||
<section className="border rounded-xl bg-white" data-testid="webhook-guide">
|
||||
<button type="button" aria-expanded={open} onClick={() => setOpen(o => !o)} className="w-full flex items-center gap-2 px-4 py-3 text-left">
|
||||
{open ? <ChevronDown className="w-4 h-4 text-gray-500" aria-hidden /> : <ChevronRight className="w-4 h-4 text-gray-500" aria-hidden />}
|
||||
<BookOpen className="w-4 h-4 text-brand-700" aria-hidden />
|
||||
<span className="font-medium text-gray-900">How webhooks work</span>
|
||||
<span className="text-xs text-gray-500">what is sent, the signature, and an example of each event</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-4 pb-4 text-sm text-gray-700 border-t">
|
||||
<H>What happens</H>
|
||||
<ol className="list-decimal pl-5 space-y-1">
|
||||
<li>Something happens here, such as a registration or a payment, that one of your webhooks is set to send.</li>
|
||||
<li>A second or two later, an <code>HTTP POST</code> with a JSON body goes to that webhook's address. Everything is sent from the server; nothing comes from people's browsers.</li>
|
||||
<li>Your side answers with any <code>2xx</code> status within 10 seconds, and the delivery is done.</li>
|
||||
<li>
|
||||
Anything else counts as a failure: another status, no answer, or a redirect (redirects aren't followed). The delivery is tried again after {RETRIES}, then marked <em>failed</em>.
|
||||
You can send any delivery again from its <em>Delivery log</em>.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<H>Using it without writing code</H>
|
||||
<p>
|
||||
In Zapier, use <strong>Webhooks by Zapier → Catch Hook</strong>; in Make, use <strong>Webhooks → Custom webhook</strong>. Paste the address it gives you here, choose the events, then click <em>Send test</em>. The fields in the examples below become available to map into ChurchSuite, Planning Center, a spreadsheet, Mailchimp and so on.
|
||||
</p>
|
||||
|
||||
<H>What arrives</H>
|
||||
<p className="mb-2">Every request has the same envelope. <code>data</code> depends on the type (see the examples below).</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs border rounded">
|
||||
<thead className="bg-gray-50 text-left"><tr><th className="px-2 py-1.5">Field</th><th className="px-2 py-1.5">Meaning</th></tr></thead>
|
||||
<tbody className="divide-y">
|
||||
<tr><td className="px-2 py-1.5"><code>id</code></td><td className="px-2 py-1.5">The event's id (<code>evt_…</code>). It stays the same on retries, so use it to skip events you've already handled.</td></tr>
|
||||
<tr><td className="px-2 py-1.5"><code>type</code></td><td className="px-2 py-1.5">The event type, e.g. <code>registration.paid</code>. The <em>Send test</em> button sends <code>webhook.test</code>.</td></tr>
|
||||
<tr><td className="px-2 py-1.5"><code>createdAt</code></td><td className="px-2 py-1.5">When it happened (ISO 8601, UTC).</td></tr>
|
||||
<tr><td className="px-2 py-1.5"><code>data</code></td><td className="px-2 py-1.5">The details. Amounts are in rand; dates are ISO 8601 in UTC.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 mb-2">Headers:</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs border rounded">
|
||||
<tbody className="divide-y">
|
||||
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>Content-Type</code></td><td className="px-2 py-1.5"><code>application/json</code></td></tr>
|
||||
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>User-Agent</code></td><td className="px-2 py-1.5"><code>CrossEvents-Webhooks/1</code></td></tr>
|
||||
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Event</code></td><td className="px-2 py-1.5">The event type, the same as <code>type</code>.</td></tr>
|
||||
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Delivery</code></td><td className="px-2 py-1.5">This delivery's id. Each webhook gets its own, and it's the same on retries.</td></tr>
|
||||
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Signature</code></td><td className="px-2 py-1.5"><code>t=<unix seconds>,v1=<signature></code>, explained below.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<H>Checking it came from here</H>
|
||||
<p className="mb-2">
|
||||
Anyone who knows the address could post to it, so check the signature before acting on a request. <code>v1</code> is the HMAC-SHA256, in hex, of <code>t</code>, a full stop, and the raw request body (<code><t>.<body></code>). It's keyed with the webhook's signing secret, the <code>whsec_…</code> shown once when you add the webhook or make a new secret.
|
||||
Work it out on the raw body before parsing the JSON, compare in constant time, and reject a <code>t</code> more than 5 minutes old.
|
||||
</p>
|
||||
<div className="flex gap-1 mb-2" role="group" aria-label="Example language">
|
||||
{(["node", "php"] as const).map(l => (
|
||||
<button key={l} type="button" aria-pressed={lang === l} onClick={() => setLang(l)}
|
||||
className={`px-2 py-0.5 rounded text-xs border ${lang === l ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>
|
||||
{l === "node" ? "Node.js" : "PHP"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Code text={lang === "node" ? VERIFY_NODE : VERIFY_PHP} />
|
||||
|
||||
<H>Good to know</H>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>An event can arrive more than once, for example after a retry or a manual resend, so use <code>id</code> to skip ones you've already handled.</li>
|
||||
<li>Events can arrive out of order. Use <code>createdAt</code> and the record's <code>status</code>, not the order they arrive in.</li>
|
||||
<li>Each payload is built about a second after the event, from the record as it is then, and retries send that same payload. If a registration is paid and then changed back within that second, nothing is sent.</li>
|
||||
<li>Kids check-in and volunteer tickets are never sent. A registration made only of those sends nothing, and those lines are left out of <code>items</code>.</li>
|
||||
<li>Only <code>https</code> addresses on the public internet are allowed. Each webhook's log keeps what was sent for {keepDays} days.</li>
|
||||
<li>Turning a webhook off stops new deliveries to it. Deleting it removes its log too.</li>
|
||||
</ul>
|
||||
|
||||
<H>Event types and examples</H>
|
||||
<ul className="border rounded-lg divide-y">
|
||||
{eventTypes.map(t => (
|
||||
<li key={t.type}>
|
||||
<button type="button" aria-expanded={openType === t.type} onClick={() => setOpenType(openType === t.type ? null : t.type)}
|
||||
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-gray-50">
|
||||
{openType === t.type ? <ChevronDown className="w-4 h-4 mt-0.5 text-gray-500 shrink-0" aria-hidden /> : <ChevronRight className="w-4 h-4 mt-0.5 text-gray-500 shrink-0" aria-hidden />}
|
||||
<span className="flex-1">
|
||||
<code className="text-xs font-semibold text-gray-900">{t.type}</code> <span className="text-gray-900">· {t.label}</span>
|
||||
<span className="block text-xs text-gray-500">{t.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
{openType === t.type && <div className="px-3 pb-3" data-testid={`webhook-example-${t.type}`}><Code text={envelope(t)} /></div>}
|
||||
</li>
|
||||
))}
|
||||
<li className="px-3 py-2 text-xs text-gray-600">
|
||||
<code className="font-semibold text-gray-900">webhook.test</code> · Sent by <em>Send test</em>. <code>data</code> is <code>{`{ "message": "…" }`}</code>, so your side can answer 2xx and ignore it.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown,
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { WebhookGuide } from "./WebhookGuide";
|
||||
|
||||
type EventType = { type: string; label: string; description: string };
|
||||
type EventType = { type: string; label: string; description: string; example?: unknown };
|
||||
type Endpoint = {
|
||||
id: string; name: string; url: string; events: string[]; isActive: boolean;
|
||||
lastSuccessAt: string | null; lastFailureAt: string | null; createdAt: string;
|
||||
@@ -188,7 +189,7 @@ export function WebhooksTab({ active }: { active: boolean }) {
|
||||
|
||||
const toggleEvent = (type: string) => setDraft(d => d && ({ ...d, events: d.events.includes(type) ? d.events.filter(t => t !== type) : [...d.events, type] }));
|
||||
|
||||
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-500">Loading…</p>;
|
||||
const label = (type: string) => data.eventTypes.find(t => t.type === type)?.label || type;
|
||||
|
||||
return (
|
||||
@@ -207,6 +208,8 @@ export function WebhooksTab({ active }: { active: boolean }) {
|
||||
Webhooks send personal information (names, email addresses, phone numbers) to the service you choose, so each one is added to your operator register. Make sure you have an agreement with that service. What was sent is kept {data.keepDays} days for the delivery log. Children's details from kids check-in are never sent.
|
||||
</p>
|
||||
|
||||
<WebhookGuide eventTypes={data.eventTypes} keepDays={data.keepDays} defaultOpen={data.endpoints.length === 0} />
|
||||
|
||||
{msg && <div className={`p-3 border rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`} role="status">{msg.text}</div>}
|
||||
{secret && <SecretBox secret={secret.value} onClose={() => setSecret(null)} />}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, Search } from "lucide-react";
|
||||
|
||||
// The reports sidebar's events multi-select. With many events, the search box narrows the list
|
||||
// and "Select all" picks only what matches.
|
||||
export default function EventsDropdown({ options, value, onChange }: {
|
||||
options: { value: string; label: string }[];
|
||||
// `hint`: shown beside the title (the date) and searched too.
|
||||
options: { value: string; label: string; hint?: string }[];
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,9 +23,23 @@ export default function EventsDropdown({ options, value, onChange }: {
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return options;
|
||||
return options.filter(o => {
|
||||
const hay = `${o.label} ${o.hint || ""}`.toLowerCase();
|
||||
return words.every(w => hay.includes(w));
|
||||
});
|
||||
}, [options, query]);
|
||||
|
||||
const toggle = (v: string) => {
|
||||
onChange(value.includes(v) ? value.filter(x => x !== v) : [...value, v]);
|
||||
};
|
||||
const selectShown = () => onChange(Array.from(new Set([...value, ...shown.map(o => o.value)])));
|
||||
const clearShown = () => {
|
||||
const drop = new Set(shown.map(o => o.value));
|
||||
onChange(query.trim() ? value.filter(v => !drop.has(v)) : []);
|
||||
};
|
||||
|
||||
const summary = value.length === 0
|
||||
? "No events selected"
|
||||
@@ -35,25 +53,43 @@ export default function EventsDropdown({ options, value, onChange }: {
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-label={`Events: ${summary}`}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between gap-2 border rounded-lg px-3 py-2 text-sm bg-white hover:bg-gray-50"
|
||||
>
|
||||
<span className="truncate text-left">{summary}</span>
|
||||
<ChevronDown className={"w-4 h-4 text-gray-400 shrink-0 transition-transform " + (open ? "rotate-180" : "")} />
|
||||
<ChevronDown className={"w-4 h-4 text-gray-500 shrink-0 transition-transform " + (open ? "rotate-180" : "")} aria-hidden />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 left-0 right-0 bg-white border rounded-lg shadow-lg max-h-64 overflow-auto p-1">
|
||||
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-gray-500 border-b mb-1">
|
||||
<button type="button" className="hover:underline" onClick={() => onChange(options.map(o => o.value))}>Select all</button>
|
||||
<button type="button" className="hover:underline" onClick={() => onChange([])}>Clear</button>
|
||||
<div className="absolute z-20 mt-1 left-0 right-0 min-w-[16rem] bg-white border rounded-lg shadow-lg p-1">
|
||||
<div className="flex items-center gap-2 px-2 border-b">
|
||||
<Search className="w-3.5 h-3.5 text-gray-500 shrink-0" aria-hidden />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
aria-label="Search events"
|
||||
placeholder="Search events…"
|
||||
className="flex-1 py-1.5 text-sm outline-none bg-transparent"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Escape") setOpen(false); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-gray-600 border-b mb-1">
|
||||
<button type="button" className="hover:underline" onClick={selectShown}>{query.trim() ? `Select these ${shown.length}` : "Select all"}</button>
|
||||
<button type="button" className="hover:underline" onClick={clearShown}>{query.trim() ? "Clear these" : "Clear"}</button>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
{shown.map(opt => (
|
||||
<label key={opt.value} className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
|
||||
<span className="truncate flex-1">{opt.label}</span>
|
||||
{opt.hint && <span className="shrink-0 text-xs text-gray-500">{opt.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
{shown.length === 0 && <div className="px-2 py-1.5 text-xs text-gray-500">{options.length ? "No events match." : "No events available."}</div>}
|
||||
</div>
|
||||
{options.map(opt => (
|
||||
<label key={opt.value} className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{options.length === 0 && <div className="px-2 py-1.5 text-xs text-gray-400">No events available.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
|
||||
import EventsDropdown from "./EventsDropdown";
|
||||
|
||||
type EventLite = { id: string; title: string };
|
||||
type EventLite = { id: string; title: string; startDate?: string };
|
||||
type DatePreset = "all_time" | "this_month" | "last_month" | "this_year" | "custom";
|
||||
|
||||
export default function ReportsShell({
|
||||
@@ -93,7 +93,7 @@ export default function ReportsShell({
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">Events</div>
|
||||
<EventsDropdown options={events.map(ev => ({ value: ev.id, label: ev.title }))} value={selectedEventIds} onChange={setSelectedEventIds} />
|
||||
<EventsDropdown options={events.map(ev => ({ value: ev.id, label: ev.title, hint: ev.startDate ? new Date(ev.startDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : undefined }))} value={selectedEventIds} onChange={setSelectedEventIds} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-sm">
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
|
||||
export type PickerEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate?: string | null;
|
||||
campus?: { name: string } | null;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
// Search results shown at once; typing more narrows them.
|
||||
const MAX_RESULTS = 60;
|
||||
|
||||
const shortDate = (d: string) => new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
||||
const isPast = (ev: PickerEvent, cutoff: number) => new Date(ev.endDate || ev.startDate).getTime() < cutoff;
|
||||
|
||||
/**
|
||||
* A searchable event chooser in place of a long <select>. Opens on upcoming events (soonest
|
||||
* first); past events (latest first) are one click away, and typing searches every event it
|
||||
* was given by title, campus or date. `emptyLabel` adds a "no event" choice (e.g. "All events").
|
||||
*/
|
||||
export function EventPicker<E extends PickerEvent>({ events, value, onChange, emptyLabel, placeholder = "Choose an event", label = "Event", id, className = "", disabled = false, graceHours = 0, describe }: {
|
||||
events: E[];
|
||||
value: string;
|
||||
onChange: (id: string, event: E | null) => void;
|
||||
emptyLabel?: string;
|
||||
placeholder?: string;
|
||||
// Accessible name (visually hidden unless the page gives its own <label htmlFor={id}>).
|
||||
label?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
// An event still counts as current this many hours after it ends (e.g. late scanning).
|
||||
graceHours?: number;
|
||||
// Extra detail after the date in the list, e.g. "3 roles".
|
||||
describe?: (ev: E) => string | null | undefined;
|
||||
}) {
|
||||
const autoId = useId();
|
||||
const buttonId = id || `event-picker-${autoId}`;
|
||||
const listId = `${buttonId}-list`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const selected = events.find(e => e.id === value) || null;
|
||||
|
||||
const { upcoming, past } = useMemo(() => {
|
||||
const cutoff = Date.now() - graceHours * 3600e3;
|
||||
const up: E[] = [];
|
||||
const old: E[] = [];
|
||||
for (const ev of events) (isPast(ev, cutoff) ? old : up).push(ev);
|
||||
up.sort((a, b) => +new Date(a.startDate) - +new Date(b.startDate));
|
||||
old.sort((a, b) => +new Date(b.startDate) - +new Date(a.startDate));
|
||||
return { upcoming: up, past: old };
|
||||
}, [events, graceHours]);
|
||||
|
||||
// What the list shows: [heading | event | "none"] rows. Only events and "none" are choosable.
|
||||
const { rows, hidden } = useMemo(() => {
|
||||
type Row = { kind: "head"; text: string } | { kind: "none" } | { kind: "event"; ev: E; past: boolean };
|
||||
const out: Row[] = [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q) {
|
||||
const words = q.split(/\s+/);
|
||||
const hit = (ev: E) => {
|
||||
const hay = `${ev.title} ${ev.campus?.name || ""} ${shortDate(ev.startDate)}`.toLowerCase();
|
||||
return words.every(w => hay.includes(w));
|
||||
};
|
||||
const up = upcoming.filter(hit);
|
||||
const old = past.filter(hit);
|
||||
const all = [...up.map(ev => ({ ev, past: false })), ...old.map(ev => ({ ev, past: true }))];
|
||||
all.slice(0, MAX_RESULTS).forEach(r => out.push({ kind: "event", ...r }));
|
||||
return { rows: out, hidden: Math.max(0, all.length - MAX_RESULTS) };
|
||||
}
|
||||
if (emptyLabel) out.push({ kind: "none" });
|
||||
if (upcoming.length) {
|
||||
if (past.length) out.push({ kind: "head", text: "Upcoming" });
|
||||
upcoming.forEach(ev => out.push({ kind: "event", ev, past: false }));
|
||||
}
|
||||
if (showPast && past.length) {
|
||||
out.push({ kind: "head", text: "Past" });
|
||||
past.forEach(ev => out.push({ kind: "event", ev, past: true }));
|
||||
}
|
||||
return { rows: out, hidden: 0 };
|
||||
}, [query, upcoming, past, showPast, emptyLabel]);
|
||||
|
||||
const choosable = rows.map((r, i) => (r.kind === "head" ? -1 : i)).filter(i => i >= 0);
|
||||
|
||||
// Open on the chosen event; a past one shows the past list too.
|
||||
const openList = () => {
|
||||
if (disabled) return;
|
||||
setQuery("");
|
||||
const pastPick = !!selected && past.some(e => e.id === selected.id);
|
||||
setShowPast(pastPick);
|
||||
setOpen(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
inputRef.current?.focus();
|
||||
const at = rows.findIndex(r => (r.kind === "event" ? r.ev.id === value : r.kind === "none" && !value));
|
||||
setActive(at >= 0 ? at : (choosable[0] ?? 0));
|
||||
// Only when it opens.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector(`[data-row="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [active, open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [open]);
|
||||
|
||||
const close = () => { setOpen(false); buttonRef.current?.focus(); };
|
||||
const choose = (i: number) => {
|
||||
const r = rows[i];
|
||||
if (!r || r.kind === "head") return;
|
||||
if (r.kind === "none") onChange("", null);
|
||||
else onChange(r.ev.id, r.ev);
|
||||
close();
|
||||
};
|
||||
const move = (step: number) => {
|
||||
if (!choosable.length) return;
|
||||
const at = choosable.indexOf(active);
|
||||
const next = at < 0 ? 0 : Math.min(choosable.length - 1, Math.max(0, at + step));
|
||||
setActive(choosable[next]);
|
||||
};
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
|
||||
else if (e.key === "PageDown") { e.preventDefault(); move(10); }
|
||||
else if (e.key === "PageUp") { e.preventDefault(); move(-10); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); choose(active); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); }
|
||||
else if (e.key === "Tab") setOpen(false);
|
||||
};
|
||||
|
||||
const buttonText = selected ? selected.title : (emptyLabel || placeholder);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative inline-block min-w-[16rem] max-w-full ${className}`} data-testid="event-picker">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
id={buttonId}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`${label}: ${buttonText}`}
|
||||
onClick={() => (open ? setOpen(false) : openList())}
|
||||
onKeyDown={e => { if (e.key === "ArrowDown" && !open) { e.preventDefault(); openList(); } }}
|
||||
className="w-full flex items-center gap-2 border rounded-lg px-3 py-2 text-sm bg-white text-left hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className={`flex-1 truncate ${selected ? "text-gray-900" : "text-gray-600"}`}>
|
||||
{buttonText}
|
||||
{selected && <span className="text-gray-500"> · {shortDate(selected.startDate)}</span>}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-gray-500 shrink-0" aria-hidden />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-40 mt-1 w-full min-w-[18rem] sm:min-w-[24rem] rounded-lg border bg-white shadow-lg">
|
||||
<div className="flex items-center gap-2 border-b px-3">
|
||||
<Search className="w-4 h-4 text-gray-500 shrink-0" aria-hidden />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={rows[active] && rows[active].kind !== "head" ? `${listId}-${active}` : undefined}
|
||||
aria-label={`Search ${label.toLowerCase()}s`}
|
||||
placeholder="Search by name, campus or date…"
|
||||
className="flex-1 py-2 text-sm outline-none bg-transparent"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setActive(0); }}
|
||||
onKeyDown={onKey}
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" aria-label="Clear search" className="text-gray-500 hover:text-gray-800" onClick={() => { setQuery(""); inputRef.current?.focus(); }}>
|
||||
<X className="w-4 h-4" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul ref={listRef} id={listId} role="listbox" aria-label={`${label}s`} className="max-h-72 overflow-y-auto py-1">
|
||||
{rows.map((r, i) => {
|
||||
if (r.kind === "head") {
|
||||
return <li key={`h-${r.text}`} role="presentation" className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-500">{r.text}</li>;
|
||||
}
|
||||
const isSel = r.kind === "none" ? !value : r.ev.id === value;
|
||||
return (
|
||||
<li
|
||||
key={r.kind === "none" ? "none" : r.ev.id}
|
||||
id={`${listId}-${i}`}
|
||||
data-row={i}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => choose(i)}
|
||||
onMouseMove={() => active !== i && setActive(i)}
|
||||
className={`px-3 py-1.5 text-sm cursor-pointer ${i === active ? "bg-brand-50" : ""} ${isSel ? "font-medium" : ""}`}
|
||||
>
|
||||
{r.kind === "none" ? (
|
||||
<span className="text-gray-700">{emptyLabel}</span>
|
||||
) : (
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="flex-1 min-w-0 truncate text-gray-900">{r.ev.title}</span>
|
||||
<span className="shrink-0 text-xs text-gray-600">
|
||||
{r.ev.campus?.name ? `${r.ev.campus.name} · ` : ""}{shortDate(r.ev.startDate)}{r.past && query ? " · past" : ""}{r.ev.isActive === false ? " · inactive" : ""}{describe?.(r.ev) ? ` · ${describe(r.ev)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!rows.some(r => r.kind === "event") && (
|
||||
<li role="presentation" className="px-3 py-2 text-sm text-gray-500">
|
||||
{query ? "No events match." : past.length ? "No upcoming events." : "No events."}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex items-center justify-between gap-2 border-t px-3 py-1.5 text-xs text-gray-600" aria-live="polite">
|
||||
<span>
|
||||
{query
|
||||
? `${rows.filter(r => r.kind === "event").length + hidden} match${rows.filter(r => r.kind === "event").length + hidden === 1 ? "" : "es"}${hidden ? ` · showing the first ${MAX_RESULTS}, keep typing` : ""}`
|
||||
: `${upcoming.length} upcoming${showPast ? ` · ${past.length} past` : ""}`}
|
||||
</span>
|
||||
{!query && past.length > 0 && (
|
||||
<button type="button" className="text-brand-700 hover:underline" onClick={() => { setShowPast(s => !s); inputRef.current?.focus(); }}>
|
||||
{showPast ? "Hide past events" : `Show past events (${past.length})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The event a page opens on: the next one coming up, or else the most recent. */
|
||||
export function defaultEventId(events: PickerEvent[], now = Date.now()): string {
|
||||
let next: PickerEvent | null = null;
|
||||
let last: PickerEvent | null = null;
|
||||
for (const ev of events) {
|
||||
const start = new Date(ev.startDate).getTime();
|
||||
if (!isPast(ev, now)) { if (!next || start < new Date(next.startDate).getTime()) next = ev; }
|
||||
else if (!last || start > new Date(last.startDate).getTime()) last = ev;
|
||||
}
|
||||
return (next || last)?.id || "";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone, Webhook, Send } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone, Webhook, Send, BookOpen } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -164,6 +164,9 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
<GuideItem icon={Send} title="Test and delivery log" tone="green">
|
||||
"Send test" sends a test message straight away. The delivery log shows everything sent in the last 30 days, what came back, and the full message. Anything that fails is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours; "Resend" tries again now.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BookOpen} title="How webhooks work" tone="gray">
|
||||
Open "How webhooks work" on the Webhooks tab for whoever sets up the receiving side: what is sent and when, the headers, how to check the signature (with Node.js and PHP examples), and an example message for every event type that you can copy. It opens by itself until you've added a webhook.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Scale} title="Privacy" tone="amber">
|
||||
Webhooks send names, email addresses and phone numbers, so each webhook is added to your operator register (Admin → Privacy) — have an agreement with the service. Children's details from kids check-in are never sent. Switch a webhook off or delete it at any time.
|
||||
</GuideItem>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.2",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user