@acms-subir/core 0.3.36 → 0.3.38
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 +21 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/theme-authoring.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +54 -47
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +54 -47
- package/mobile-template/recipes/rsvp/recipe.json +2 -1
- package/package.json +1 -1
- package/src/components/EventPage.astro +50 -30
- package/src/lib/booking/emails.ts +59 -44
- package/src/lib/booking/service.ts +22 -11
- package/src/lib/calendar/cards.ts +5 -3
- package/src/lib/calendar/event-words.ts +171 -0
- package/src/lib/calendar/events.ts +5 -2
- package/src/lib/calendar/render.ts +3 -1
- package/src/lib/calendar/rrule.ts +14 -18
- package/src/lib/calendar/rsvp-words.ts +121 -0
- package/src/lib/email/booking-words.ts +203 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/mobile/bookings.ts +9 -2
- package/src/lib/mobile/calendar.ts +6 -4
- package/src/lib/mobile/guest.ts +3 -2
- package/src/lib/mobile/manage.ts +7 -5
- package/src/lib/mobile/rsvp.ts +9 -1
- package/src/lib/mobile/schemas.ts +2 -0
- package/src/lib/mobile/search.ts +1 -1
- package/src/lib/mobile/site.ts +7 -3
- package/src/lib/push/service.ts +10 -0
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +4 -4
- package/src/lib/repo/types.ts +2 -1
- package/src/lib/schema.ts +3 -0
- package/src/pages/book/[resource]/[link].astro +8 -4
- package/src/pages/book/manage/[token].astro +48 -29
- package/src/pages/events/[slug].astro +8 -5
- package/src/theme-sdk/index.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.38
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8d85520: `GET /api/v1/site?locale=es` returns the menu the way the `/es` header shows it: links to the Spanish pages, and the automatic menu's own words translated ("Inicio", "Enlaces"). Labels you wrote in Navigation stay as written. Events in the app API carry `notify.changes`, the push topics that hear when the event's date, time or place changes (Admin → Mobile app → When an event changes), or `[]`, so an app only promises "we'll let you know if it changes" when it's true. The RSVP panel recipe takes `children`, shown under the person's RSVP.
|
|
8
|
+
- 7b71457: Event pages now follow the page'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, a cancelled date said "Cancelled on … Other dates go ahead as usual." in English; now it says "Cancelado el domingo, 25 de octubre de 2026. Las demás fechas siguen como siempre.", so the "Cancelado el …" push opens a page that matches it. The same goes for a cancelled event, the "Cancelado" flag on event cards and in the calendar's pop-up, "Todo el día", the repeat line ("Todos los domingos", "El primer domingo de cada mes", "Cada 2 semanas, los lunes, 6 veces"), Agregar al calendario, Descargar .ics, Compartir and Enlace copiado, Más eventos, Ver el calendario completo, the map's labels and "Mostrando el …". Links on a `/es` event page (more events, the calendar, sharing) stay in Spanish. Theme event templates can use the same words with `eventStrings` and `describeRepeat` from `@acms-subir/core/theme`.
|
|
9
|
+
|
|
10
|
+
Booking and RSVP emails now speak the language the person used: the page's on the website (RSVPs from `/es` event pages, and bookings from `/es/book/…`, whose pages stay in English), the app's `locale` on `POST /api/v1/bookings`, and the site's main language for bookings staff add. The subject, message, details, dates and button are in that language, RSVPs show the event's translated title and place, and every link (the booking's own page, the calendar file) keeps it, such as `/es/book/manage/…`. Spanish emails say _usted_, like the privacy notice. In the app API, `manageUrl` (and the event's `url` from `GET /api/v1/bookings/manage`) points to the page in that language too. Staff notifications stay in the main language. Older bookings, and backups made before this release, use the main language. On a site whose main language isn't English, booking emails without a language (and staff notifications) now come in that language instead of English. Migration 60 adds `bookings.locale` (default `''`, the main language).
|
|
11
|
+
|
|
12
|
+
A room booking's own page (`/book/manage/…`, the page booking emails link to) now follows the page's language too, like an RSVP's: "Tu reservación está confirmada", Cambiar horario, Cancelar reservación and so on. Opened without `/es`, it shows the main language.
|
|
13
|
+
|
|
14
|
+
## 0.3.37
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
20
|
+
`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.
|
|
21
|
+
|
|
22
|
+
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`.
|
|
23
|
+
|
|
3
24
|
## 0.3.36
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
|
@@ -1111,6 +1111,11 @@ var MIGRATIONS = [
|
|
|
1111
1111
|
{ version: 59, statements: [
|
|
1112
1112
|
`ALTER TABLE push_queue ADD COLUMN payload TEXT`,
|
|
1113
1113
|
`ALTER TABLE push_queue ADD COLUMN due_at INTEGER`
|
|
1114
|
+
] },
|
|
1115
|
+
// the language a booking or RSVP was made in (Booking.locale), so its
|
|
1116
|
+
// emails and links follow it; '' = the main language, so older rows need no backfill
|
|
1117
|
+
{ version: 60, statements: [
|
|
1118
|
+
`ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`
|
|
1114
1119
|
] }
|
|
1115
1120
|
];
|
|
1116
1121
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
package/docs/theme-authoring.md
CHANGED
|
@@ -32,7 +32,7 @@ const { page, posts } = Astro.props;
|
|
|
32
32
|
{posts.map(post => <PostCard href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} />)}
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
|
|
35
|
+
Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. For an event template, `eventStrings(lang)` gives the built-in event page's words (Cancelled, Add to calendar, Share, …) and `describeRepeat(event.rrule, event.start, formatDate?, lang)` its repeat line ("Weekly on Sunday", "Todos los domingos"), in English, Spanish, French, German or Portuguese, otherwise English. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
|
|
36
36
|
|
|
37
37
|
### Upcoming events in coded templates
|
|
38
38
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { Fragment, useEffect, useState, type ReactNode } from 'react';
|
|
2
2
|
import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
|
|
3
3
|
import * as Crypto from 'expo-crypto';
|
|
4
4
|
import * as SecureStore from 'expo-secure-store';
|
|
5
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';
|
|
6
|
+
import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
7
7
|
|
|
8
8
|
// RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
|
|
9
9
|
// with `start` exactly as useEvents returns it. Shows going and spots left,
|
|
@@ -11,7 +11,9 @@ import { Button, answerProblems, seatChoices, useCancelBooking, useGuest, useMan
|
|
|
11
11
|
// with a way to cancel it; or why the date takes no RSVPs. Same rules as the
|
|
12
12
|
// event's web page: open until the date ends, and the organiser may approve
|
|
13
13
|
// each RSVP. useRsvp keeps one idempotency key per RSVP, so sending again
|
|
14
|
-
// after a timeout can't register anyone twice.
|
|
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.
|
|
15
17
|
|
|
16
18
|
// The RSVP's private manage token, one per event date, lives only in secure
|
|
17
19
|
// storage (never a URL or log). Keys allow letters, digits, ".", "-" and "_".
|
|
@@ -26,19 +28,21 @@ const tokens = {
|
|
|
26
28
|
|
|
27
29
|
type Answers = Record<string, string | string[]>;
|
|
28
30
|
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
31
|
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
|
|
33
32
|
// "Other" choices send the typed text instead
|
|
34
33
|
const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
|
|
35
34
|
if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
|
|
36
35
|
return value === OTHER ? other.trim() : (value ?? '');
|
|
37
36
|
};
|
|
38
|
-
|
|
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
39
|
|
|
40
|
-
|
|
40
|
+
// children: shown under the person's RSVP (not a declined one), e.g. an offer
|
|
41
|
+
// to get notified if the date, time or place changes
|
|
42
|
+
export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
|
|
41
43
|
const t = useTheme();
|
|
44
|
+
const words = useStrings();
|
|
45
|
+
const { locale } = useLocale();
|
|
42
46
|
const { data: site } = useSite();
|
|
43
47
|
const guest = useGuest();
|
|
44
48
|
const status = useRsvpStatus(slug, start);
|
|
@@ -78,17 +82,19 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
78
82
|
setStored(null);
|
|
79
83
|
setConfirming(false);
|
|
80
84
|
rsvp.reset();
|
|
81
|
-
if (managed.data?.booking.status === 'cancelled') setNotice(
|
|
85
|
+
if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
|
|
82
86
|
}, [key, stored, gone]);
|
|
83
87
|
|
|
84
88
|
if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
|
|
85
89
|
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
90
|
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
91
|
const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
|
|
92
|
+
const num = (n: number) => n.toLocaleString(locale);
|
|
93
|
+
const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
|
|
88
94
|
const going = (
|
|
89
95
|
<Text style={t.styles.text.body}>
|
|
90
|
-
|
|
91
|
-
{s.left !== null && <> ·
|
|
96
|
+
{bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
|
|
97
|
+
{s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
|
|
92
98
|
</Text>
|
|
93
99
|
);
|
|
94
100
|
|
|
@@ -99,9 +105,9 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
99
105
|
// offline or busy: say so rather than offer a second RSVP
|
|
100
106
|
if (stored && changes && managed.error && !gone && !made) return (
|
|
101
107
|
<View style={[t.styles.card, { gap: 8 }]}>
|
|
102
|
-
<Text accessibilityRole="header" style={t.styles.text.heading}>
|
|
103
|
-
<Text style={t.styles.text.muted}>
|
|
104
|
-
<Button label=
|
|
108
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{words.yourRsvp}</Text>
|
|
109
|
+
<Text style={t.styles.text.muted}>{words.loadError}</Text>
|
|
110
|
+
<Button label={words.tryAgain} variant="outline" onPress={() => void managed.refetch()} />
|
|
105
111
|
</View>
|
|
106
112
|
);
|
|
107
113
|
if ((stored && mine) || made) {
|
|
@@ -109,37 +115,38 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
109
115
|
const who = mine?.booking.email ?? made!.email;
|
|
110
116
|
const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
|
|
111
117
|
const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
|
|
112
|
-
return (
|
|
118
|
+
return (<>
|
|
113
119
|
<View style={[t.styles.card, { gap: 8 }]}>
|
|
114
|
-
<Text accessibilityRole="header" style={t.styles.text.heading}>{!made ?
|
|
120
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
|
|
115
121
|
<Text style={[t.styles.text.label, { color: t.colors.ink }]}>
|
|
116
|
-
{seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ?
|
|
122
|
+
{seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
|
|
117
123
|
</Text>
|
|
118
124
|
<Text style={t.styles.text.body}>
|
|
119
|
-
{state === 'confirmed' ? thanks ||
|
|
120
|
-
: state === 'pending' ?
|
|
125
|
+
{state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
|
|
126
|
+
: state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
|
|
121
127
|
</Text>
|
|
122
|
-
{state === 'declined' && <Button label=
|
|
128
|
+
{state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
|
|
123
129
|
{canCancel && state !== 'declined' && (confirming ? (
|
|
124
130
|
<View style={{ gap: 8 }}>
|
|
125
|
-
<Text style={t.styles.text.body}>
|
|
131
|
+
<Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
|
|
126
132
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
|
|
127
|
-
<Button label={cancel.isPending ?
|
|
128
|
-
<Button label=
|
|
133
|
+
<Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
|
|
134
|
+
<Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
|
|
129
135
|
</View>
|
|
130
136
|
</View>
|
|
131
|
-
) : <Button label=
|
|
132
|
-
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ?
|
|
133
|
-
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>
|
|
137
|
+
) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
|
|
138
|
+
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
|
|
139
|
+
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
|
|
134
140
|
</View>
|
|
135
|
-
|
|
141
|
+
{state !== 'declined' && children}
|
|
142
|
+
</>);
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
if (s.reason === 'disabled') return null;
|
|
139
146
|
if (!s.open) return (
|
|
140
147
|
<View style={[t.styles.card, { gap: 6 }]}>
|
|
141
|
-
<Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ?
|
|
142
|
-
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ?
|
|
148
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
|
|
149
|
+
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
|
|
143
150
|
</View>
|
|
144
151
|
);
|
|
145
152
|
|
|
@@ -148,8 +155,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
148
155
|
const fields = form?.fields ?? [];
|
|
149
156
|
const answers: Answers = Object.fromEntries(fields.filter((f) => f.type !== 'heading').map((f) => [f.id, withOther(f, values[f.id], others[f.id] ?? '')]));
|
|
150
157
|
const problems = answerProblems(fields, answers);
|
|
151
|
-
const nameProblem = !name.trim() ?
|
|
152
|
-
const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ?
|
|
158
|
+
const nameProblem = !name.trim() ? words.nameProblem : '';
|
|
159
|
+
const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
|
|
153
160
|
// the form may ask for a phone itself
|
|
154
161
|
const askPhone = !fields.some((f) => f.type === 'phone');
|
|
155
162
|
const chosen = Math.min(seats, s.maxSeats) || 1;
|
|
@@ -163,7 +170,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
163
170
|
};
|
|
164
171
|
const set = (id: string, v: string | string[]) => setValues((a) => ({ ...a, [id]: v }));
|
|
165
172
|
const label = (text: string, required: boolean) => (
|
|
166
|
-
<Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel=
|
|
173
|
+
<Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel={words.required}> *</Text> : ''}</Text>
|
|
167
174
|
);
|
|
168
175
|
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
176
|
<View key={lbl} style={{ gap: 6 }}>
|
|
@@ -177,7 +184,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
177
184
|
</View>
|
|
178
185
|
);
|
|
179
186
|
const field = (f: FormField) => {
|
|
180
|
-
const problem = tried && problems[f.id] ?
|
|
187
|
+
const problem = tried && problems[f.id] ? answerProblemText(f, problems[f.id], words) : '';
|
|
181
188
|
if (f.type === 'heading') return (
|
|
182
189
|
<View key={f.id} style={{ gap: 2, paddingTop: 4 }}>
|
|
183
190
|
<Text accessibilityRole="header" style={t.styles.text.heading}>{f.label}</Text>
|
|
@@ -211,12 +218,12 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
211
218
|
<View accessibilityRole={many ? undefined : 'radiogroup'} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
212
219
|
{choices.map((o) => (
|
|
213
220
|
<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 ?
|
|
221
|
+
<Text style={[t.styles.text.body, isOn(o) && { fontWeight: '600' }]}>{o === OTHER ? words.other : o}</Text>
|
|
215
222
|
</Pressable>
|
|
216
223
|
))}
|
|
217
224
|
</View>
|
|
218
225
|
{isOn(OTHER) && (
|
|
219
|
-
<TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder=
|
|
226
|
+
<TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder={words.other} accessibilityLabel={fill(words.otherAnswer, { label: f.label })}
|
|
220
227
|
placeholderTextColor={t.colors.muted} style={input} />
|
|
221
228
|
)}
|
|
222
229
|
{!!problem && <Text style={errorText}>{problem}</Text>}
|
|
@@ -225,7 +232,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
225
232
|
}
|
|
226
233
|
return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
|
|
227
234
|
required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
|
|
228
|
-
placeholder: f.placeholder || (f.type === 'date' ?
|
|
235
|
+
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
229
236
|
});
|
|
230
237
|
};
|
|
231
238
|
|
|
@@ -233,21 +240,21 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
233
240
|
return (
|
|
234
241
|
<View style={[t.styles.card, { gap: 16 }]}>
|
|
235
242
|
<View style={{ gap: 4 }}>
|
|
236
|
-
<Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title ||
|
|
243
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
|
|
237
244
|
{going}
|
|
238
245
|
{!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
|
|
239
246
|
{!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
|
|
240
247
|
</View>
|
|
241
|
-
{text(
|
|
242
|
-
{text(
|
|
243
|
-
{askPhone && text(
|
|
248
|
+
{text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
|
|
249
|
+
{text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
|
|
250
|
+
{askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
|
|
244
251
|
{s.maxSeats > 1 && (
|
|
245
252
|
<View style={{ gap: 6 }}>
|
|
246
|
-
{label(
|
|
253
|
+
{label(words.seatsField, false)}
|
|
247
254
|
<View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
248
255
|
{seatChoices(s).map((n) => (
|
|
249
256
|
<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>
|
|
257
|
+
<Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
|
|
251
258
|
</Pressable>
|
|
252
259
|
))}
|
|
253
260
|
</View>
|
|
@@ -256,15 +263,15 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
256
263
|
{fields.map(field)}
|
|
257
264
|
{error && (
|
|
258
265
|
<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' }]}>
|
|
266
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
|
|
260
267
|
<Text style={t.styles.text.muted}>
|
|
261
|
-
{isAcmsError(error) && error.status === 0 ?
|
|
268
|
+
{isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
|
|
262
269
|
</Text>
|
|
263
270
|
</View>
|
|
264
271
|
)}
|
|
265
|
-
{tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>
|
|
266
|
-
<Button label={rsvp.isPending ?
|
|
267
|
-
<Text style={t.styles.text.small}>
|
|
272
|
+
{tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
|
|
273
|
+
<Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
|
|
274
|
+
<Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
|
|
268
275
|
</View>
|
|
269
276
|
);
|
|
270
277
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { Fragment, useEffect, useState, type ReactNode } from 'react';
|
|
2
2
|
import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
|
|
3
3
|
import * as Crypto from 'expo-crypto';
|
|
4
4
|
import * as SecureStore from 'expo-secure-store';
|
|
5
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';
|
|
6
|
+
import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
7
7
|
|
|
8
8
|
// RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
|
|
9
9
|
// with `start` exactly as useEvents returns it. Shows going and spots left,
|
|
@@ -11,7 +11,9 @@ import { Button, answerProblems, seatChoices, useCancelBooking, useGuest, useMan
|
|
|
11
11
|
// with a way to cancel it; or why the date takes no RSVPs. Same rules as the
|
|
12
12
|
// event's web page: open until the date ends, and the organiser may approve
|
|
13
13
|
// each RSVP. useRsvp keeps one idempotency key per RSVP, so sending again
|
|
14
|
-
// after a timeout can't register anyone twice.
|
|
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.
|
|
15
17
|
|
|
16
18
|
// The RSVP's private manage token, one per event date, lives only in secure
|
|
17
19
|
// storage (never a URL or log). Keys allow letters, digits, ".", "-" and "_".
|
|
@@ -26,19 +28,21 @@ const tokens = {
|
|
|
26
28
|
|
|
27
29
|
type Answers = Record<string, string | string[]>;
|
|
28
30
|
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
31
|
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
|
|
33
32
|
// "Other" choices send the typed text instead
|
|
34
33
|
const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
|
|
35
34
|
if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
|
|
36
35
|
return value === OTHER ? other.trim() : (value ?? '');
|
|
37
36
|
};
|
|
38
|
-
|
|
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
39
|
|
|
40
|
-
|
|
40
|
+
// children: shown under the person's RSVP (not a declined one), e.g. an offer
|
|
41
|
+
// to get notified if the date, time or place changes
|
|
42
|
+
export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
|
|
41
43
|
const t = useTheme();
|
|
44
|
+
const words = useStrings();
|
|
45
|
+
const { locale } = useLocale();
|
|
42
46
|
const { data: site } = useSite();
|
|
43
47
|
const guest = useGuest();
|
|
44
48
|
const status = useRsvpStatus(slug, start);
|
|
@@ -78,17 +82,19 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
78
82
|
setStored(null);
|
|
79
83
|
setConfirming(false);
|
|
80
84
|
rsvp.reset();
|
|
81
|
-
if (managed.data?.booking.status === 'cancelled') setNotice(
|
|
85
|
+
if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
|
|
82
86
|
}, [key, stored, gone]);
|
|
83
87
|
|
|
84
88
|
if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
|
|
85
89
|
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
90
|
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
91
|
const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
|
|
92
|
+
const num = (n: number) => n.toLocaleString(locale);
|
|
93
|
+
const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
|
|
88
94
|
const going = (
|
|
89
95
|
<Text style={t.styles.text.body}>
|
|
90
|
-
|
|
91
|
-
{s.left !== null && <> ·
|
|
96
|
+
{bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
|
|
97
|
+
{s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
|
|
92
98
|
</Text>
|
|
93
99
|
);
|
|
94
100
|
|
|
@@ -99,9 +105,9 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
99
105
|
// offline or busy: say so rather than offer a second RSVP
|
|
100
106
|
if (stored && changes && managed.error && !gone && !made) return (
|
|
101
107
|
<View style={[t.styles.card, { gap: 8 }]}>
|
|
102
|
-
<Text accessibilityRole="header" style={t.styles.text.heading}>
|
|
103
|
-
<Text style={t.styles.text.muted}>
|
|
104
|
-
<Button label=
|
|
108
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{words.yourRsvp}</Text>
|
|
109
|
+
<Text style={t.styles.text.muted}>{words.loadError}</Text>
|
|
110
|
+
<Button label={words.tryAgain} variant="outline" onPress={() => void managed.refetch()} />
|
|
105
111
|
</View>
|
|
106
112
|
);
|
|
107
113
|
if ((stored && mine) || made) {
|
|
@@ -109,37 +115,38 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
109
115
|
const who = mine?.booking.email ?? made!.email;
|
|
110
116
|
const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
|
|
111
117
|
const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
|
|
112
|
-
return (
|
|
118
|
+
return (<>
|
|
113
119
|
<View style={[t.styles.card, { gap: 8 }]}>
|
|
114
|
-
<Text accessibilityRole="header" style={t.styles.text.heading}>{!made ?
|
|
120
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
|
|
115
121
|
<Text style={[t.styles.text.label, { color: t.colors.ink }]}>
|
|
116
|
-
{seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ?
|
|
122
|
+
{seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
|
|
117
123
|
</Text>
|
|
118
124
|
<Text style={t.styles.text.body}>
|
|
119
|
-
{state === 'confirmed' ? thanks ||
|
|
120
|
-
: state === 'pending' ?
|
|
125
|
+
{state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
|
|
126
|
+
: state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
|
|
121
127
|
</Text>
|
|
122
|
-
{state === 'declined' && <Button label=
|
|
128
|
+
{state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
|
|
123
129
|
{canCancel && state !== 'declined' && (confirming ? (
|
|
124
130
|
<View style={{ gap: 8 }}>
|
|
125
|
-
<Text style={t.styles.text.body}>
|
|
131
|
+
<Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
|
|
126
132
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
|
|
127
|
-
<Button label={cancel.isPending ?
|
|
128
|
-
<Button label=
|
|
133
|
+
<Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
|
|
134
|
+
<Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
|
|
129
135
|
</View>
|
|
130
136
|
</View>
|
|
131
|
-
) : <Button label=
|
|
132
|
-
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ?
|
|
133
|
-
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>
|
|
137
|
+
) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
|
|
138
|
+
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
|
|
139
|
+
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
|
|
134
140
|
</View>
|
|
135
|
-
|
|
141
|
+
{state !== 'declined' && children}
|
|
142
|
+
</>);
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
if (s.reason === 'disabled') return null;
|
|
139
146
|
if (!s.open) return (
|
|
140
147
|
<View style={[t.styles.card, { gap: 6 }]}>
|
|
141
|
-
<Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ?
|
|
142
|
-
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ?
|
|
148
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
|
|
149
|
+
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
|
|
143
150
|
</View>
|
|
144
151
|
);
|
|
145
152
|
|
|
@@ -148,8 +155,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
148
155
|
const fields = form?.fields ?? [];
|
|
149
156
|
const answers: Answers = Object.fromEntries(fields.filter((f) => f.type !== 'heading').map((f) => [f.id, withOther(f, values[f.id], others[f.id] ?? '')]));
|
|
150
157
|
const problems = answerProblems(fields, answers);
|
|
151
|
-
const nameProblem = !name.trim() ?
|
|
152
|
-
const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ?
|
|
158
|
+
const nameProblem = !name.trim() ? words.nameProblem : '';
|
|
159
|
+
const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
|
|
153
160
|
// the form may ask for a phone itself
|
|
154
161
|
const askPhone = !fields.some((f) => f.type === 'phone');
|
|
155
162
|
const chosen = Math.min(seats, s.maxSeats) || 1;
|
|
@@ -163,7 +170,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
163
170
|
};
|
|
164
171
|
const set = (id: string, v: string | string[]) => setValues((a) => ({ ...a, [id]: v }));
|
|
165
172
|
const label = (text: string, required: boolean) => (
|
|
166
|
-
<Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel=
|
|
173
|
+
<Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel={words.required}> *</Text> : ''}</Text>
|
|
167
174
|
);
|
|
168
175
|
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
176
|
<View key={lbl} style={{ gap: 6 }}>
|
|
@@ -177,7 +184,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
177
184
|
</View>
|
|
178
185
|
);
|
|
179
186
|
const field = (f: FormField) => {
|
|
180
|
-
const problem = tried && problems[f.id] ?
|
|
187
|
+
const problem = tried && problems[f.id] ? answerProblemText(f, problems[f.id], words) : '';
|
|
181
188
|
if (f.type === 'heading') return (
|
|
182
189
|
<View key={f.id} style={{ gap: 2, paddingTop: 4 }}>
|
|
183
190
|
<Text accessibilityRole="header" style={t.styles.text.heading}>{f.label}</Text>
|
|
@@ -211,12 +218,12 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
211
218
|
<View accessibilityRole={many ? undefined : 'radiogroup'} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
212
219
|
{choices.map((o) => (
|
|
213
220
|
<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 ?
|
|
221
|
+
<Text style={[t.styles.text.body, isOn(o) && { fontWeight: '600' }]}>{o === OTHER ? words.other : o}</Text>
|
|
215
222
|
</Pressable>
|
|
216
223
|
))}
|
|
217
224
|
</View>
|
|
218
225
|
{isOn(OTHER) && (
|
|
219
|
-
<TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder=
|
|
226
|
+
<TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder={words.other} accessibilityLabel={fill(words.otherAnswer, { label: f.label })}
|
|
220
227
|
placeholderTextColor={t.colors.muted} style={input} />
|
|
221
228
|
)}
|
|
222
229
|
{!!problem && <Text style={errorText}>{problem}</Text>}
|
|
@@ -225,7 +232,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
225
232
|
}
|
|
226
233
|
return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
|
|
227
234
|
required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
|
|
228
|
-
placeholder: f.placeholder || (f.type === 'date' ?
|
|
235
|
+
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
229
236
|
});
|
|
230
237
|
};
|
|
231
238
|
|
|
@@ -233,21 +240,21 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
233
240
|
return (
|
|
234
241
|
<View style={[t.styles.card, { gap: 16 }]}>
|
|
235
242
|
<View style={{ gap: 4 }}>
|
|
236
|
-
<Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title ||
|
|
243
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
|
|
237
244
|
{going}
|
|
238
245
|
{!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
|
|
239
246
|
{!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
|
|
240
247
|
</View>
|
|
241
|
-
{text(
|
|
242
|
-
{text(
|
|
243
|
-
{askPhone && text(
|
|
248
|
+
{text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
|
|
249
|
+
{text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
|
|
250
|
+
{askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
|
|
244
251
|
{s.maxSeats > 1 && (
|
|
245
252
|
<View style={{ gap: 6 }}>
|
|
246
|
-
{label(
|
|
253
|
+
{label(words.seatsField, false)}
|
|
247
254
|
<View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
248
255
|
{seatChoices(s).map((n) => (
|
|
249
256
|
<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>
|
|
257
|
+
<Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
|
|
251
258
|
</Pressable>
|
|
252
259
|
))}
|
|
253
260
|
</View>
|
|
@@ -256,15 +263,15 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
256
263
|
{fields.map(field)}
|
|
257
264
|
{error && (
|
|
258
265
|
<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' }]}>
|
|
266
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
|
|
260
267
|
<Text style={t.styles.text.muted}>
|
|
261
|
-
{isAcmsError(error) && error.status === 0 ?
|
|
268
|
+
{isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
|
|
262
269
|
</Text>
|
|
263
270
|
</View>
|
|
264
271
|
)}
|
|
265
|
-
{tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>
|
|
266
|
-
<Button label={rsvp.isPending ?
|
|
267
|
-
<Text style={t.styles.text.small}>
|
|
272
|
+
{tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
|
|
273
|
+
<Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
|
|
274
|
+
<Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
|
|
268
275
|
</View>
|
|
269
276
|
);
|
|
270
277
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"next": [
|
|
11
11
|
"Mount it on your event screen where the date is known: <RsvpPanel slug={occurrence.event.slug} start={occurrence.start} /> from '../../components/RsvpPanel', with start exactly as useEvents returns it (the UTC ISO string, e.g. pass o.start through router params), and only when occurrence.event.rsvp?.enabled",
|
|
12
12
|
"The screen should scroll and avoid the keyboard: wrap it in <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}> with <ScrollView keyboardShouldPersistTaps=\"handled\">",
|
|
13
|
-
"RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too"
|
|
13
|
+
"RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too",
|
|
14
|
+
"Anything you pass as children shows under the person's RSVP (not a declined one), e.g. an offer to get notified if the date, time or place changes: <RsvpPanel slug={…} start={…}><MyOffer /></RsvpPanel>. With push on, occurrence.event.notify.changes lists the topics that hear about changes ([] = none)"
|
|
14
15
|
]
|
|
15
16
|
}
|