@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.
- package/CHANGELOG.md +25 -0
- package/docs/mobile.md +7 -4
- 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 +274 -0
- package/mobile-template/recipes/events/recipe.json +6 -3
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +274 -0
- package/mobile-template/recipes/rsvp/recipe.json +15 -0
- package/package.json +1 -1
- package/src/components/EventPage.astro +14 -8
- package/src/layouts/Site.astro +1 -0
- package/src/lib/cache/policy.ts +2 -2
- package/src/lib/calendar/rsvp-words.ts +121 -0
- 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/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 +73 -0
- package/src/lib/mobile/schemas.ts +9 -1
- package/src/lib/mobile/site.ts +3 -1
- package/src/lib/schema.ts +2 -1
- package/src/lib/site-chrome.ts +8 -0
- package/src/pages/admin/calendar/bookings.astro +3 -1
- package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
- package/src/pages/book/manage/[token].astro +35 -24
- package/src/pages/events/[slug].astro +7 -4
- 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
|
-
(
|
|
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)
|
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,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
|
}
|