@acms-subir/core 0.3.34 → 0.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/cli/migrations.generated.mjs +7 -1
- package/docs/mobile.md +9 -6
- package/docs/theme-authoring.md +5 -4
- package/integration.mjs +3 -1
- package/mobile-template/client/AGENTS.md +2 -1
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
- package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +270 -0
- package/mobile-template/recipes/events/recipe.json +6 -3
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +270 -0
- package/mobile-template/recipes/rsvp/recipe.json +15 -0
- package/package.json +1 -1
- package/src/components/Announcements.tsx +14 -1
- package/src/components/LiveCard.astro +23 -10
- package/src/components/MobileApp.tsx +54 -4
- package/src/layouts/Site.astro +45 -38
- package/src/lib/announcements/api.ts +8 -4
- package/src/lib/announcements/store.ts +4 -0
- package/src/lib/cache/policy.ts +2 -2
- package/src/lib/calendar/hooks.ts +5 -1
- package/src/lib/legal/app-privacy.ts +5 -3
- package/src/lib/legal/text-en.ts +1 -0
- package/src/lib/legal/text-es.ts +1 -0
- package/src/lib/live/view.ts +28 -24
- package/src/lib/live/words.ts +94 -0
- package/src/lib/mobile/bookings.ts +17 -11
- package/src/lib/mobile/forms.ts +21 -11
- package/src/lib/mobile/manage.ts +2 -0
- package/src/lib/mobile/openapi.ts +8 -3
- package/src/lib/mobile/rsvp.ts +65 -0
- package/src/lib/mobile/schemas.ts +9 -1
- package/src/lib/mobile/site.ts +3 -1
- package/src/lib/push/api.ts +13 -1
- package/src/lib/push/service.ts +211 -24
- package/src/lib/push/store.ts +35 -8
- package/src/lib/push/types.ts +23 -2
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/schema.ts +2 -1
- package/src/lib/search-i18n.ts +41 -4
- package/src/lib/site-chrome.ts +8 -0
- package/src/lib/site-words.ts +62 -0
- package/src/pages/admin/calendar/bookings.astro +3 -1
- package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
- package/src/styles/mobile-app.css +7 -0
- package/src/theme-sdk/index.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.36
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- fe964b1: Apps can RSVP to events on any site with the calendar on, not only on sites with bookings, with the same rules as the event's page: open until the date ends, capacity per date, and RSVPs the organiser approves stay pending until then.
|
|
8
|
+
|
|
9
|
+
- New `GET /api/v1/events/:slug/rsvp?start=…` (the date's start exactly as `/api/v1/events` lists it): whether a date takes RSVPs (or why not: turned off, cancelled, ended or full), the seats going and left right now, how many seats one RSVP may take, and the event's questions with their version. It's never cached. `site.features.rsvp` says it's there.
|
|
10
|
+
- `POST /api/v1/bookings` takes RSVPs on calendar-only sites; room bookings still need bookings on. An RSVP to an event with questions must send that `formVersion`, so changed questions answer 409 as on the website, and checkbox groups can send their answers as a list.
|
|
11
|
+
- Bookings and RSVPs from apps are recorded as coming from the app: the booking details in the admin say "App" (and "Website", "Staff", "API" or "Agent" for the others), and webhooks carry `source: "app"`.
|
|
12
|
+
- `GET /api/v1/bookings/manage` returns `confirmation`, the message the booking's web page shows once it's confirmed.
|
|
13
|
+
- The App store answers in Settings → Legal list RSVPs among what the app collects whenever the calendar is on, and the privacy notice says the app keeps an RSVP's or booking's private key in the phone's secure storage.
|
|
14
|
+
- RSVPs in the app recipes: `components/RsvpPanel.tsx` shows going and spots left, seats, the event's questions with inline errors, then the person's RSVP with a way to cancel it (its private token stays in `expo-secure-store`), or why a date is full or closed. It fills in the email of someone signed in for My bookings. New apps get it on the events recipe's event screen (which now also installs `expo-crypto` and `expo-secure-store`); an app with its own event screen runs `npx acms mobile recipe add rsvp` and mounts `<RsvpPanel slug start />`. The events list strikes through a single cancelled date of a repeating event too.
|
|
15
|
+
|
|
16
|
+
- d06240a: Theme header and footer templates get the page's language and core's own labels: `site.lang` and `site.words` (`main`, `openMenu`, `closeMenu`, `scheme`, `language`, `social`, `menu`, `skip`, `read`, `site`, `privacy`, `terms`, …) in English, Spanish, French, German or Portuguese, so a custom header's buttons and landmarks read in the visitor's language like the built-in one. `siteStrings(lang)` and the `SiteStrings` type are exported from `@acms-subir/core/theme`.
|
|
17
|
+
|
|
18
|
+
## 0.3.35
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 85f91a1: A new automatic notification in **Admin → Mobile app**: **When an event changes**, off unless you choose calendars (all public calendars or some, like "Starting soon"). People hear when a published event's next date or time moves ("Now Sun, Oct 26, 10:30 AM (was 9:00 AM)"), its place changes ("New place: Fellowship Hall"), the whole event is cancelled, or a date of a repeating event is cancelled ("Cancelled on Sun, Oct 26", which opens that date). It goes out in each phone's language (English, Spanish, French, German or Portuguese, with the event's translated title) 10 minutes after the last edit, compared with what people last knew, so several quick edits send one notice and an edit undone in time sends none. When the date it touches starts within 3 hours, it waits only a minute, and it isn't sent once that date has started (for a move, once the new time has started).
|
|
23
|
+
|
|
24
|
+
- **Only events tagged** narrows the rule to events with one of a few tags (any case), and **Send to** picks topics instead of the event's own (Events and its calendar), for example classes tagged "Class" in a shared rooms calendar, sent to a site topic `classes`.
|
|
25
|
+
- Every way of changing an event counts: the editor's Update, dragging on the schedule, cancelling a date, the API and agents. Other edits, restoring a date, un-cancelling, past events and events in private or unlisted calendars never notify.
|
|
26
|
+
- No double push: the change notice is skipped when an announcement whose button opens the same event went up with a push in the last hour, and the announcement composer says when an automatic change notice for the event it links to went out in the last 2 hours.
|
|
27
|
+
- While the rule sends to the event's own topics, the Events topic is called "Events and changes" in apps (also in Spanish, French, German and Portuguese).
|
|
28
|
+
- Taps open the event as before; `data.change` says what changed and `source` is `change`. Migration 59.
|
|
29
|
+
|
|
30
|
+
- 6b83f1e: The website's built-in words now follow the page's language, in English, Spanish, French, German and Portuguese (other languages stay in English). On `/es` pages, and on a site whose main language is Spanish, these were still in English: the live bar, header marker and floating player; the live video card, including its times ("empezó hace 5 min", "en 3 h") and the updates it shows while the page is open; the skip link; the menu and dark-mode buttons; the footer's Read and Site columns; the automatic Privacy, Terms, Search, Home and Links labels; the code copy button; and the gallery's close button. Screen-reader labels are translated too. Live times use the page's language. Search also comes in French, German and Portuguese now. Menu items you created keep their own labels.
|
|
31
|
+
|
|
3
32
|
## 0.3.34
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
|
@@ -1105,7 +1105,13 @@ var MIGRATIONS = [
|
|
|
1105
1105
|
...contentVersionTriggers("settings", [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"])
|
|
1106
1106
|
] },
|
|
1107
1107
|
// announcements show on public pages, the banner and the app API
|
|
1108
|
-
{ version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers("announcements", ANNOUNCEMENT_VISIBLE_COLUMNS)] }
|
|
1108
|
+
{ version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers("announcements", ANNOUNCEMENT_VISIBLE_COLUMNS)] },
|
|
1109
|
+
// event-change notices (PushAuto.eventChanges) wait in push_queue until the
|
|
1110
|
+
// edits settle: payload = the schedule people last knew, due_at = send time
|
|
1111
|
+
{ version: 59, statements: [
|
|
1112
|
+
`ALTER TABLE push_queue ADD COLUMN payload TEXT`,
|
|
1113
|
+
`ALTER TABLE push_queue ADD COLUMN due_at INTEGER`
|
|
1114
|
+
] }
|
|
1109
1115
|
];
|
|
1110
1116
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1111
1117
|
...CONTENT_VERSION_COLUMNS,
|
package/docs/mobile.md
CHANGED
|
@@ -105,20 +105,22 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
105
105
|
Every screen needs loading, error (`<ErrorView error retry>`) and empty states,
|
|
106
106
|
and pull to refresh (`useRefresh()`).
|
|
107
107
|
13. **Bookings are idempotent.** Use `useBooking({ createKey: randomUUID })`
|
|
108
|
-
(
|
|
109
|
-
|
|
108
|
+
and `useRsvp(slug, start, { createKey: randomUUID })` (expo-crypto): the
|
|
109
|
+
same details reuse one Idempotency-Key until success, so retries never
|
|
110
|
+
double-book. Never put the manage token, email or key in
|
|
110
111
|
a URL, log or analytics; keep the manage token in `expo-secure-store`.
|
|
111
112
|
|
|
112
113
|
## Hooks at a glance
|
|
113
114
|
|
|
114
115
|
| Hook | For |
|
|
115
116
|
| --- | --- |
|
|
116
|
-
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`) |
|
|
117
|
+
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`) |
|
|
117
118
|
| `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
|
|
118
119
|
| `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
|
|
119
120
|
| `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
|
|
120
121
|
| `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90). `from`/`to` take a site-local day (`'2026-10-01'`) or an ISO date-time with a zone (`new Date().toISOString()`) |
|
|
121
122
|
| `useEvent(slug)` | one event |
|
|
123
|
+
| `useRsvpStatus(slug, start)` / `useRsvp(slug, start)` | RSVP to one date (`start` exactly as `useEvents` returns it, a UTC ISO string) when `event.rsvp.enabled`: `open` or `reason` (`full`, `ended`, `cancelled`, `disabled`), live `going`/`left`, `maxSeats`, the questions in `form`; `answerProblems(form.fields, answers)` for inline errors. Needs only the calendar (`features.rsvp`). Keep `manageToken` in `expo-secure-store` to show or cancel the RSVP later (`useManagedBooking`, `useCancelBooking`) |
|
|
122
124
|
| `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
|
|
123
125
|
| `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
|
|
124
126
|
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
|
|
@@ -140,7 +142,8 @@ Ready-made screens; add them instead of writing from scratch:
|
|
|
140
142
|
|
|
141
143
|
```sh
|
|
142
144
|
npx acms mobile recipe list
|
|
143
|
-
npx acms mobile recipe add events # list, detail, add to calendar (expo-calendar)
|
|
145
|
+
npx acms mobile recipe add events # list, detail with RSVP, add to calendar (expo-calendar, expo-crypto, expo-secure-store)
|
|
146
|
+
npx acms mobile recipe add rsvp # just the RSVP panel, for your own event screen: <RsvpPanel slug start /> (expo-crypto, expo-secure-store)
|
|
144
147
|
npx acms mobile recipe add news # posts list, article
|
|
145
148
|
npx acms mobile recipe add booking # resources → slots → form → confirmation (expo-crypto)
|
|
146
149
|
npx acms mobile recipe add push # notifications: turn on, topic toggles, taps open the page (expo-notifications, expo-device)
|
|
@@ -272,8 +275,8 @@ keyed by `locale`.
|
|
|
272
275
|
## Push notifications
|
|
273
276
|
|
|
274
277
|
Optional; the owner sends them from **Admin → Mobile app** (manual messages
|
|
275
|
-
plus automatic ones: new post, new event, "starting soon",
|
|
276
|
-
app only registers and chooses topics; it never sends.
|
|
278
|
+
plus automatic ones: new post, new event, "starting soon", event changes,
|
|
279
|
+
"we're live"). The app only registers and chooses topics; it never sends.
|
|
277
280
|
|
|
278
281
|
1. Site: `acms({ mobile: { push: true } })` in `astro.config.mjs` (off by
|
|
279
282
|
default; `/api/v1/devices` and `/api/v1/push/topics` 404 until then).
|
package/docs/theme-authoring.md
CHANGED
|
@@ -61,7 +61,7 @@ Options default to `{ limit: 6, horizonDays: 400 }`. The limit must be a non-neg
|
|
|
61
61
|
|
|
62
62
|
Template filenames: `home.astro`, `event.astro`, `header.astro`, `footer.astro`, `post-<name>.astro`, `page-<name>.astro`.
|
|
63
63
|
|
|
64
|
-
`header.astro` and `footer.astro` replace the site header and footer on every public page. They receive `ChromeProps` — one `site` prop (`SiteChrome`) with everything already resolved for the request: `title`, `tagline`, `logo`, `logoDark`, `homeUrl`, `links` (header nav, each `{ label, url, current, external }`, already localized), `footerLinks`, `social` (with inline SVG `icon`), `readLinks`, `footerHtml`, `languages` (`{ code, label, url, current }`, where `label` is the language's own name, e.g. "Español"), `search` (`{ url, label }` or null), `schemeToggle`, `liveHeader`, `headerStyle`, `footerStyle`. Core behaviors attach through data hooks, so keep them on your own elements: `data-search-open` (opens search), `data-scheme-toggle` (light/dark), `data-menu-open` (phone menu sheet, built from `site.links`) and a hidden `data-live-header` link (shown during a live stream).
|
|
64
|
+
`header.astro` and `footer.astro` replace the site header and footer on every public page. They receive `ChromeProps` — one `site` prop (`SiteChrome`) with everything already resolved for the request: `title`, `tagline`, `logo`, `logoDark`, `homeUrl`, `links` (header nav, each `{ label, url, current, external }`, already localized), `footerLinks`, `social` (with inline SVG `icon`), `readLinks`, `footerHtml`, `languages` (`{ code, label, url, current }`, where `label` is the language's own name, e.g. "Español"), `search` (`{ url, label }` or null), `schemeToggle`, `liveHeader`, `headerStyle`, `footerStyle`, `lang` (the page's language, as on `<html lang>`) and `words`: core's own labels in that language (English, Spanish, French, German or Portuguese, otherwise English), so your controls read the same as the built-in header for people using screen readers: `skip`, `main`, `menu`, `language`, `social`, `openMenu`, `closeMenu`, `scheme`, `read`, `site`, `privacy`, `terms`, `home`, `links`, `copy`, `copied`, `close`. `siteStrings(lang)` from `@acms-subir/core/theme` returns the same words for any language. Core behaviors attach through data hooks, so keep them on your own elements: `data-search-open` (opens search), `data-scheme-toggle` (light/dark), `data-menu-open` (phone menu sheet, built from `site.links`) and a hidden `data-live-header` link (shown during a live stream).
|
|
65
65
|
|
|
66
66
|
Owners upload **Logo (dark mode)** beside Logo in Design → Brand and publish it with the design. `site.logoDark` is an empty string when unset; use the regular logo as the fallback. The built-in header/footer and the `SiteLogo` component below switch without image filters, following the site’s forced scheme, visitor toggle, or OS preference. Custom markup can use `html[data-scheme="dark"]` / `html[data-scheme="light"]`, falling back to `prefers-color-scheme` when the attribute is absent. `/api/v1/site` also returns `logoDark` as an absolute URL (or `""` when unset).
|
|
67
67
|
|
|
@@ -73,10 +73,11 @@ const { site } = Astro.props as ChromeProps;
|
|
|
73
73
|
---
|
|
74
74
|
<header class="gh-head">
|
|
75
75
|
<a class="brand" href={site.homeUrl}><SiteLogo title={site.title} logo={site.logo} logoDark={site.logoDark} /></a>
|
|
76
|
-
<nav>{site.links.map((l) => <a href={l.url} aria-current={l.current ? 'page' : undefined}>{l.label}</a>)}</nav>
|
|
77
|
-
{site.languages.map((l) => <a href={l.url} lang={l.code} aria-current={l.current ? 'true' : undefined}>{l.label}</a>)}
|
|
76
|
+
<nav aria-label={site.words.main}>{site.links.map((l) => <a href={l.url} aria-current={l.current ? 'page' : undefined}>{l.label}</a>)}</nav>
|
|
77
|
+
<span role="group" aria-label={site.words.language}>{site.languages.map((l) => <a href={l.url} lang={l.code} aria-current={l.current ? 'true' : undefined}>{l.label}</a>)}</span>
|
|
78
78
|
{site.search && <a href={site.search.url} data-search-open>{site.search.label}</a>}
|
|
79
|
-
<button type="button" data-
|
|
79
|
+
{site.schemeToggle && <button type="button" data-scheme-toggle aria-label={site.words.scheme}>◐</button>}
|
|
80
|
+
<button type="button" data-menu-open aria-label={site.words.openMenu}>{site.words.menu}</button>
|
|
80
81
|
</header>
|
|
81
82
|
``` Post/page variants are selected in the content editor. Core owns routes, auth, SEO and published-content queries. Templates only replace their documented content area. Never query private data from a public section or insert untrusted strings with set:html. The core's rendered `html`/`bodyHtml` is the intended rich-content handoff.
|
|
82
83
|
|
package/integration.mjs
CHANGED
|
@@ -834,6 +834,9 @@ export default function acms(options = {}) {
|
|
|
834
834
|
['/embed/calendar/[slug]', 'embed/calendar/[slug].astro'],
|
|
835
835
|
['/api/v1/events', 'api/v1/events.ts'],
|
|
836
836
|
['/api/v1/calendars', 'api/v1/calendars.ts'],
|
|
837
|
+
// RSVPs from apps: a date's status, then POST /bookings (room bookings there need bookings on)
|
|
838
|
+
['/api/v1/events/[slug]/rsvp', 'api/v1/events/[slug]/rsvp.ts'],
|
|
839
|
+
['/api/v1/bookings', 'api/v1/bookings.ts'],
|
|
837
840
|
['/api/calendar/events', 'api/calendar/events/index.ts'],
|
|
838
841
|
['/api/calendar/calendars', 'api/calendar/calendars.ts'],
|
|
839
842
|
['/admin/calendar', 'admin/calendar/index.astro'],
|
|
@@ -866,7 +869,6 @@ export default function acms(options = {}) {
|
|
|
866
869
|
['/embed/book/[resource]/[link]', 'embed/book/[resource]/[link].astro'],
|
|
867
870
|
['/api/v1/resources', 'api/v1/resources.ts'],
|
|
868
871
|
['/api/v1/availability', 'api/v1/availability.ts'],
|
|
869
|
-
['/api/v1/bookings', 'api/v1/bookings.ts'],
|
|
870
872
|
['/api/calendar/availability', 'api/calendar/availability.ts'],
|
|
871
873
|
['/api/calendar/bookings', 'api/calendar/bookings/index.ts'],
|
|
872
874
|
['/api/calendar/bookings/[id]', 'api/calendar/bookings/[id].ts'],
|
|
@@ -72,7 +72,8 @@ Add ready-made screens from the site folder instead of writing them from scratch
|
|
|
72
72
|
|
|
73
73
|
```sh
|
|
74
74
|
npx acms mobile recipe list
|
|
75
|
-
npx acms mobile recipe add events # events list, detail, add to calendar
|
|
75
|
+
npx acms mobile recipe add events # events list, detail with RSVP, add to calendar
|
|
76
|
+
npx acms mobile recipe add rsvp # just the RSVP panel for your own event screen: <RsvpPanel slug start />
|
|
76
77
|
npx acms mobile recipe add news # posts list and article
|
|
77
78
|
npx acms mobile recipe add booking # resources → slots → form → confirmation
|
|
78
79
|
npx acms mobile recipe add push # notifications: turn on, topics, taps (site needs push on)
|
|
@@ -28,7 +28,7 @@ site) and `.env.local` (written by `acms mobile dev`, not committed).
|
|
|
28
28
|
## Add screens
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
npx acms mobile recipe add events # also: news, booking, push
|
|
31
|
+
npx acms mobile recipe add events # also: news, booking, push, rsvp, announcements
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Build your own with the hooks from `@acms-subir/native`. `AGENTS.md` has the
|
|
@@ -1,41 +1,69 @@
|
|
|
1
|
-
import { ActivityIndicator, Image, Linking, ScrollView, Text, View } from 'react-native';
|
|
1
|
+
import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Platform, ScrollView, Text, View } from 'react-native';
|
|
2
2
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
3
|
-
import { Button, ErrorView, EventDate, resolveUrl, useAcmsContext, useEvent, useLayout, useTheme } from '@acms-subir/native';
|
|
3
|
+
import { Button, ErrorView, EventDate, resolveUrl, useAcmsContext, useEvent, useEvents, useLayout, useTheme } from '@acms-subir/native';
|
|
4
4
|
import { addToCalendar } from '../../lib/addToCalendar';
|
|
5
|
+
import { RsvpPanel } from '../../components/RsvpPanel';
|
|
5
6
|
|
|
6
7
|
export default function EventScreen() {
|
|
7
8
|
const t = useTheme();
|
|
8
9
|
const { baseUrl } = useAcmsContext();
|
|
9
10
|
const { gutter } = useLayout();
|
|
10
|
-
|
|
11
|
+
// `on`: the website's ?on=YYYY-MM-DD, when a shared link opens the app
|
|
12
|
+
const params = useLocalSearchParams<{ slug: string; start?: string; end?: string; allDay?: string; cancelled?: string; on?: string }>();
|
|
11
13
|
// Search from the chosen occurrence's day so later occurrences are found too.
|
|
12
14
|
const from = params.start ? params.start.slice(0, 10) : undefined;
|
|
13
15
|
const { data: event, error, isPending, refetch } = useEvent(params.slug, from ? { from } : {});
|
|
14
16
|
if (error && !event) return <ErrorView error={error} retry={refetch} />;
|
|
15
17
|
if (isPending || !event) return <ActivityIndicator style={{ marginTop: 48 }} color={t.colors.accent} />;
|
|
16
18
|
const occurrence = { start: params.start ?? event.start, end: params.end ?? event.end, allDay: params.start ? params.allDay === '1' : event.allDay };
|
|
19
|
+
// a single date of a repeating event can be cancelled on its own
|
|
20
|
+
const dateCancelled = params.start ? params.cancelled === '1' : false;
|
|
21
|
+
const cancelled = event.cancelled || dateCancelled;
|
|
17
22
|
const image = resolveUrl(event.image, baseUrl);
|
|
18
23
|
const cta = event.cta.label ? resolveUrl(event.cta.href, baseUrl) : undefined;
|
|
19
24
|
return (
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
|
26
|
+
<ScrollView keyboardShouldPersistTaps="handled" style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 760, alignSelf: 'center' }}>
|
|
27
|
+
<Stack.Screen options={{ title: event.title }} />
|
|
28
|
+
{image && <Image source={{ uri: image }} accessibilityLabel={event.imageAlt} style={{ width: '100%', aspectRatio: 16 / 9, borderRadius: t.radius, backgroundColor: t.colors.soft }} />}
|
|
29
|
+
<EventDate {...occurrence} timezone={event.timezone} style={[{ color: t.colors.accent }, dateCancelled && { textDecorationLine: 'line-through' }]} />
|
|
30
|
+
<Text accessibilityRole="header" style={t.styles.text.display}>{event.title}</Text>
|
|
31
|
+
{event.cancelled ? <Text style={t.styles.text.heading}>This event is cancelled.</Text> : dateCancelled && <Text style={t.styles.text.heading}>This date is cancelled.</Text>}
|
|
32
|
+
{!!event.repeats && <Text style={t.styles.text.small}>{event.repeats}</Text>}
|
|
33
|
+
{!!(event.location.name || event.location.address) && (
|
|
34
|
+
<View style={{ gap: 2 }}>
|
|
35
|
+
{!!event.location.name && <Text style={t.styles.text.body}>{event.location.name}</Text>}
|
|
36
|
+
{!!event.location.address && <Text style={t.styles.text.muted}>{event.location.address}</Text>}
|
|
37
|
+
</View>
|
|
38
|
+
)}
|
|
39
|
+
{!!event.excerpt && <Text style={t.styles.text.body}>{event.excerpt}</Text>}
|
|
40
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
|
|
41
|
+
{!cancelled && <Button label="Add to calendar" onPress={() => addToCalendar(event, occurrence)} />}
|
|
42
|
+
{cta && !event.rsvp?.enabled && <Button label={event.cta.label} variant="outline" onPress={() => Linking.openURL(cta)} />}
|
|
43
|
+
<Button label="Open on website" variant="outline" onPress={() => Linking.openURL(event.url)} />
|
|
31
44
|
</View>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
{/* RSVP replaces the event's button, as on the website */}
|
|
46
|
+
{event.rsvp?.enabled && !cancelled && (params.start ? <RsvpPanel slug={event.slug} start={params.start} /> : <NextDateRsvp slug={event.slug} from={params.on} repeats={!!event.rrule} />)}
|
|
47
|
+
</ScrollView>
|
|
48
|
+
</KeyboardAvoidingView>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const day = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
53
|
+
// Opened without a date (a shared link or a push): RSVP to its next date in the coming 60 days
|
|
54
|
+
// (named, for a repeating event).
|
|
55
|
+
function NextDateRsvp({ slug, from, repeats }: { slug: string; from?: string; repeats: boolean }) {
|
|
56
|
+
const t = useTheme();
|
|
57
|
+
const first = from && /^\d{4}-\d{2}-\d{2}$/.test(from) ? from : day(new Date());
|
|
58
|
+
const to = new Date(`${first}T12:00:00`);
|
|
59
|
+
to.setDate(to.getDate() + 60);
|
|
60
|
+
const { data } = useEvents({ from: first, to: day(to), limit: 50 });
|
|
61
|
+
const next = data?.occurrences.find((o) => o.event.slug === slug && !o.cancelled && Date.parse(o.end) > Date.now());
|
|
62
|
+
if (!next) return null;
|
|
63
|
+
return (
|
|
64
|
+
<View style={{ gap: 8 }}>
|
|
65
|
+
{repeats && <EventDate start={next.start} end={next.end} allDay={next.allDay} timezone={next.event.timezone} style={{ color: t.colors.accent }} />}
|
|
66
|
+
<RsvpPanel slug={slug} start={next.start} />
|
|
67
|
+
</View>
|
|
40
68
|
);
|
|
41
69
|
}
|
|
@@ -33,13 +33,14 @@ export default function EventsScreen() {
|
|
|
33
33
|
renderItem={({ item: o }) => (
|
|
34
34
|
<Pressable
|
|
35
35
|
accessibilityRole="button"
|
|
36
|
-
onPress={() => router.push({ pathname: '/events/[slug]', params: { slug: o.event.slug, start: o.start, end: o.end, allDay: o.allDay ? '1' : '' } })}
|
|
36
|
+
onPress={() => router.push({ pathname: '/events/[slug]', params: { slug: o.event.slug, start: o.start, end: o.end, allDay: o.allDay ? '1' : '', cancelled: o.cancelled ? '1' : '' } })}
|
|
37
37
|
style={({ pressed }) => [t.styles.card, { flex: 1, gap: 4, opacity: pressed ? 0.85 : 1 }]}
|
|
38
38
|
>
|
|
39
39
|
<EventDate start={o.start} end={o.end} allDay={o.allDay} timezone={o.event.timezone} style={{ color: t.colors.accent }} />
|
|
40
|
-
|
|
40
|
+
{/* o.cancelled: the whole event or just this date */}
|
|
41
|
+
<Text style={[t.styles.text.heading, o.cancelled && { textDecorationLine: 'line-through' }]}>{o.event.title}</Text>
|
|
41
42
|
{!!o.event.location.name && <Text style={t.styles.text.small}>{o.event.location.name}</Text>}
|
|
42
|
-
{o.
|
|
43
|
+
{o.cancelled && <Text style={[t.styles.text.label, { color: t.colors.ink }]}>Cancelled</Text>}
|
|
43
44
|
</Pressable>
|
|
44
45
|
)}
|
|
45
46
|
ListFooterComponent={<View style={{ height: 24 }} />}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
|
|
3
|
+
import * as Crypto from 'expo-crypto';
|
|
4
|
+
import * as SecureStore from 'expo-secure-store';
|
|
5
|
+
import { isAcmsError, type FormField } from '@acms-subir/client';
|
|
6
|
+
import { Button, answerProblems, seatChoices, useCancelBooking, useGuest, useManagedBooking, useRsvp, useRsvpStatus, useSite, useTheme } from '@acms-subir/native';
|
|
7
|
+
|
|
8
|
+
// RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
|
|
9
|
+
// with `start` exactly as useEvents returns it. Shows going and spots left,
|
|
10
|
+
// seats, contact details and the event's questions, then the person's RSVP
|
|
11
|
+
// with a way to cancel it; or why the date takes no RSVPs. Same rules as the
|
|
12
|
+
// event's web page: open until the date ends, and the organiser may approve
|
|
13
|
+
// each RSVP. useRsvp keeps one idempotency key per RSVP, so sending again
|
|
14
|
+
// after a timeout can't register anyone twice.
|
|
15
|
+
|
|
16
|
+
// The RSVP's private manage token, one per event date, lives only in secure
|
|
17
|
+
// storage (never a URL or log). Keys allow letters, digits, ".", "-" and "_".
|
|
18
|
+
// Web has no secure storage: there the confirmation email's link is the way back.
|
|
19
|
+
const tokenKey = (eventId: string, start: string) => `rsvp.${eventId}.${start}`.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
20
|
+
const keychain = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
|
|
21
|
+
const tokens = {
|
|
22
|
+
get: async (key: string) => (Platform.OS === 'web' ? null : SecureStore.getItemAsync(key, keychain).catch(() => null)),
|
|
23
|
+
set: async (key: string, token: string) => { if (Platform.OS !== 'web') await SecureStore.setItemAsync(key, token, keychain).catch(() => {}); },
|
|
24
|
+
remove: async (key: string) => { if (Platform.OS !== 'web') await SecureStore.deleteItemAsync(key, keychain).catch(() => {}); },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Answers = Record<string, string | string[]>;
|
|
28
|
+
const OTHER = '__other__';
|
|
29
|
+
const problemText = (f: Pick<FormField, 'type'>, problem: 'required' | 'invalid') => problem === 'required' ? 'Please answer this.'
|
|
30
|
+
: f.type === 'email' ? 'Enter an email address.' : f.type === 'number' ? 'Enter a number.' : f.type === 'phone' ? 'Enter a phone number.'
|
|
31
|
+
: f.type === 'date' ? 'Use YYYY-MM-DD, for example 2026-10-05.' : 'Check this answer.';
|
|
32
|
+
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
|
|
33
|
+
// "Other" choices send the typed text instead
|
|
34
|
+
const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
|
|
35
|
+
if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
|
|
36
|
+
return value === OTHER ? other.trim() : (value ?? '');
|
|
37
|
+
};
|
|
38
|
+
const seatsText = (n: number) => (n === 1 ? '1 seat' : `${n} seats`);
|
|
39
|
+
|
|
40
|
+
export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
41
|
+
const t = useTheme();
|
|
42
|
+
const { data: site } = useSite();
|
|
43
|
+
const guest = useGuest();
|
|
44
|
+
const status = useRsvpStatus(slug, start);
|
|
45
|
+
const rsvp = useRsvp(slug, start, { createKey: Crypto.randomUUID });
|
|
46
|
+
const s = status.data;
|
|
47
|
+
const key = s ? tokenKey(s.event.id, s.start) : null;
|
|
48
|
+
// undefined until secure storage has answered
|
|
49
|
+
const [stored, setStored] = useState<string | null | undefined>(undefined);
|
|
50
|
+
const changes = site?.features.bookingChanges === true;
|
|
51
|
+
const managed = useManagedBooking(changes ? stored : null);
|
|
52
|
+
const cancel = useCancelBooking(stored);
|
|
53
|
+
const [confirming, setConfirming] = useState(false);
|
|
54
|
+
const [notice, setNotice] = useState('');
|
|
55
|
+
const [name, setName] = useState('');
|
|
56
|
+
const [email, setEmail] = useState('');
|
|
57
|
+
const [phone, setPhone] = useState('');
|
|
58
|
+
const [seats, setSeats] = useState(1);
|
|
59
|
+
const [values, setValues] = useState<Answers>({});
|
|
60
|
+
const [others, setOthers] = useState<Record<string, string>>({});
|
|
61
|
+
const [tried, setTried] = useState(false);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!key) return;
|
|
65
|
+
let live = true;
|
|
66
|
+
void tokens.get(key).then((token) => { if (live) setStored(token); });
|
|
67
|
+
return () => { live = false; };
|
|
68
|
+
}, [key]);
|
|
69
|
+
// signed in for "My bookings": that's their email (guest sign-in knows no name)
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (site?.features.guestSignIn && guest.email) setEmail((e) => e || guest.email!);
|
|
72
|
+
}, [site?.features.guestSignIn, guest.email]);
|
|
73
|
+
// cancelled (here, from the email link or by the organiser) or gone: forget the token
|
|
74
|
+
const gone = managed.data?.booking.status === 'cancelled' || (isAcmsError(managed.error) && managed.error.status === 404);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!key || !stored || !gone) return;
|
|
77
|
+
void tokens.remove(key);
|
|
78
|
+
setStored(null);
|
|
79
|
+
setConfirming(false);
|
|
80
|
+
rsvp.reset();
|
|
81
|
+
if (managed.data?.booking.status === 'cancelled') setNotice('Your RSVP is cancelled.');
|
|
82
|
+
}, [key, stored, gone]);
|
|
83
|
+
|
|
84
|
+
if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
|
|
85
|
+
const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
|
|
86
|
+
const chip = (on: boolean) => ({ paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel });
|
|
87
|
+
const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
|
|
88
|
+
const going = (
|
|
89
|
+
<Text style={t.styles.text.body}>
|
|
90
|
+
<Text style={{ fontWeight: '700' }}>{s.going}</Text> going
|
|
91
|
+
{s.left !== null && <> · <Text style={{ fontWeight: '700' }}>{s.left}</Text> {s.left === 1 ? 'spot' : 'spots'} left</>}
|
|
92
|
+
</Text>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// ---- their RSVP: just sent, or kept from before ----
|
|
96
|
+
const mine = managed.data && managed.data.booking.status !== 'cancelled' ? managed.data : null;
|
|
97
|
+
const made = rsvp.data?.booking;
|
|
98
|
+
if (stored === undefined || (stored && changes && managed.isPending && !made)) return <ActivityIndicator color={t.colors.accent} />;
|
|
99
|
+
// offline or busy: say so rather than offer a second RSVP
|
|
100
|
+
if (stored && changes && managed.error && !gone && !made) return (
|
|
101
|
+
<View style={[t.styles.card, { gap: 8 }]}>
|
|
102
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>Your RSVP</Text>
|
|
103
|
+
<Text style={t.styles.text.muted}>Couldn’t load it right now.</Text>
|
|
104
|
+
<Button label="Try again" variant="outline" onPress={() => void managed.refetch()} />
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
if ((stored && mine) || made) {
|
|
108
|
+
const state = mine?.booking.status ?? made!.status;
|
|
109
|
+
const who = mine?.booking.email ?? made!.email;
|
|
110
|
+
const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
|
|
111
|
+
const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
|
|
112
|
+
return (
|
|
113
|
+
<View style={[t.styles.card, { gap: 8 }]}>
|
|
114
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? 'Your RSVP' : state === 'confirmed' ? 'Confirmed' : 'Request sent'}</Text>
|
|
115
|
+
<Text style={[t.styles.text.label, { color: t.colors.ink }]}>
|
|
116
|
+
{seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? 'Confirmed' : state === 'pending' ? 'Waiting for approval' : 'Not confirmed'}
|
|
117
|
+
</Text>
|
|
118
|
+
<Text style={t.styles.text.body}>
|
|
119
|
+
{state === 'confirmed' ? thanks || `A confirmation is on its way to ${who}.`
|
|
120
|
+
: state === 'pending' ? `We’ll email ${who} once it’s approved.` : 'Sorry, the organiser couldn’t confirm it.'}
|
|
121
|
+
</Text>
|
|
122
|
+
{state === 'declined' && <Button label="RSVP again" variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
|
|
123
|
+
{canCancel && state !== 'declined' && (confirming ? (
|
|
124
|
+
<View style={{ gap: 8 }}>
|
|
125
|
+
<Text style={t.styles.text.body}>Cancel your RSVP for {seatsText(mine!.booking.seats)}?</Text>
|
|
126
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
|
|
127
|
+
<Button label={cancel.isPending ? 'Cancelling…' : 'Yes, cancel it'} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
|
|
128
|
+
<Button label="Keep it" variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
|
|
129
|
+
</View>
|
|
130
|
+
</View>
|
|
131
|
+
) : <Button label="Cancel my RSVP" variant="outline" onPress={() => setConfirming(true)} />)}
|
|
132
|
+
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? 'Check your connection and try again.' : cancel.error.message}</Text>}
|
|
133
|
+
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>The confirmation email has a link to change or cancel it.</Text>}
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (s.reason === 'disabled') return null;
|
|
139
|
+
if (!s.open) return (
|
|
140
|
+
<View style={[t.styles.card, { gap: 6 }]}>
|
|
141
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? 'Full' : s.reason === 'cancelled' ? 'This date is cancelled' : 'RSVPs are closed'}</Text>
|
|
142
|
+
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? 'Nobody needs to RSVP for it.' : 'This date has already happened.'}</Text>}
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// ---- the form ----
|
|
147
|
+
const form = s.form;
|
|
148
|
+
const fields = form?.fields ?? [];
|
|
149
|
+
const answers: Answers = Object.fromEntries(fields.filter((f) => f.type !== 'heading').map((f) => [f.id, withOther(f, values[f.id], others[f.id] ?? '')]));
|
|
150
|
+
const problems = answerProblems(fields, answers);
|
|
151
|
+
const nameProblem = !name.trim() ? 'Add your name.' : '';
|
|
152
|
+
const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? 'Add a valid email address.' : '';
|
|
153
|
+
// the form may ask for a phone itself
|
|
154
|
+
const askPhone = !fields.some((f) => f.type === 'phone');
|
|
155
|
+
const chosen = Math.min(seats, s.maxSeats) || 1;
|
|
156
|
+
const submit = () => {
|
|
157
|
+
setTried(true);
|
|
158
|
+
setNotice('');
|
|
159
|
+
if (nameProblem || emailProblem || Object.keys(problems).length) return;
|
|
160
|
+
rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers }, {
|
|
161
|
+
onSuccess: (data) => { if (key) void tokens.set(key, data.manageToken); setStored(data.manageToken); },
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
const set = (id: string, v: string | string[]) => setValues((a) => ({ ...a, [id]: v }));
|
|
165
|
+
const label = (text: string, required: boolean) => (
|
|
166
|
+
<Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel="required"> *</Text> : ''}</Text>
|
|
167
|
+
);
|
|
168
|
+
const text = (lbl: string, value: string, onChange: (v: string) => void, o: { required?: boolean; keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel'; placeholder?: string; help?: string; problem?: string } = {}) => (
|
|
169
|
+
<View key={lbl} style={{ gap: 6 }}>
|
|
170
|
+
{label(lbl, !!o.required)}
|
|
171
|
+
{!!o.help && <Text style={t.styles.text.small}>{o.help}</Text>}
|
|
172
|
+
<TextInput value={value} onChangeText={onChange} keyboardType={o.keyboard} multiline={o.multiline} autoComplete={o.auto} placeholder={o.placeholder}
|
|
173
|
+
autoCapitalize={o.auto === 'email' || o.keyboard === 'email-address' || o.keyboard === 'url' ? 'none' : 'sentences'} placeholderTextColor={t.colors.muted}
|
|
174
|
+
accessibilityLabel={lbl} accessibilityHint={o.problem}
|
|
175
|
+
style={[input, o.multiline && { minHeight: 96, textAlignVertical: 'top' }, !!o.problem && { borderColor: t.colors.ink }]} />
|
|
176
|
+
{!!o.problem && <Text style={errorText}>{o.problem}</Text>}
|
|
177
|
+
</View>
|
|
178
|
+
);
|
|
179
|
+
const field = (f: FormField) => {
|
|
180
|
+
const problem = tried && problems[f.id] ? problemText(f, problems[f.id]) : '';
|
|
181
|
+
if (f.type === 'heading') return (
|
|
182
|
+
<View key={f.id} style={{ gap: 2, paddingTop: 4 }}>
|
|
183
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{f.label}</Text>
|
|
184
|
+
{!!f.help && <Text style={t.styles.text.muted}>{f.help}</Text>}
|
|
185
|
+
</View>
|
|
186
|
+
);
|
|
187
|
+
if (f.type === 'checkbox' || f.type === 'consent') return (
|
|
188
|
+
<View key={f.id} style={{ gap: 6 }}>
|
|
189
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
190
|
+
<Text style={[t.styles.text.body, { flex: 1 }]}>{f.label}{f.required ? ' *' : ''}</Text>
|
|
191
|
+
<Switch accessibilityLabel={f.label} value={values[f.id] === 'yes'} onValueChange={(v) => set(f.id, v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} />
|
|
192
|
+
</View>
|
|
193
|
+
{!!f.help && <Text style={t.styles.text.small}>{f.help}</Text>}
|
|
194
|
+
{!!problem && <Text style={errorText}>{problem}</Text>}
|
|
195
|
+
</View>
|
|
196
|
+
);
|
|
197
|
+
if (f.type === 'select' || f.type === 'radio' || f.type === 'checkboxes') {
|
|
198
|
+
const many = f.type === 'checkboxes';
|
|
199
|
+
const current = values[f.id];
|
|
200
|
+
const isOn = (o: string) => (many ? Array.isArray(current) && current.includes(o) : current === o);
|
|
201
|
+
const toggle = (o: string) => {
|
|
202
|
+
if (!many) return set(f.id, current === o && !f.required ? '' : o);
|
|
203
|
+
const list = Array.isArray(current) ? current : [];
|
|
204
|
+
set(f.id, list.includes(o) ? list.filter((x) => x !== o) : [...list, o]);
|
|
205
|
+
};
|
|
206
|
+
const choices = f.allowOther && f.type !== 'select' ? [...f.options, OTHER] : f.options;
|
|
207
|
+
return (
|
|
208
|
+
<View key={f.id} style={{ gap: 6 }}>
|
|
209
|
+
{label(f.label, f.required)}
|
|
210
|
+
{!!f.help && <Text style={t.styles.text.small}>{f.help}</Text>}
|
|
211
|
+
<View accessibilityRole={many ? undefined : 'radiogroup'} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
212
|
+
{choices.map((o) => (
|
|
213
|
+
<Pressable key={o} accessibilityRole={many ? 'checkbox' : 'radio'} accessibilityState={{ checked: isOn(o) }} onPress={() => toggle(o)} style={chip(isOn(o))}>
|
|
214
|
+
<Text style={[t.styles.text.body, isOn(o) && { fontWeight: '600' }]}>{o === OTHER ? 'Other' : o}</Text>
|
|
215
|
+
</Pressable>
|
|
216
|
+
))}
|
|
217
|
+
</View>
|
|
218
|
+
{isOn(OTHER) && (
|
|
219
|
+
<TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder="Other" accessibilityLabel={`${f.label}: other`}
|
|
220
|
+
placeholderTextColor={t.colors.muted} style={input} />
|
|
221
|
+
)}
|
|
222
|
+
{!!problem && <Text style={errorText}>{problem}</Text>}
|
|
223
|
+
</View>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
|
|
227
|
+
required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
|
|
228
|
+
placeholder: f.placeholder || (f.type === 'date' ? 'YYYY-MM-DD' : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const error = rsvp.error;
|
|
233
|
+
return (
|
|
234
|
+
<View style={[t.styles.card, { gap: 16 }]}>
|
|
235
|
+
<View style={{ gap: 4 }}>
|
|
236
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || 'Save your seat'}</Text>
|
|
237
|
+
{going}
|
|
238
|
+
{!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
|
|
239
|
+
{!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
|
|
240
|
+
</View>
|
|
241
|
+
{text('Your name', name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
|
|
242
|
+
{text('Email', email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
|
|
243
|
+
{askPhone && text('Phone (optional)', phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
|
|
244
|
+
{s.maxSeats > 1 && (
|
|
245
|
+
<View style={{ gap: 6 }}>
|
|
246
|
+
{label('Seats', false)}
|
|
247
|
+
<View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
248
|
+
{seatChoices(s).map((n) => (
|
|
249
|
+
<Pressable key={n} accessibilityRole="radio" accessibilityLabel={seatsText(n)} accessibilityState={{ checked: n === chosen }} onPress={() => setSeats(n)} style={[chip(n === chosen), { minWidth: 48, alignItems: 'center' }]}>
|
|
250
|
+
<Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{n}</Text>
|
|
251
|
+
</Pressable>
|
|
252
|
+
))}
|
|
253
|
+
</View>
|
|
254
|
+
</View>
|
|
255
|
+
)}
|
|
256
|
+
{fields.map(field)}
|
|
257
|
+
{error && (
|
|
258
|
+
<View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
|
|
259
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>That didn’t go through.</Text>
|
|
260
|
+
<Text style={t.styles.text.muted}>
|
|
261
|
+
{isAcmsError(error) && error.status === 0 ? 'Check your connection and try again. You won’t be registered twice.' : error.message}
|
|
262
|
+
</Text>
|
|
263
|
+
</View>
|
|
264
|
+
)}
|
|
265
|
+
{tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>Check the answers marked above.</Text>}
|
|
266
|
+
<Button label={rsvp.isPending ? 'Sending…' : form?.submitLabel || (s.autoConfirm ? 'Confirm my seat' : 'Request a seat')} disabled={rsvp.isPending} onPress={submit} />
|
|
267
|
+
<Text style={t.styles.text.small}>Your details go only to the organiser of this event.</Text>
|
|
268
|
+
</View>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"kind": "client",
|
|
3
3
|
"title": "Events",
|
|
4
|
-
"description": "Upcoming events list, event detail and add to calendar (with a share fallback).",
|
|
4
|
+
"description": "Upcoming events list, event detail with RSVP (seats, the event's questions, confirmation) and add to calendar (with a share fallback).",
|
|
5
5
|
"packages": [
|
|
6
|
-
"expo-calendar"
|
|
6
|
+
"expo-calendar",
|
|
7
|
+
"expo-crypto",
|
|
8
|
+
"expo-secure-store"
|
|
7
9
|
],
|
|
8
10
|
"routes": [
|
|
9
11
|
"/events",
|
|
@@ -11,6 +13,7 @@
|
|
|
11
13
|
],
|
|
12
14
|
"next": [
|
|
13
15
|
"Link to it from a screen: <Link href=\"/events\">Events</Link> (expo-router)",
|
|
14
|
-
"Add to calendar needs a development build (npx expo run:ios / run:android); in Expo Go it falls back to sharing"
|
|
16
|
+
"Add to calendar needs a development build (npx expo run:ios / run:android); in Expo Go it falls back to sharing",
|
|
17
|
+
"RSVP (components/RsvpPanel.tsx) shows on events with RSVPs turned on and needs only the calendar, not bookings. It keeps each RSVP's private manage token in expo-secure-store, so people can see and cancel their RSVP in the app as well as from the confirmation email"
|
|
15
18
|
]
|
|
16
19
|
}
|