@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/theme-authoring.md +1 -1
  4. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +54 -47
  5. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +54 -47
  6. package/mobile-template/recipes/rsvp/recipe.json +2 -1
  7. package/package.json +1 -1
  8. package/src/components/EventPage.astro +50 -30
  9. package/src/lib/booking/emails.ts +59 -44
  10. package/src/lib/booking/service.ts +22 -11
  11. package/src/lib/calendar/cards.ts +5 -3
  12. package/src/lib/calendar/event-words.ts +171 -0
  13. package/src/lib/calendar/events.ts +5 -2
  14. package/src/lib/calendar/render.ts +3 -1
  15. package/src/lib/calendar/rrule.ts +14 -18
  16. package/src/lib/calendar/rsvp-words.ts +121 -0
  17. package/src/lib/email/booking-words.ts +203 -0
  18. package/src/lib/forms/events.ts +4 -2
  19. package/src/lib/mobile/bookings.ts +9 -2
  20. package/src/lib/mobile/calendar.ts +6 -4
  21. package/src/lib/mobile/guest.ts +3 -2
  22. package/src/lib/mobile/manage.ts +7 -5
  23. package/src/lib/mobile/rsvp.ts +9 -1
  24. package/src/lib/mobile/schemas.ts +2 -0
  25. package/src/lib/mobile/search.ts +1 -1
  26. package/src/lib/mobile/site.ts +7 -3
  27. package/src/lib/push/service.ts +10 -0
  28. package/src/lib/repo/migrations.ts +5 -0
  29. package/src/lib/repo/sql.ts +4 -4
  30. package/src/lib/repo/types.ts +2 -1
  31. package/src/lib/schema.ts +3 -0
  32. package/src/pages/book/[resource]/[link].astro +8 -4
  33. package/src/pages/book/manage/[token].astro +48 -29
  34. package/src/pages/events/[slug].astro +8 -5
  35. 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 = {
@@ -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
- const seatsText = (n: number) => (n === 1 ? '1 seat' : `${n} seats`);
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
- export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
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('Your RSVP is cancelled.');
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
- <Text style={{ fontWeight: '700' }}>{s.going}</Text> going
91
- {s.left !== null && <> · <Text style={{ fontWeight: '700' }}>{s.left}</Text> {s.left === 1 ? 'spot' : 'spots'} left</>}
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}>Your RSVP</Text>
103
- <Text style={t.styles.text.muted}>Couldn’t load it right now.</Text>
104
- <Button label="Try again" variant="outline" onPress={() => void managed.refetch()} />
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 ? 'Your RSVP' : state === 'confirmed' ? 'Confirmed' : 'Request sent'}</Text>
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' ? 'Confirmed' : state === 'pending' ? 'Waiting for approval' : 'Not 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 || `A confirmation is on its way to ${who}.`
120
- : state === 'pending' ? `We’ll email ${who} once it’s approved.` : 'Sorry, the organiser couldn’t confirm it.'}
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="RSVP again" variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
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}>Cancel your RSVP for {seatsText(mine!.booking.seats)}?</Text>
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 ? 'Cancelling…' : 'Yes, cancel it'} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
128
- <Button label="Keep it" variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
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="Cancel my RSVP" variant="outline" onPress={() => setConfirming(true)} />)}
132
- {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? 'Check your connection and try again.' : cancel.error.message}</Text>}
133
- {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>The confirmation email has a link to change or cancel it.</Text>}
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' ? 'Full' : s.reason === 'cancelled' ? 'This date is cancelled' : 'RSVPs are closed'}</Text>
142
- {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? 'Nobody needs to RSVP for it.' : 'This date has already happened.'}</Text>}
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() ? 'Add your name.' : '';
152
- const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? 'Add a valid email address.' : '';
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="required"> *</Text> : ''}</Text>
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] ? problemText(f, 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 ? 'Other' : o}</Text>
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="Other" accessibilityLabel={`${f.label}: other`}
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' ? 'YYYY-MM-DD' : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
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 || 'Save your seat'}</Text>
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('Your name', name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
242
- {text('Email', email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
243
- {askPhone && text('Phone (optional)', phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
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('Seats', false)}
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' }]}>That didn’t go through.</Text>
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 ? 'Check your connection and try again. You won’t be registered twice.' : error.message}
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}>Check the answers marked above.</Text>}
266
- <Button label={rsvp.isPending ? 'Sending…' : form?.submitLabel || (s.autoConfirm ? 'Confirm my seat' : 'Request a seat')} disabled={rsvp.isPending} onPress={submit} />
267
- <Text style={t.styles.text.small}>Your details go only to the organiser of this event.</Text>
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
- const seatsText = (n: number) => (n === 1 ? '1 seat' : `${n} seats`);
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
- export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
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('Your RSVP is cancelled.');
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
- <Text style={{ fontWeight: '700' }}>{s.going}</Text> going
91
- {s.left !== null && <> · <Text style={{ fontWeight: '700' }}>{s.left}</Text> {s.left === 1 ? 'spot' : 'spots'} left</>}
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}>Your RSVP</Text>
103
- <Text style={t.styles.text.muted}>Couldn’t load it right now.</Text>
104
- <Button label="Try again" variant="outline" onPress={() => void managed.refetch()} />
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 ? 'Your RSVP' : state === 'confirmed' ? 'Confirmed' : 'Request sent'}</Text>
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' ? 'Confirmed' : state === 'pending' ? 'Waiting for approval' : 'Not 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 || `A confirmation is on its way to ${who}.`
120
- : state === 'pending' ? `We’ll email ${who} once it’s approved.` : 'Sorry, the organiser couldn’t confirm it.'}
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="RSVP again" variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
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}>Cancel your RSVP for {seatsText(mine!.booking.seats)}?</Text>
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 ? 'Cancelling…' : 'Yes, cancel it'} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
128
- <Button label="Keep it" variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
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="Cancel my RSVP" variant="outline" onPress={() => setConfirming(true)} />)}
132
- {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? 'Check your connection and try again.' : cancel.error.message}</Text>}
133
- {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>The confirmation email has a link to change or cancel it.</Text>}
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' ? 'Full' : s.reason === 'cancelled' ? 'This date is cancelled' : 'RSVPs are closed'}</Text>
142
- {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? 'Nobody needs to RSVP for it.' : 'This date has already happened.'}</Text>}
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() ? 'Add your name.' : '';
152
- const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? 'Add a valid email address.' : '';
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="required"> *</Text> : ''}</Text>
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] ? problemText(f, 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 ? 'Other' : o}</Text>
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="Other" accessibilityLabel={`${f.label}: other`}
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' ? 'YYYY-MM-DD' : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
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 || 'Save your seat'}</Text>
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('Your name', name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
242
- {text('Email', email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
243
- {askPhone && text('Phone (optional)', phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
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('Seats', false)}
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' }]}>That didn’t go through.</Text>
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 ? 'Check your connection and try again. You won’t be registered twice.' : error.message}
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}>Check the answers marked above.</Text>}
266
- <Button label={rsvp.isPending ? 'Sending…' : form?.submitLabel || (s.autoConfirm ? 'Confirm my seat' : 'Request a seat')} disabled={rsvp.isPending} onPress={submit} />
267
- <Text style={t.styles.text.small}>Your details go only to the organiser of this event.</Text>
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },