@acms-subir/core 0.3.36 → 0.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.37
4
+
5
+ ### Patch Changes
6
+
7
+ - 12fdb58: RSVPs now follow the page's or the app's language, in English, Spanish, French, German and Portuguese (other languages stay in English). On `/es` event pages, and on a site whose main language is Spanish, the RSVP block still said "going", "spots left", "Save my seat", "Your name", "Email", "Seats", "Confirm my seat" or "Request a seat", "This event is full." and "RSVPs are closed." in English; now it says "van", "lugares disponibles", "Apartar mi lugar" and so on. RSVPing from an `/es` page lands on the RSVP's own page in Spanish too: the heading, status, details, Add to calendar, the cancel button, the event's title and the link back, with the date in Spanish. Room booking pages stay in English.
8
+
9
+ `GET /api/v1/events/:slug/rsvp` names the built-in button ("Confirm my seat", or "Request a seat" when the organiser approves RSVPs) in the `locale` the app asks for, or the site's main language. A button label you wrote stays as you wrote it.
10
+
11
+ The RSVP panel in the app recipes (`components/RsvpPanel.tsx`) takes every word from `useStrings()`, including screen-reader labels and the errors next to answers, so it speaks the app's language (English and Spanish built in; pass `strings` to `<AcmsProvider>` for others), with numbers in the app's format. The event's own form title, questions and button keep your words. Apps that already have the panel, from the `events` or the `rsvp` recipe, can update it with `npx acms mobile recipe add rsvp --force`. That overwrites `components/RsvpPanel.tsx`, including any changes you made to it, and needs this release's `@acms-subir/native`.
12
+
3
13
  ## 0.3.36
4
14
 
5
15
  ### Patch Changes
@@ -1,9 +1,9 @@
1
- import { useEffect, useState } from 'react';
1
+ import { Fragment, useEffect, useState } 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,19 @@ 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
40
  export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
41
41
  const t = useTheme();
42
+ const words = useStrings();
43
+ const { locale } = useLocale();
42
44
  const { data: site } = useSite();
43
45
  const guest = useGuest();
44
46
  const status = useRsvpStatus(slug, start);
@@ -78,17 +80,19 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
78
80
  setStored(null);
79
81
  setConfirming(false);
80
82
  rsvp.reset();
81
- if (managed.data?.booking.status === 'cancelled') setNotice('Your RSVP is cancelled.');
83
+ if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
82
84
  }, [key, stored, gone]);
83
85
 
84
86
  if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
85
87
  const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
86
88
  const chip = (on: boolean) => ({ paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel });
87
89
  const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
90
+ const num = (n: number) => n.toLocaleString(locale);
91
+ const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
88
92
  const going = (
89
93
  <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</>}
94
+ {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
95
+ {s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
92
96
  </Text>
93
97
  );
94
98
 
@@ -99,9 +103,9 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
99
103
  // offline or busy: say so rather than offer a second RSVP
100
104
  if (stored && changes && managed.error && !gone && !made) return (
101
105
  <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()} />
106
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{words.yourRsvp}</Text>
107
+ <Text style={t.styles.text.muted}>{words.loadError}</Text>
108
+ <Button label={words.tryAgain} variant="outline" onPress={() => void managed.refetch()} />
105
109
  </View>
106
110
  );
107
111
  if ((stored && mine) || made) {
@@ -111,26 +115,26 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
111
115
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
112
116
  return (
113
117
  <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>
118
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
115
119
  <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'}
120
+ {seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
117
121
  </Text>
118
122
  <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.'}
123
+ {state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
124
+ : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
121
125
  </Text>
122
- {state === 'declined' && <Button label="RSVP again" variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
126
+ {state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
123
127
  {canCancel && state !== 'declined' && (confirming ? (
124
128
  <View style={{ gap: 8 }}>
125
- <Text style={t.styles.text.body}>Cancel your RSVP for {seatsText(mine!.booking.seats)}?</Text>
129
+ <Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
126
130
  <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)} />
131
+ <Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
132
+ <Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
129
133
  </View>
130
134
  </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>}
135
+ ) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
136
+ {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
137
+ {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
134
138
  </View>
135
139
  );
136
140
  }
@@ -138,8 +142,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
138
142
  if (s.reason === 'disabled') return null;
139
143
  if (!s.open) return (
140
144
  <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>}
145
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
146
+ {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
143
147
  </View>
144
148
  );
145
149
 
@@ -148,8 +152,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
148
152
  const fields = form?.fields ?? [];
149
153
  const answers: Answers = Object.fromEntries(fields.filter((f) => f.type !== 'heading').map((f) => [f.id, withOther(f, values[f.id], others[f.id] ?? '')]));
150
154
  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.' : '';
155
+ const nameProblem = !name.trim() ? words.nameProblem : '';
156
+ const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
153
157
  // the form may ask for a phone itself
154
158
  const askPhone = !fields.some((f) => f.type === 'phone');
155
159
  const chosen = Math.min(seats, s.maxSeats) || 1;
@@ -163,7 +167,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
163
167
  };
164
168
  const set = (id: string, v: string | string[]) => setValues((a) => ({ ...a, [id]: v }));
165
169
  const label = (text: string, required: boolean) => (
166
- <Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel="required"> *</Text> : ''}</Text>
170
+ <Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel={words.required}> *</Text> : ''}</Text>
167
171
  );
168
172
  const text = (lbl: string, value: string, onChange: (v: string) => void, o: { required?: boolean; keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel'; placeholder?: string; help?: string; problem?: string } = {}) => (
169
173
  <View key={lbl} style={{ gap: 6 }}>
@@ -177,7 +181,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
177
181
  </View>
178
182
  );
179
183
  const field = (f: FormField) => {
180
- const problem = tried && problems[f.id] ? problemText(f, problems[f.id]) : '';
184
+ const problem = tried && problems[f.id] ? answerProblemText(f, problems[f.id], words) : '';
181
185
  if (f.type === 'heading') return (
182
186
  <View key={f.id} style={{ gap: 2, paddingTop: 4 }}>
183
187
  <Text accessibilityRole="header" style={t.styles.text.heading}>{f.label}</Text>
@@ -211,12 +215,12 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
211
215
  <View accessibilityRole={many ? undefined : 'radiogroup'} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
212
216
  {choices.map((o) => (
213
217
  <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>
218
+ <Text style={[t.styles.text.body, isOn(o) && { fontWeight: '600' }]}>{o === OTHER ? words.other : o}</Text>
215
219
  </Pressable>
216
220
  ))}
217
221
  </View>
218
222
  {isOn(OTHER) && (
219
- <TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder="Other" accessibilityLabel={`${f.label}: other`}
223
+ <TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder={words.other} accessibilityLabel={fill(words.otherAnswer, { label: f.label })}
220
224
  placeholderTextColor={t.colors.muted} style={input} />
221
225
  )}
222
226
  {!!problem && <Text style={errorText}>{problem}</Text>}
@@ -225,7 +229,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
225
229
  }
226
230
  return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
227
231
  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,
232
+ placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
229
233
  });
230
234
  };
231
235
 
@@ -233,21 +237,21 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
233
237
  return (
234
238
  <View style={[t.styles.card, { gap: 16 }]}>
235
239
  <View style={{ gap: 4 }}>
236
- <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || 'Save your seat'}</Text>
240
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
237
241
  {going}
238
242
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
239
243
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
240
244
  </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' })}
245
+ {text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
246
+ {text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
247
+ {askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
244
248
  {s.maxSeats > 1 && (
245
249
  <View style={{ gap: 6 }}>
246
- {label('Seats', false)}
250
+ {label(words.seatsField, false)}
247
251
  <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
248
252
  {seatChoices(s).map((n) => (
249
253
  <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>
254
+ <Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
251
255
  </Pressable>
252
256
  ))}
253
257
  </View>
@@ -256,15 +260,15 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
256
260
  {fields.map(field)}
257
261
  {error && (
258
262
  <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>
263
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
260
264
  <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}
265
+ {isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
262
266
  </Text>
263
267
  </View>
264
268
  )}
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>
269
+ {tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
270
+ <Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
271
+ <Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
268
272
  </View>
269
273
  );
270
274
  }
@@ -1,9 +1,9 @@
1
- import { useEffect, useState } from 'react';
1
+ import { Fragment, useEffect, useState } 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,19 @@ 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
40
  export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
41
41
  const t = useTheme();
42
+ const words = useStrings();
43
+ const { locale } = useLocale();
42
44
  const { data: site } = useSite();
43
45
  const guest = useGuest();
44
46
  const status = useRsvpStatus(slug, start);
@@ -78,17 +80,19 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
78
80
  setStored(null);
79
81
  setConfirming(false);
80
82
  rsvp.reset();
81
- if (managed.data?.booking.status === 'cancelled') setNotice('Your RSVP is cancelled.');
83
+ if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
82
84
  }, [key, stored, gone]);
83
85
 
84
86
  if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
85
87
  const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
86
88
  const chip = (on: boolean) => ({ paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel });
87
89
  const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
90
+ const num = (n: number) => n.toLocaleString(locale);
91
+ const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
88
92
  const going = (
89
93
  <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</>}
94
+ {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
95
+ {s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
92
96
  </Text>
93
97
  );
94
98
 
@@ -99,9 +103,9 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
99
103
  // offline or busy: say so rather than offer a second RSVP
100
104
  if (stored && changes && managed.error && !gone && !made) return (
101
105
  <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()} />
106
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{words.yourRsvp}</Text>
107
+ <Text style={t.styles.text.muted}>{words.loadError}</Text>
108
+ <Button label={words.tryAgain} variant="outline" onPress={() => void managed.refetch()} />
105
109
  </View>
106
110
  );
107
111
  if ((stored && mine) || made) {
@@ -111,26 +115,26 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
111
115
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
112
116
  return (
113
117
  <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>
118
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
115
119
  <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'}
120
+ {seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
117
121
  </Text>
118
122
  <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.'}
123
+ {state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
124
+ : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
121
125
  </Text>
122
- {state === 'declined' && <Button label="RSVP again" variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
126
+ {state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
123
127
  {canCancel && state !== 'declined' && (confirming ? (
124
128
  <View style={{ gap: 8 }}>
125
- <Text style={t.styles.text.body}>Cancel your RSVP for {seatsText(mine!.booking.seats)}?</Text>
129
+ <Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
126
130
  <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)} />
131
+ <Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
132
+ <Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
129
133
  </View>
130
134
  </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>}
135
+ ) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
136
+ {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
137
+ {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
134
138
  </View>
135
139
  );
136
140
  }
@@ -138,8 +142,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
138
142
  if (s.reason === 'disabled') return null;
139
143
  if (!s.open) return (
140
144
  <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>}
145
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
146
+ {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
143
147
  </View>
144
148
  );
145
149
 
@@ -148,8 +152,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
148
152
  const fields = form?.fields ?? [];
149
153
  const answers: Answers = Object.fromEntries(fields.filter((f) => f.type !== 'heading').map((f) => [f.id, withOther(f, values[f.id], others[f.id] ?? '')]));
150
154
  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.' : '';
155
+ const nameProblem = !name.trim() ? words.nameProblem : '';
156
+ const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
153
157
  // the form may ask for a phone itself
154
158
  const askPhone = !fields.some((f) => f.type === 'phone');
155
159
  const chosen = Math.min(seats, s.maxSeats) || 1;
@@ -163,7 +167,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
163
167
  };
164
168
  const set = (id: string, v: string | string[]) => setValues((a) => ({ ...a, [id]: v }));
165
169
  const label = (text: string, required: boolean) => (
166
- <Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel="required"> *</Text> : ''}</Text>
170
+ <Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel={words.required}> *</Text> : ''}</Text>
167
171
  );
168
172
  const text = (lbl: string, value: string, onChange: (v: string) => void, o: { required?: boolean; keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel'; placeholder?: string; help?: string; problem?: string } = {}) => (
169
173
  <View key={lbl} style={{ gap: 6 }}>
@@ -177,7 +181,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
177
181
  </View>
178
182
  );
179
183
  const field = (f: FormField) => {
180
- const problem = tried && problems[f.id] ? problemText(f, problems[f.id]) : '';
184
+ const problem = tried && problems[f.id] ? answerProblemText(f, problems[f.id], words) : '';
181
185
  if (f.type === 'heading') return (
182
186
  <View key={f.id} style={{ gap: 2, paddingTop: 4 }}>
183
187
  <Text accessibilityRole="header" style={t.styles.text.heading}>{f.label}</Text>
@@ -211,12 +215,12 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
211
215
  <View accessibilityRole={many ? undefined : 'radiogroup'} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
212
216
  {choices.map((o) => (
213
217
  <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>
218
+ <Text style={[t.styles.text.body, isOn(o) && { fontWeight: '600' }]}>{o === OTHER ? words.other : o}</Text>
215
219
  </Pressable>
216
220
  ))}
217
221
  </View>
218
222
  {isOn(OTHER) && (
219
- <TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder="Other" accessibilityLabel={`${f.label}: other`}
223
+ <TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder={words.other} accessibilityLabel={fill(words.otherAnswer, { label: f.label })}
220
224
  placeholderTextColor={t.colors.muted} style={input} />
221
225
  )}
222
226
  {!!problem && <Text style={errorText}>{problem}</Text>}
@@ -225,7 +229,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
225
229
  }
226
230
  return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
227
231
  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,
232
+ placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
229
233
  });
230
234
  };
231
235
 
@@ -233,21 +237,21 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
233
237
  return (
234
238
  <View style={[t.styles.card, { gap: 16 }]}>
235
239
  <View style={{ gap: 4 }}>
236
- <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || 'Save your seat'}</Text>
240
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
237
241
  {going}
238
242
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
239
243
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
240
244
  </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' })}
245
+ {text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
246
+ {text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
247
+ {askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
244
248
  {s.maxSeats > 1 && (
245
249
  <View style={{ gap: 6 }}>
246
- {label('Seats', false)}
250
+ {label(words.seatsField, false)}
247
251
  <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
248
252
  {seatChoices(s).map((n) => (
249
253
  <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>
254
+ <Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
251
255
  </Pressable>
252
256
  ))}
253
257
  </View>
@@ -256,15 +260,15 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
256
260
  {fields.map(field)}
257
261
  {error && (
258
262
  <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>
263
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
260
264
  <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}
265
+ {isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
262
266
  </Text>
263
267
  </View>
264
268
  )}
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>
269
+ {tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
270
+ <Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
271
+ <Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
268
272
  </View>
269
273
  );
270
274
  }
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.37",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,7 +8,9 @@ import { CALENDAR, eventIcsUrl, eventUrl, calendarPageUrl } from '../lib/calenda
8
8
  import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
9
9
  import { describeRepeat } from '../lib/calendar/rrule';
10
10
  import { eventCardHtml, safeHref } from '../lib/calendar/cards';
11
- import { siteLocale } from '../lib/dates';
11
+ import { siteLang, siteLocale } from '../lib/dates';
12
+ import { I18N, requestLocale } from '../lib/i18n';
13
+ import { aroundCount, rsvpStrings } from '../lib/calendar/rsvp-words';
12
14
  import { eventImage } from '../lib/calendar/images';
13
15
  import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
14
16
 
@@ -61,6 +63,10 @@ const I = {
61
63
  video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
62
64
  };
63
65
  const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
66
+ // the RSVP block speaks the page's language, as the layout does
67
+ const w = rsvpStrings((I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '') || siteLang(settings));
68
+ const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
69
+ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.spotsLeft) : ['', ''];
64
70
  ---
65
71
 
66
72
  <article class={`pc-ev-page${cancelled ? ' is-cancelled' : ''}`}>
@@ -130,27 +136,27 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
130
136
  ))}
131
137
  {rsvp ? (
132
138
  <div class="pc-ev-rsvp">
133
- <p class="pc-ev-going"><b>{rsvp.going}</b> going{rsvp.left !== null && <> · <b>{rsvp.left}</b> {rsvp.left === 1 ? 'spot' : 'spots'} left</>}</p>
139
+ <p class="pc-ev-going">{going[0]}<b>{rsvp.going}</b>{going[1]}{rsvp.left !== null && <> · {left[0]}<b>{rsvp.left}</b>{left[1]}</>}</p>
134
140
  {rsvp.open && (rsvp.left === null || rsvp.left > 0) ? (
135
141
  <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
136
- <summary class="pc-ev-cta">Save my seat</summary>
142
+ <summary class="pc-ev-cta">{w.saveSeat}</summary>
137
143
  <form method="post">
138
144
  <Fragment set:html={rsvp.formHtml ?? ''}/>
139
145
  {e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
140
146
  {e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
141
147
  {rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
142
- <label class="pc-bk-field"><span>Your name</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
143
- <label class="pc-bk-field"><span>Email</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
148
+ <label class="pc-bk-field"><span>{w.nameField}</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
149
+ <label class="pc-bk-field"><span>{w.emailField}</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
144
150
  {rsvp.maxSeats > 1 && (
145
- <label class="pc-bk-field"><span>Seats</span>
151
+ <label class="pc-bk-field"><span>{w.seatsField}</span>
146
152
  <select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
147
153
  </label>
148
154
  )}
149
155
  <Fragment set:html={rsvp.fieldsHtml ?? ''}/>
150
- <button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat')}</button>
156
+ <button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? w.confirmSeat : w.requestSeat)}</button>
151
157
  </form>
152
158
  </details>
153
- ) : <p class="pc-ev-full">{rsvp.open ? 'This event is full.' : 'RSVPs are closed.'}</p>}
159
+ ) : <p class="pc-ev-full">{rsvp.open ? w.full : w.closed}</p>}
154
160
  </div>
155
161
  ) : e.cta.label && ctaHref && !cancelled && (
156
162
  <a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
@@ -0,0 +1,121 @@
1
+ // Words of the built-in RSVP: the event page's RSVP block, the RSVP's own
2
+ // page (book/manage) and the app's default button (GET /events/:slug/rsvp).
3
+ // The same wording as the app's RSVP panel (packages/native strings.ts).
4
+ // Plain strings with {count}, {email} and {title} slots (live/words.ts
5
+ // `fill`); of a pair (`spotLeft`/`spotsLeft`), the first is for 1 and the
6
+ // second for any other number. The event's own form title, questions and
7
+ // button keep their words. Other languages fall back to English.
8
+ export interface RsvpStrings {
9
+ /** "12 going · 3 spots left" (the page puts the numbers in bold) */
10
+ personGoing: string;
11
+ peopleGoing: string;
12
+ spotLeft: string;
13
+ spotsLeft: string;
14
+ /** the event page's form */
15
+ saveSeat: string;
16
+ nameField: string;
17
+ emailField: string;
18
+ seatsField: string;
19
+ /** the button when the form has no label of its own: confirmed right away, or the organiser approves */
20
+ confirmSeat: string;
21
+ requestSeat: string;
22
+ full: string;
23
+ closed: string;
24
+ saveError: string;
25
+ /** the RSVP's own page */
26
+ booked: string;
27
+ yours: string;
28
+ requestSent: string;
29
+ awaitingApproval: string;
30
+ notAvailable: string;
31
+ cancelledTitle: string;
32
+ emailWhenApproved: string;
33
+ confirmationOnItsWay: string;
34
+ declined: string;
35
+ cancelledText: string;
36
+ what: string;
37
+ when: string;
38
+ where: string;
39
+ status: string;
40
+ reference: string;
41
+ statuses: Record<'pending' | 'confirmed' | 'cancelled' | 'declined', string>;
42
+ addToCalendar: string;
43
+ downloadIcs: string;
44
+ cancel: string;
45
+ cancelConfirm: string;
46
+ cancelError: string;
47
+ backTo: string;
48
+ keepLink: string;
49
+ }
50
+
51
+ const STRINGS: Record<string, RsvpStrings> = {
52
+ en: {
53
+ personGoing: '{count} going', peopleGoing: '{count} going', spotLeft: '{count} spot left', spotsLeft: '{count} spots left',
54
+ saveSeat: 'Save my seat', nameField: 'Your name', emailField: 'Email', seatsField: 'Seats', confirmSeat: 'Confirm my seat', requestSeat: 'Request a seat',
55
+ full: 'This event is full.', closed: 'RSVPs are closed.', saveError: 'Couldn’t save your RSVP. Please try again.',
56
+ booked: 'You’re booked', yours: 'Your booking', requestSent: 'Request sent', awaitingApproval: 'Waiting for approval', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
57
+ emailWhenApproved: 'We’ll email {email} once it’s approved.', confirmationOnItsWay: 'A confirmation is on its way to {email}.',
58
+ declined: 'Sorry, the organiser couldn’t confirm it.', cancelledText: 'This booking is cancelled.',
59
+ what: 'What', when: 'When', where: 'Where', status: 'Status', reference: 'Reference',
60
+ statuses: { pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', declined: 'Declined' },
61
+ addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', cancel: 'Cancel RSVP', cancelConfirm: 'Cancel this RSVP?', cancelError: 'Couldn’t cancel — please try again.',
62
+ backTo: 'Back to {title}', keepLink: 'Keep this link — anyone with it can change this booking.',
63
+ },
64
+ es: {
65
+ personGoing: '{count} va', peopleGoing: '{count} van', spotLeft: '{count} lugar disponible', spotsLeft: '{count} lugares disponibles',
66
+ saveSeat: 'Apartar mi lugar', nameField: 'Tu nombre', emailField: 'Correo electrónico', seatsField: 'Lugares', confirmSeat: 'Confirmar mi lugar', requestSeat: 'Solicitar un lugar',
67
+ full: 'Este evento está lleno.', closed: 'Las confirmaciones están cerradas.', saveError: 'No se pudo guardar tu confirmación. Inténtalo de nuevo.',
68
+ booked: 'Ya tienes tu lugar', yours: 'Tu confirmación', requestSent: 'Solicitud enviada', awaitingApproval: 'En espera de aprobación', notAvailable: 'No se pudo confirmar', cancelledTitle: 'Confirmación cancelada',
69
+ emailWhenApproved: 'Te escribiremos a {email} cuando se apruebe.', confirmationOnItsWay: 'Te enviamos la confirmación a {email}.',
70
+ declined: 'Lo sentimos, el organizador no pudo confirmarla.', cancelledText: 'Esta confirmación se canceló.',
71
+ what: 'Qué', when: 'Cuándo', where: 'Dónde', status: 'Estado', reference: 'Referencia',
72
+ statuses: { pending: 'Pendiente', confirmed: 'Confirmado', cancelled: 'Cancelado', declined: 'Rechazado' },
73
+ addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', cancel: 'Cancelar mi confirmación', cancelConfirm: '¿Cancelar tu confirmación?', cancelError: 'No se pudo cancelar. Inténtalo de nuevo.',
74
+ backTo: 'Volver a {title}', keepLink: 'Guarda este enlace: quien lo tenga puede cambiar esta confirmación.',
75
+ },
76
+ fr: {
77
+ personGoing: '{count} participant', peopleGoing: '{count} participants', spotLeft: '{count} place restante', spotsLeft: '{count} places restantes',
78
+ saveSeat: 'Réserver ma place', nameField: 'Votre nom', emailField: 'E-mail', seatsField: 'Places', confirmSeat: 'Confirmer ma place', requestSeat: 'Demander une place',
79
+ full: 'Cet événement est complet.', closed: 'Les inscriptions sont closes.', saveError: 'Impossible d’enregistrer votre inscription. Veuillez réessayer.',
80
+ booked: 'Votre place est réservée', yours: 'Votre inscription', requestSent: 'Demande envoyée', awaitingApproval: 'En attente de validation', notAvailable: 'Non confirmée', cancelledTitle: 'Inscription annulée',
81
+ emailWhenApproved: 'Nous écrirons à {email} dès qu’elle sera validée.', confirmationOnItsWay: 'Une confirmation est en route vers {email}.',
82
+ declined: 'Désolés, l’organisateur n’a pas pu la confirmer.', cancelledText: 'Cette inscription est annulée.',
83
+ what: 'Quoi', when: 'Quand', where: 'Où', status: 'Statut', reference: 'Référence',
84
+ statuses: { pending: 'En attente', confirmed: 'Confirmée', cancelled: 'Annulée', declined: 'Refusée' },
85
+ addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', cancel: 'Annuler mon inscription', cancelConfirm: 'Annuler cette inscription ?', cancelError: 'Impossible d’annuler. Veuillez réessayer.',
86
+ backTo: 'Retour à {title}', keepLink: 'Conservez ce lien : toute personne qui l’a peut modifier cette inscription.',
87
+ },
88
+ de: {
89
+ personGoing: '{count} Zusage', peopleGoing: '{count} Zusagen', spotLeft: '{count} Platz frei', spotsLeft: '{count} Plätze frei',
90
+ saveSeat: 'Platz reservieren', nameField: 'Ihr Name', emailField: 'E-Mail', seatsField: 'Plätze', confirmSeat: 'Platz bestätigen', requestSeat: 'Platz anfragen',
91
+ full: 'Diese Veranstaltung ist ausgebucht.', closed: 'Anmeldungen sind geschlossen.', saveError: 'Ihre Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
92
+ booked: 'Ihr Platz ist reserviert', yours: 'Ihre Anmeldung', requestSent: 'Anfrage gesendet', awaitingApproval: 'Wartet auf Freigabe', notAvailable: 'Nicht bestätigt', cancelledTitle: 'Anmeldung storniert',
93
+ emailWhenApproved: 'Wir schreiben an {email}, sobald sie freigegeben ist.', confirmationOnItsWay: 'Eine Bestätigung ist unterwegs an {email}.',
94
+ declined: 'Leider konnte der Veranstalter sie nicht bestätigen.', cancelledText: 'Diese Anmeldung ist storniert.',
95
+ what: 'Was', when: 'Wann', where: 'Wo', status: 'Status', reference: 'Referenz',
96
+ statuses: { pending: 'Ausstehend', confirmed: 'Bestätigt', cancelled: 'Storniert', declined: 'Abgelehnt' },
97
+ addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', cancel: 'Anmeldung stornieren', cancelConfirm: 'Diese Anmeldung stornieren?', cancelError: 'Stornieren fehlgeschlagen. Bitte versuchen Sie es erneut.',
98
+ backTo: 'Zurück zu {title}', keepLink: 'Bewahren Sie diesen Link auf – wer ihn hat, kann diese Anmeldung ändern.',
99
+ },
100
+ pt: {
101
+ personGoing: '{count} confirmado', peopleGoing: '{count} confirmados', spotLeft: '{count} vaga disponível', spotsLeft: '{count} vagas disponíveis',
102
+ saveSeat: 'Reservar minha vaga', nameField: 'Seu nome', emailField: 'E-mail', seatsField: 'Vagas', confirmSeat: 'Confirmar minha vaga', requestSeat: 'Solicitar uma vaga',
103
+ full: 'Este evento está lotado.', closed: 'As inscrições estão encerradas.', saveError: 'Não foi possível salvar sua inscrição. Tente novamente.',
104
+ booked: 'Sua vaga está reservada', yours: 'Sua inscrição', requestSent: 'Solicitação enviada', awaitingApproval: 'Aguardando aprovação', notAvailable: 'Não confirmada', cancelledTitle: 'Inscrição cancelada',
105
+ emailWhenApproved: 'Vamos escrever para {email} assim que for aprovada.', confirmationOnItsWay: 'Uma confirmação está a caminho de {email}.',
106
+ declined: 'Desculpe, o organizador não pôde confirmá-la.', cancelledText: 'Esta inscrição foi cancelada.',
107
+ what: 'O quê', when: 'Quando', where: 'Onde', status: 'Status', reference: 'Referência',
108
+ statuses: { pending: 'Pendente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Recusada' },
109
+ addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', cancel: 'Cancelar inscrição', cancelConfirm: 'Cancelar esta inscrição?', cancelError: 'Não foi possível cancelar. Tente novamente.',
110
+ backTo: 'Voltar para {title}', keepLink: 'Guarde este link: qualquer pessoa com ele pode alterar esta inscrição.',
111
+ },
112
+ };
113
+
114
+ export const rsvpStrings = (locale: string | null | undefined): RsvpStrings =>
115
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
116
+
117
+ /** The words for `count` split where it goes, so a page can bold the number: aroundCount(3, w.spotLeft, w.spotsLeft) → ["", " spots left"]. */
118
+ export function aroundCount(count: number, one: string, other: string): [string, string] {
119
+ const [before, after = ''] = (count === 1 ? one : other).split('{count}');
120
+ return [before, after];
121
+ }
@@ -6,6 +6,8 @@ import { answerFields } from '../forms/schema';
6
6
  import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
7
7
  import { seatsTaken } from '../booking/service';
8
8
  import { localizeDoc } from '../translations';
9
+ import { siteLang } from '../dates';
10
+ import { rsvpStrings } from '../calendar/rsvp-words';
9
11
  import { queryParams } from './http';
10
12
  import { requireCalendar } from './calendar';
11
13
  import { formJson } from './forms';
@@ -54,12 +56,18 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
54
56
  const reason = closed ?? (left === 0 ? 'full' as const : null);
55
57
  const form = rsvpQuestions(o.event);
56
58
  const title = localizeDoc(o.event, locale ?? undefined).title;
59
+ // the built-in button speaks the app's language (the site's own without languages set up)
60
+ let submitLabel = form?.submitLabel ?? '';
61
+ if (form && !submitLabel) {
62
+ const words = rsvpStrings(locale ?? siteLang(await repo.getSettings()));
63
+ submitLabel = rsvp.autoConfirm ? words.confirmSeat : words.requestSeat;
64
+ }
57
65
  return {
58
66
  event: { id: o.event.id, slug: o.event.slug, title },
59
67
  start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
60
68
  open: !reason, reason, going, left, capacity: rsvp.capacity,
61
69
  // seats one RSVP may take right now
62
70
  maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
63
- form: form ? { ...await formJson(form), submitLabel: form.submitLabel || (rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat') } : null,
71
+ form: form ? { ...await formJson(form), submitLabel } : null,
64
72
  };
65
73
  }
@@ -8,21 +8,32 @@ import { resourceZone } from '../../../lib/booking/availability';
8
8
  import { eventTimeZone, locationLine } from '../../../lib/calendar/events';
9
9
  import { siteOrigin } from '../../../lib/seo';
10
10
  import { waitUntilFrom } from '../../../lib/webhooks';
11
+ import { I18N, localePath, requestLocale } from '../../../lib/i18n';
12
+ import { siteLang } from '../../../lib/dates';
13
+ import { isExtraLocale, localizeDoc } from '../../../lib/translations';
14
+ import { rsvpStrings } from '../../../lib/calendar/rsvp-words';
15
+ import { fill } from '../../../lib/live/words';
11
16
 
12
17
  // The booker's own page, reached from the confirmation and every email: see
13
18
  // the status, add it to a calendar, move it or cancel it. The token in the
14
- // URL is the only credential — it's long and random, and never listed.
19
+ // URL is the only credential — it's long and random, and never listed. An
20
+ // RSVP's page speaks the page's language (/es/book/manage/… in Spanish);
21
+ // room bookings stay in English like the rest of their pages.
15
22
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
16
23
  const repo = await getRepo(Astro.locals);
17
24
  const booking = await repo.getBookingByToken(Astro.params.token ?? '');
18
25
  if (!booking || (!bookingsEnabled() && booking.kind !== 'rsvp')) return new Response(null, { status: 404, statusText: 'Not found' });
19
26
  const settings = await repo.getSettings();
20
27
  const origin = siteOrigin(settings, Astro.url);
21
- const [resource, link, event] = await Promise.all([
28
+ const [resource, link, rawEvent] = await Promise.all([
22
29
  booking.resourceId ? repo.getResource(booking.resourceId) : Promise.resolve(null),
23
30
  booking.linkId ? repo.getBookingLink(booking.linkId) : Promise.resolve(null),
24
31
  booking.eventId ? repo.getEvent(booking.eventId) : Promise.resolve(null),
25
32
  ]);
33
+ const isRsvp = booking.kind === 'rsvp';
34
+ const locale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
35
+ const w = rsvpStrings(isRsvp ? locale || siteLang(settings) : 'en');
36
+ const event = rawEvent && localizeDoc(rawEvent, locale || undefined);
26
37
  const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking';
27
38
  const location = event ? locationLine(event.location) : resource?.name ?? '';
28
39
  const url = origin + Astro.url.pathname;
@@ -40,25 +51,25 @@ if (Astro.request.method === 'POST') {
40
51
  try {
41
52
  current = await guestCancel(repo, booking, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
42
53
  } catch (err) {
43
- error = err instanceof BookingError ? err.message : 'Couldn’t cancel — please try again.';
54
+ error = err instanceof BookingError ? err.message : w.cancelError;
44
55
  }
45
56
  }
46
57
  }
47
58
  const tz = event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
48
- const when = whenText(current, tz, settings, link?.mode === 'day' || event?.allDay);
59
+ const when = whenText(current, tz, isRsvp && isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
49
60
  const active = current.status === 'pending' || current.status === 'confirmed';
50
61
  const upcoming = current.end > Date.now();
51
62
  const can = guestChanges(current, link);
52
63
  const isNew = Astro.url.searchParams.has('new');
53
64
  const moved = Astro.url.searchParams.has('moved');
54
- const heading = current.status === 'cancelled' ? 'Booking cancelled'
55
- : current.status === 'declined' ? 'Not available'
56
- : current.status === 'pending' ? (isNew ? 'Request sent' : 'Waiting for approval')
57
- : moved ? 'Booking moved' : isNew ? 'You’re booked' : 'Your booking';
58
- const lede = current.status === 'pending' ? `We’ll email ${current.email} once it’s approved.`
59
- : current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || `A confirmation is on its way to ${current.email}.`)
60
- : current.status === 'declined' ? 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.'
61
- : 'This booking is cancelled.';
65
+ const heading = current.status === 'cancelled' ? w.cancelledTitle
66
+ : current.status === 'declined' ? w.notAvailable
67
+ : current.status === 'pending' ? (isNew ? w.requestSent : w.awaitingApproval)
68
+ : moved ? 'Booking moved' : isNew ? w.booked : w.yours;
69
+ const lede = current.status === 'pending' ? fill(w.emailWhenApproved, { email: current.email })
70
+ : current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || fill(w.confirmationOnItsWay, { email: current.email }))
71
+ : current.status === 'declined' ? (isRsvp ? w.declined : 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.')
72
+ : w.cancelledText;
62
73
  const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchParams({ action: 'TEMPLATE', text: title, dates: `${new Date(current.start).toISOString().replace(/[-:]|\.\d{3}/g, '')}/${new Date(current.end).toISOString().replace(/[-:]|\.\d{3}/g, '')}`, details: url, location })}`;
63
74
  ---
64
75
 
@@ -73,22 +84,22 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
73
84
  <p class="pc-manage-lede">{lede}</p>
74
85
  {error && <p class="pc-bk-error">{error}</p>}
75
86
  <dl class="pc-manage-card">
76
- <div><dt>What</dt><dd>{title}</dd></div>
77
- <div><dt>When</dt><dd>{when}</dd></div>
78
- {location && <div><dt>Where</dt><dd>{location}</dd></div>}
79
- {current.seats > 1 && <div><dt>Seats</dt><dd>{current.seats}</dd></div>}
80
- <div><dt>Status</dt><dd class={`pc-manage-status is-${current.status}`}>{current.status[0].toUpperCase() + current.status.slice(1)}</dd></div>
81
- <div><dt>Reference</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
87
+ <div><dt>{w.what}</dt><dd>{title}</dd></div>
88
+ <div><dt>{w.when}</dt><dd>{when}</dd></div>
89
+ {location && <div><dt>{w.where}</dt><dd>{location}</dd></div>}
90
+ {current.seats > 1 && <div><dt>{w.seatsField}</dt><dd>{current.seats}</dd></div>}
91
+ <div><dt>{w.status}</dt><dd class={`pc-manage-status is-${current.status}`}>{w.statuses[current.status]}</dd></div>
92
+ <div><dt>{w.reference}</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
82
93
  </dl>
83
94
  {active && upcoming && (
84
95
  <div class="pc-manage-actions">
85
96
  {current.status === 'confirmed' && (
86
97
  <details class="pc-ev-add">
87
- <summary>Add to calendar</summary>
98
+ <summary>{w.addToCalendar}</summary>
88
99
  <div class="pc-ev-add-menu">
89
100
  <a href={googleUrl} target="_blank" rel="noopener">Google Calendar</a>
90
101
  <a href="?ics=1">Apple Calendar</a>
91
- <a href="?ics=1" download="booking.ics">Download .ics</a>
102
+ <a href="?ics=1" download="booking.ics">{w.downloadIcs}</a>
92
103
  </div>
93
104
  </details>
94
105
  )}
@@ -96,17 +107,17 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
96
107
  <a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>Reschedule</a>
97
108
  )}
98
109
  {can.cancel && (
99
- <form method="post" onsubmit="return confirm('Cancel this booking?')">
110
+ <form method="post" onsubmit={`return confirm(${JSON.stringify(isRsvp ? w.cancelConfirm : 'Cancel this booking?')})`}>
100
111
  <input type="hidden" name="op" value="cancel" />
101
- <button class="pc-manage-btn is-danger" type="submit">Cancel {current.kind === 'rsvp' ? 'RSVP' : 'booking'}</button>
112
+ <button class="pc-manage-btn is-danger" type="submit">{isRsvp ? w.cancel : 'Cancel booking'}</button>
102
113
  </form>
103
114
  )}
104
115
  </div>
105
116
  )}
106
117
  {can.closed && <p class="pc-manage-note">Changes close {can.cutoffHours} hours before the start. Please contact us to change or cancel.</p>}
107
118
  <p class="pc-bk-foot">
108
- {event ? <a href={eventUrl(event.slug)}>Back to {event.title}</a> : resource ? <a href={resourceUrl(resource.slug)}>Back to {resource.name}</a> : null}
119
+ {event ? <a href={localePath(eventUrl(event.slug), locale)}>{fill(w.backTo, { title: event.title })}</a> : resource ? <a href={resourceUrl(resource.slug)}>{fill(w.backTo, { title: resource.name })}</a> : null}
109
120
  </p>
110
- <p class="pc-manage-note">Keep this link — anyone with it can change this booking.</p>
121
+ <p class="pc-manage-note">{w.keepLink}</p>
111
122
  </div>
112
123
  </Site>
@@ -5,7 +5,9 @@ import { formVersion } from '../../lib/forms/service';
5
5
  import { renderFields, hiddenFields } from '../../lib/forms/render';
6
6
  import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
7
7
  import { submitEventForm, prepareLegacyEventForm } from '../../lib/forms/events';
8
- import { requestLocale } from '../../lib/i18n';
8
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
9
+ import { siteLang } from '../../lib/dates';
10
+ import { rsvpStrings } from '../../lib/calendar/rsvp-words';
9
11
  import { localizeDoc, isExtraLocale } from '../../lib/translations';
10
12
  import Site from '../../layouts/Site.astro';
11
13
  import EventPage from '../../components/EventPage.astro';
@@ -45,7 +47,8 @@ const on = Astro.url.searchParams.get('on') ?? undefined;
45
47
  const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-\d{2}$/.test(on) ? on : undefined);
46
48
  if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found' });
47
49
  const origin = siteOrigin(settings, Astro.url);
48
- // RSVP: "Save my seat" posts back here and lands on the manage page
50
+ // RSVP: "Save my seat" posts back here and lands on the manage page, in the
51
+ // visitor's language
49
52
  let rsvpError = '';
50
53
  let formValues: Record<string,string> = {};
51
54
  let coreValues = { name: '', email: '', seats: '1' };
@@ -61,9 +64,9 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
61
64
  formValues = readAnswers(form, definition);
62
65
  coreValues = { name: String(form.get('name') || '').slice(0,120), email: String(form.get('email') || '').slice(0,200), seats: String(form.get('seats') || '1') };
63
66
  const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
64
- return Astro.redirect(`${manageUrl(booking.manageToken)}?new=1`, 303);
67
+ return Astro.redirect(`${localePath(manageUrl(booking.manageToken), locale)}?new=1`, 303);
65
68
  } catch (err) {
66
- rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : 'Couldn’t save your RSVP. Please try again.';
69
+ rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings)).saveError;
67
70
  }
68
71
  }
69
72
  const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;