@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 +10 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +48 -44
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +48 -44
- package/package.json +1 -1
- package/src/components/EventPage.astro +14 -8
- package/src/lib/calendar/rsvp-words.ts +121 -0
- package/src/lib/mobile/rsvp.ts +9 -1
- package/src/pages/book/manage/[token].astro +35 -24
- package/src/pages/events/[slug].astro +7 -4
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
|
-
|
|
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(
|
|
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
|
-
|
|
91
|
-
{s.left !== null && <> ·
|
|
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}>
|
|
103
|
-
<Text style={t.styles.text.muted}>
|
|
104
|
-
<Button label=
|
|
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 ?
|
|
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' ?
|
|
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 ||
|
|
120
|
-
: state === 'pending' ?
|
|
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=
|
|
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}>
|
|
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 ?
|
|
128
|
-
<Button label=
|
|
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=
|
|
132
|
-
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ?
|
|
133
|
-
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>
|
|
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' ?
|
|
142
|
-
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ?
|
|
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() ?
|
|
152
|
-
const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ?
|
|
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=
|
|
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] ?
|
|
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 ?
|
|
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=
|
|
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' ?
|
|
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 ||
|
|
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(
|
|
242
|
-
{text(
|
|
243
|
-
{askPhone && text(
|
|
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(
|
|
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' }]}>
|
|
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 ?
|
|
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}>
|
|
266
|
-
<Button label={rsvp.isPending ?
|
|
267
|
-
<Text style={t.styles.text.small}>
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
91
|
-
{s.left !== null && <> ·
|
|
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}>
|
|
103
|
-
<Text style={t.styles.text.muted}>
|
|
104
|
-
<Button label=
|
|
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 ?
|
|
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' ?
|
|
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 ||
|
|
120
|
-
: state === 'pending' ?
|
|
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=
|
|
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}>
|
|
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 ?
|
|
128
|
-
<Button label=
|
|
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=
|
|
132
|
-
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ?
|
|
133
|
-
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>
|
|
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' ?
|
|
142
|
-
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ?
|
|
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() ?
|
|
152
|
-
const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ?
|
|
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=
|
|
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] ?
|
|
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 ?
|
|
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=
|
|
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' ?
|
|
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 ||
|
|
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(
|
|
242
|
-
{text(
|
|
243
|
-
{askPhone && text(
|
|
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(
|
|
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' }]}>
|
|
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 ?
|
|
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}>
|
|
266
|
-
<Button label={rsvp.isPending ?
|
|
267
|
-
<Text style={t.styles.text.small}>
|
|
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
|
@@ -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"
|
|
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">
|
|
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>
|
|
143
|
-
<label class="pc-bk-field"><span>
|
|
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>
|
|
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 ?
|
|
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 ?
|
|
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
|
+
}
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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 :
|
|
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' ?
|
|
55
|
-
: current.status === 'declined' ?
|
|
56
|
-
: current.status === 'pending' ? (isNew ?
|
|
57
|
-
: moved ? 'Booking moved' : isNew ?
|
|
58
|
-
const lede = current.status === 'pending' ?
|
|
59
|
-
: current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage ||
|
|
60
|
-
: current.status === 'declined' ? 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.'
|
|
61
|
-
:
|
|
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>
|
|
77
|
-
<div><dt>
|
|
78
|
-
{location && <div><dt>
|
|
79
|
-
{current.seats > 1 && <div><dt>
|
|
80
|
-
<div><dt>
|
|
81
|
-
<div><dt>
|
|
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>
|
|
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">
|
|
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=
|
|
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">
|
|
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)}>
|
|
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">
|
|
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 :
|
|
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}">`;
|