@acms-subir/core 0.3.35 → 0.3.37

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/docs/mobile.md +7 -4
  3. package/docs/theme-authoring.md +5 -4
  4. package/integration.mjs +3 -1
  5. package/mobile-template/client/AGENTS.md +2 -1
  6. package/mobile-template/client/README.md +1 -1
  7. package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
  8. package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
  9. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +274 -0
  10. package/mobile-template/recipes/events/recipe.json +6 -3
  11. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +274 -0
  12. package/mobile-template/recipes/rsvp/recipe.json +15 -0
  13. package/package.json +1 -1
  14. package/src/components/EventPage.astro +14 -8
  15. package/src/layouts/Site.astro +1 -0
  16. package/src/lib/cache/policy.ts +2 -2
  17. package/src/lib/calendar/rsvp-words.ts +121 -0
  18. package/src/lib/legal/app-privacy.ts +5 -3
  19. package/src/lib/legal/text-en.ts +1 -0
  20. package/src/lib/legal/text-es.ts +1 -0
  21. package/src/lib/mobile/bookings.ts +17 -11
  22. package/src/lib/mobile/forms.ts +21 -11
  23. package/src/lib/mobile/manage.ts +2 -0
  24. package/src/lib/mobile/openapi.ts +8 -3
  25. package/src/lib/mobile/rsvp.ts +73 -0
  26. package/src/lib/mobile/schemas.ts +9 -1
  27. package/src/lib/mobile/site.ts +3 -1
  28. package/src/lib/schema.ts +2 -1
  29. package/src/lib/site-chrome.ts +8 -0
  30. package/src/pages/admin/calendar/bookings.astro +3 -1
  31. package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
  32. package/src/pages/book/manage/[token].astro +35 -24
  33. package/src/pages/events/[slug].astro +7 -4
  34. package/src/theme-sdk/index.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.37
4
+
5
+ ### Patch Changes
6
+
7
+ - 12fdb58: RSVPs now follow the page's or the app's language, in English, Spanish, French, German and Portuguese (other languages stay in English). On `/es` event pages, and on a site whose main language is Spanish, the RSVP block still said "going", "spots left", "Save my seat", "Your name", "Email", "Seats", "Confirm my seat" or "Request a seat", "This event is full." and "RSVPs are closed." in English; now it says "van", "lugares disponibles", "Apartar mi lugar" and so on. RSVPing from an `/es` page lands on the RSVP's own page in Spanish too: the heading, status, details, Add to calendar, the cancel button, the event's title and the link back, with the date in Spanish. Room booking pages stay in English.
8
+
9
+ `GET /api/v1/events/:slug/rsvp` names the built-in button ("Confirm my seat", or "Request a seat" when the organiser approves RSVPs) in the `locale` the app asks for, or the site's main language. A button label you wrote stays as you wrote it.
10
+
11
+ The RSVP panel in the app recipes (`components/RsvpPanel.tsx`) takes every word from `useStrings()`, including screen-reader labels and the errors next to answers, so it speaks the app's language (English and Spanish built in; pass `strings` to `<AcmsProvider>` for others), with numbers in the app's format. The event's own form title, questions and button keep your words. Apps that already have the panel, from the `events` or the `rsvp` recipe, can update it with `npx acms mobile recipe add rsvp --force`. That overwrites `components/RsvpPanel.tsx`, including any changes you made to it, and needs this release's `@acms-subir/native`.
12
+
13
+ ## 0.3.36
14
+
15
+ ### Patch Changes
16
+
17
+ - 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.
18
+
19
+ - 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.
20
+ - `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.
21
+ - 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"`.
22
+ - `GET /api/v1/bookings/manage` returns `confirmation`, the message the booking's web page shows once it's confirmed.
23
+ - 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.
24
+ - 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.
25
+
26
+ - 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`.
27
+
3
28
  ## 0.3.35
4
29
 
5
30
  ### Patch Changes
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
- (expo-crypto): the same details reuse one Idempotency-Key until success,
109
- so retries never double-book. Never put the manage token, email or key in
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)
@@ -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-menu-open aria-label="Open menu">Menu</button>
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
- const params = useLocalSearchParams<{ slug: string; start?: string; end?: string; allDay?: string }>();
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
- <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 760, alignSelf: 'center' }}>
21
- <Stack.Screen options={{ title: event.title }} />
22
- {image && <Image source={{ uri: image }} accessibilityLabel={event.imageAlt} style={{ width: '100%', aspectRatio: 16 / 9, borderRadius: t.radius, backgroundColor: t.colors.soft }} />}
23
- <EventDate {...occurrence} timezone={event.timezone} style={{ color: t.colors.accent }} />
24
- <Text accessibilityRole="header" style={t.styles.text.display}>{event.title}</Text>
25
- {event.cancelled && <Text style={t.styles.text.heading}>This event is cancelled.</Text>}
26
- {!!event.repeats && <Text style={t.styles.text.small}>{event.repeats}</Text>}
27
- {!!(event.location.name || event.location.address) && (
28
- <View style={{ gap: 2 }}>
29
- {!!event.location.name && <Text style={t.styles.text.body}>{event.location.name}</Text>}
30
- {!!event.location.address && <Text style={t.styles.text.muted}>{event.location.address}</Text>}
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
- {!!event.excerpt && <Text style={t.styles.text.body}>{event.excerpt}</Text>}
34
- <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
35
- {!event.cancelled && <Button label="Add to calendar" onPress={() => addToCalendar(event, occurrence)} />}
36
- {cta && <Button label={event.cta.label} variant="outline" onPress={() => Linking.openURL(cta)} />}
37
- <Button label="Open on website" variant="outline" onPress={() => Linking.openURL(event.url)} />
38
- </View>
39
- </ScrollView>
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
- <Text style={[t.styles.text.heading, o.event.cancelled && { textDecorationLine: 'line-through' }]}>{o.event.title}</Text>
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.event.cancelled && <Text style={[t.styles.text.label, { color: t.colors.ink }]}>Cancelled</Text>}
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,274 @@
1
+ import { Fragment, 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, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, 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. Its words come from
15
+ // useStrings() in the app's language; the event's own form title, labels and
16
+ // button win when the site has them.
17
+
18
+ // The RSVP's private manage token, one per event date, lives only in secure
19
+ // storage (never a URL or log). Keys allow letters, digits, ".", "-" and "_".
20
+ // Web has no secure storage: there the confirmation email's link is the way back.
21
+ const tokenKey = (eventId: string, start: string) => `rsvp.${eventId}.${start}`.replace(/[^A-Za-z0-9._-]/g, '_');
22
+ const keychain = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
23
+ const tokens = {
24
+ get: async (key: string) => (Platform.OS === 'web' ? null : SecureStore.getItemAsync(key, keychain).catch(() => null)),
25
+ set: async (key: string, token: string) => { if (Platform.OS !== 'web') await SecureStore.setItemAsync(key, token, keychain).catch(() => {}); },
26
+ remove: async (key: string) => { if (Platform.OS !== 'web') await SecureStore.deleteItemAsync(key, keychain).catch(() => {}); },
27
+ };
28
+
29
+ type Answers = Record<string, string | string[]>;
30
+ const OTHER = '__other__';
31
+ const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
32
+ // "Other" choices send the typed text instead
33
+ const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
34
+ if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
35
+ return value === OTHER ? other.trim() : (value ?? '');
36
+ };
37
+ // a count in bold inside its words ("{count} going")
38
+ const bold = (template: string, n: string) => template.split('{count}').map((part, i) => (i ? <Fragment key={i}><Text style={{ fontWeight: '700' }}>{n}</Text>{part}</Fragment> : part));
39
+
40
+ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
41
+ const t = useTheme();
42
+ const words = useStrings();
43
+ const { locale } = useLocale();
44
+ const { data: site } = useSite();
45
+ const guest = useGuest();
46
+ const status = useRsvpStatus(slug, start);
47
+ const rsvp = useRsvp(slug, start, { createKey: Crypto.randomUUID });
48
+ const s = status.data;
49
+ const key = s ? tokenKey(s.event.id, s.start) : null;
50
+ // undefined until secure storage has answered
51
+ const [stored, setStored] = useState<string | null | undefined>(undefined);
52
+ const changes = site?.features.bookingChanges === true;
53
+ const managed = useManagedBooking(changes ? stored : null);
54
+ const cancel = useCancelBooking(stored);
55
+ const [confirming, setConfirming] = useState(false);
56
+ const [notice, setNotice] = useState('');
57
+ const [name, setName] = useState('');
58
+ const [email, setEmail] = useState('');
59
+ const [phone, setPhone] = useState('');
60
+ const [seats, setSeats] = useState(1);
61
+ const [values, setValues] = useState<Answers>({});
62
+ const [others, setOthers] = useState<Record<string, string>>({});
63
+ const [tried, setTried] = useState(false);
64
+
65
+ useEffect(() => {
66
+ if (!key) return;
67
+ let live = true;
68
+ void tokens.get(key).then((token) => { if (live) setStored(token); });
69
+ return () => { live = false; };
70
+ }, [key]);
71
+ // signed in for "My bookings": that's their email (guest sign-in knows no name)
72
+ useEffect(() => {
73
+ if (site?.features.guestSignIn && guest.email) setEmail((e) => e || guest.email!);
74
+ }, [site?.features.guestSignIn, guest.email]);
75
+ // cancelled (here, from the email link or by the organiser) or gone: forget the token
76
+ const gone = managed.data?.booking.status === 'cancelled' || (isAcmsError(managed.error) && managed.error.status === 404);
77
+ useEffect(() => {
78
+ if (!key || !stored || !gone) return;
79
+ void tokens.remove(key);
80
+ setStored(null);
81
+ setConfirming(false);
82
+ rsvp.reset();
83
+ if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
84
+ }, [key, stored, gone]);
85
+
86
+ if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
87
+ 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;
88
+ 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 });
89
+ const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
90
+ const num = (n: number) => n.toLocaleString(locale);
91
+ const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
92
+ const going = (
93
+ <Text style={t.styles.text.body}>
94
+ {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
95
+ {s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
96
+ </Text>
97
+ );
98
+
99
+ // ---- their RSVP: just sent, or kept from before ----
100
+ const mine = managed.data && managed.data.booking.status !== 'cancelled' ? managed.data : null;
101
+ const made = rsvp.data?.booking;
102
+ if (stored === undefined || (stored && changes && managed.isPending && !made)) return <ActivityIndicator color={t.colors.accent} />;
103
+ // offline or busy: say so rather than offer a second RSVP
104
+ if (stored && changes && managed.error && !gone && !made) return (
105
+ <View style={[t.styles.card, { gap: 8 }]}>
106
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{words.yourRsvp}</Text>
107
+ <Text style={t.styles.text.muted}>{words.loadError}</Text>
108
+ <Button label={words.tryAgain} variant="outline" onPress={() => void managed.refetch()} />
109
+ </View>
110
+ );
111
+ if ((stored && mine) || made) {
112
+ const state = mine?.booking.status ?? made!.status;
113
+ const who = mine?.booking.email ?? made!.email;
114
+ const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
115
+ const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
116
+ return (
117
+ <View style={[t.styles.card, { gap: 8 }]}>
118
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
119
+ <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
120
+ {seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
121
+ </Text>
122
+ <Text style={t.styles.text.body}>
123
+ {state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
124
+ : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
125
+ </Text>
126
+ {state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
127
+ {canCancel && state !== 'declined' && (confirming ? (
128
+ <View style={{ gap: 8 }}>
129
+ <Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
130
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
131
+ <Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
132
+ <Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
133
+ </View>
134
+ </View>
135
+ ) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
136
+ {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
137
+ {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
138
+ </View>
139
+ );
140
+ }
141
+
142
+ if (s.reason === 'disabled') return null;
143
+ if (!s.open) return (
144
+ <View style={[t.styles.card, { gap: 6 }]}>
145
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
146
+ {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
147
+ </View>
148
+ );
149
+
150
+ // ---- the form ----
151
+ const form = s.form;
152
+ const fields = form?.fields ?? [];
153
+ const answers: Answers = Object.fromEntries(fields.filter((f) => f.type !== 'heading').map((f) => [f.id, withOther(f, values[f.id], others[f.id] ?? '')]));
154
+ const problems = answerProblems(fields, answers);
155
+ const nameProblem = !name.trim() ? words.nameProblem : '';
156
+ const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
157
+ // the form may ask for a phone itself
158
+ const askPhone = !fields.some((f) => f.type === 'phone');
159
+ const chosen = Math.min(seats, s.maxSeats) || 1;
160
+ const submit = () => {
161
+ setTried(true);
162
+ setNotice('');
163
+ if (nameProblem || emailProblem || Object.keys(problems).length) return;
164
+ rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers }, {
165
+ onSuccess: (data) => { if (key) void tokens.set(key, data.manageToken); setStored(data.manageToken); },
166
+ });
167
+ };
168
+ const set = (id: string, v: string | string[]) => setValues((a) => ({ ...a, [id]: v }));
169
+ const label = (text: string, required: boolean) => (
170
+ <Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel={words.required}> *</Text> : ''}</Text>
171
+ );
172
+ 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 } = {}) => (
173
+ <View key={lbl} style={{ gap: 6 }}>
174
+ {label(lbl, !!o.required)}
175
+ {!!o.help && <Text style={t.styles.text.small}>{o.help}</Text>}
176
+ <TextInput value={value} onChangeText={onChange} keyboardType={o.keyboard} multiline={o.multiline} autoComplete={o.auto} placeholder={o.placeholder}
177
+ autoCapitalize={o.auto === 'email' || o.keyboard === 'email-address' || o.keyboard === 'url' ? 'none' : 'sentences'} placeholderTextColor={t.colors.muted}
178
+ accessibilityLabel={lbl} accessibilityHint={o.problem}
179
+ style={[input, o.multiline && { minHeight: 96, textAlignVertical: 'top' }, !!o.problem && { borderColor: t.colors.ink }]} />
180
+ {!!o.problem && <Text style={errorText}>{o.problem}</Text>}
181
+ </View>
182
+ );
183
+ const field = (f: FormField) => {
184
+ const problem = tried && problems[f.id] ? answerProblemText(f, problems[f.id], words) : '';
185
+ if (f.type === 'heading') return (
186
+ <View key={f.id} style={{ gap: 2, paddingTop: 4 }}>
187
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{f.label}</Text>
188
+ {!!f.help && <Text style={t.styles.text.muted}>{f.help}</Text>}
189
+ </View>
190
+ );
191
+ if (f.type === 'checkbox' || f.type === 'consent') return (
192
+ <View key={f.id} style={{ gap: 6 }}>
193
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
194
+ <Text style={[t.styles.text.body, { flex: 1 }]}>{f.label}{f.required ? ' *' : ''}</Text>
195
+ <Switch accessibilityLabel={f.label} value={values[f.id] === 'yes'} onValueChange={(v) => set(f.id, v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} />
196
+ </View>
197
+ {!!f.help && <Text style={t.styles.text.small}>{f.help}</Text>}
198
+ {!!problem && <Text style={errorText}>{problem}</Text>}
199
+ </View>
200
+ );
201
+ if (f.type === 'select' || f.type === 'radio' || f.type === 'checkboxes') {
202
+ const many = f.type === 'checkboxes';
203
+ const current = values[f.id];
204
+ const isOn = (o: string) => (many ? Array.isArray(current) && current.includes(o) : current === o);
205
+ const toggle = (o: string) => {
206
+ if (!many) return set(f.id, current === o && !f.required ? '' : o);
207
+ const list = Array.isArray(current) ? current : [];
208
+ set(f.id, list.includes(o) ? list.filter((x) => x !== o) : [...list, o]);
209
+ };
210
+ const choices = f.allowOther && f.type !== 'select' ? [...f.options, OTHER] : f.options;
211
+ return (
212
+ <View key={f.id} style={{ gap: 6 }}>
213
+ {label(f.label, f.required)}
214
+ {!!f.help && <Text style={t.styles.text.small}>{f.help}</Text>}
215
+ <View accessibilityRole={many ? undefined : 'radiogroup'} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
216
+ {choices.map((o) => (
217
+ <Pressable key={o} accessibilityRole={many ? 'checkbox' : 'radio'} accessibilityState={{ checked: isOn(o) }} onPress={() => toggle(o)} style={chip(isOn(o))}>
218
+ <Text style={[t.styles.text.body, isOn(o) && { fontWeight: '600' }]}>{o === OTHER ? words.other : o}</Text>
219
+ </Pressable>
220
+ ))}
221
+ </View>
222
+ {isOn(OTHER) && (
223
+ <TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder={words.other} accessibilityLabel={fill(words.otherAnswer, { label: f.label })}
224
+ placeholderTextColor={t.colors.muted} style={input} />
225
+ )}
226
+ {!!problem && <Text style={errorText}>{problem}</Text>}
227
+ </View>
228
+ );
229
+ }
230
+ return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
231
+ required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
232
+ placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
233
+ });
234
+ };
235
+
236
+ const error = rsvp.error;
237
+ return (
238
+ <View style={[t.styles.card, { gap: 16 }]}>
239
+ <View style={{ gap: 4 }}>
240
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
241
+ {going}
242
+ {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
243
+ {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
244
+ </View>
245
+ {text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
246
+ {text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
247
+ {askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
248
+ {s.maxSeats > 1 && (
249
+ <View style={{ gap: 6 }}>
250
+ {label(words.seatsField, false)}
251
+ <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
252
+ {seatChoices(s).map((n) => (
253
+ <Pressable key={n} accessibilityRole="radio" accessibilityLabel={seatsText(n)} accessibilityState={{ checked: n === chosen }} onPress={() => setSeats(n)} style={[chip(n === chosen), { minWidth: 48, alignItems: 'center' }]}>
254
+ <Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
255
+ </Pressable>
256
+ ))}
257
+ </View>
258
+ </View>
259
+ )}
260
+ {fields.map(field)}
261
+ {error && (
262
+ <View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
263
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
264
+ <Text style={t.styles.text.muted}>
265
+ {isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
266
+ </Text>
267
+ </View>
268
+ )}
269
+ {tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
270
+ <Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
271
+ <Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
272
+ </View>
273
+ );
274
+ }
@@ -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
  }