@acms-subir/core 0.3.35 → 0.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/docs/mobile.md +7 -4
  3. package/docs/theme-authoring.md +5 -4
  4. package/integration.mjs +3 -1
  5. package/mobile-template/client/AGENTS.md +2 -1
  6. package/mobile-template/client/README.md +1 -1
  7. package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
  8. package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
  9. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +274 -0
  10. package/mobile-template/recipes/events/recipe.json +6 -3
  11. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +274 -0
  12. package/mobile-template/recipes/rsvp/recipe.json +15 -0
  13. package/package.json +1 -1
  14. package/src/components/EventPage.astro +14 -8
  15. package/src/layouts/Site.astro +1 -0
  16. package/src/lib/cache/policy.ts +2 -2
  17. package/src/lib/calendar/rsvp-words.ts +121 -0
  18. package/src/lib/legal/app-privacy.ts +5 -3
  19. package/src/lib/legal/text-en.ts +1 -0
  20. package/src/lib/legal/text-es.ts +1 -0
  21. package/src/lib/mobile/bookings.ts +17 -11
  22. package/src/lib/mobile/forms.ts +21 -11
  23. package/src/lib/mobile/manage.ts +2 -0
  24. package/src/lib/mobile/openapi.ts +8 -3
  25. package/src/lib/mobile/rsvp.ts +73 -0
  26. package/src/lib/mobile/schemas.ts +9 -1
  27. package/src/lib/mobile/site.ts +3 -1
  28. package/src/lib/schema.ts +2 -1
  29. package/src/lib/site-chrome.ts +8 -0
  30. package/src/pages/admin/calendar/bookings.astro +3 -1
  31. package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
  32. package/src/pages/book/manage/[token].astro +35 -24
  33. package/src/pages/events/[slug].astro +7 -4
  34. package/src/theme-sdk/index.ts +2 -0
@@ -0,0 +1,274 @@
1
+ import { Fragment, useEffect, useState } from 'react';
2
+ import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
3
+ import * as Crypto from 'expo-crypto';
4
+ import * as SecureStore from 'expo-secure-store';
5
+ import { isAcmsError, type FormField } from '@acms-subir/client';
6
+ import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
7
+
8
+ // RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
9
+ // with `start` exactly as useEvents returns it. Shows going and spots left,
10
+ // seats, contact details and the event's questions, then the person's RSVP
11
+ // with a way to cancel it; or why the date takes no RSVPs. Same rules as the
12
+ // event's web page: open until the date ends, and the organiser may approve
13
+ // each RSVP. useRsvp keeps one idempotency key per RSVP, so sending again
14
+ // after a timeout can't register anyone twice. Its words come from
15
+ // useStrings() in the app's language; the event's own form title, labels and
16
+ // button win when the site has them.
17
+
18
+ // The RSVP's private manage token, one per event date, lives only in secure
19
+ // storage (never a URL or log). Keys allow letters, digits, ".", "-" and "_".
20
+ // Web has no secure storage: there the confirmation email's link is the way back.
21
+ const tokenKey = (eventId: string, start: string) => `rsvp.${eventId}.${start}`.replace(/[^A-Za-z0-9._-]/g, '_');
22
+ const keychain = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
23
+ const tokens = {
24
+ get: async (key: string) => (Platform.OS === 'web' ? null : SecureStore.getItemAsync(key, keychain).catch(() => null)),
25
+ set: async (key: string, token: string) => { if (Platform.OS !== 'web') await SecureStore.setItemAsync(key, token, keychain).catch(() => {}); },
26
+ remove: async (key: string) => { if (Platform.OS !== 'web') await SecureStore.deleteItemAsync(key, keychain).catch(() => {}); },
27
+ };
28
+
29
+ type Answers = Record<string, string | string[]>;
30
+ const OTHER = '__other__';
31
+ const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
32
+ // "Other" choices send the typed text instead
33
+ const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
34
+ if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
35
+ return value === OTHER ? other.trim() : (value ?? '');
36
+ };
37
+ // a count in bold inside its words ("{count} going")
38
+ const bold = (template: string, n: string) => template.split('{count}').map((part, i) => (i ? <Fragment key={i}><Text style={{ fontWeight: '700' }}>{n}</Text>{part}</Fragment> : part));
39
+
40
+ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
41
+ const t = useTheme();
42
+ const words = useStrings();
43
+ const { locale } = useLocale();
44
+ const { data: site } = useSite();
45
+ const guest = useGuest();
46
+ const status = useRsvpStatus(slug, start);
47
+ const rsvp = useRsvp(slug, start, { createKey: Crypto.randomUUID });
48
+ const s = status.data;
49
+ const key = s ? tokenKey(s.event.id, s.start) : null;
50
+ // undefined until secure storage has answered
51
+ const [stored, setStored] = useState<string | null | undefined>(undefined);
52
+ const changes = site?.features.bookingChanges === true;
53
+ const managed = useManagedBooking(changes ? stored : null);
54
+ const cancel = useCancelBooking(stored);
55
+ const [confirming, setConfirming] = useState(false);
56
+ const [notice, setNotice] = useState('');
57
+ const [name, setName] = useState('');
58
+ const [email, setEmail] = useState('');
59
+ const [phone, setPhone] = useState('');
60
+ const [seats, setSeats] = useState(1);
61
+ const [values, setValues] = useState<Answers>({});
62
+ const [others, setOthers] = useState<Record<string, string>>({});
63
+ const [tried, setTried] = useState(false);
64
+
65
+ useEffect(() => {
66
+ if (!key) return;
67
+ let live = true;
68
+ void tokens.get(key).then((token) => { if (live) setStored(token); });
69
+ return () => { live = false; };
70
+ }, [key]);
71
+ // signed in for "My bookings": that's their email (guest sign-in knows no name)
72
+ useEffect(() => {
73
+ if (site?.features.guestSignIn && guest.email) setEmail((e) => e || guest.email!);
74
+ }, [site?.features.guestSignIn, guest.email]);
75
+ // cancelled (here, from the email link or by the organiser) or gone: forget the token
76
+ const gone = managed.data?.booking.status === 'cancelled' || (isAcmsError(managed.error) && managed.error.status === 404);
77
+ useEffect(() => {
78
+ if (!key || !stored || !gone) return;
79
+ void tokens.remove(key);
80
+ setStored(null);
81
+ setConfirming(false);
82
+ rsvp.reset();
83
+ if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
84
+ }, [key, stored, gone]);
85
+
86
+ if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
87
+ const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
88
+ const chip = (on: boolean) => ({ paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel });
89
+ const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
90
+ const num = (n: number) => n.toLocaleString(locale);
91
+ const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
92
+ const going = (
93
+ <Text style={t.styles.text.body}>
94
+ {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
95
+ {s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
96
+ </Text>
97
+ );
98
+
99
+ // ---- their RSVP: just sent, or kept from before ----
100
+ const mine = managed.data && managed.data.booking.status !== 'cancelled' ? managed.data : null;
101
+ const made = rsvp.data?.booking;
102
+ if (stored === undefined || (stored && changes && managed.isPending && !made)) return <ActivityIndicator color={t.colors.accent} />;
103
+ // offline or busy: say so rather than offer a second RSVP
104
+ if (stored && changes && managed.error && !gone && !made) return (
105
+ <View style={[t.styles.card, { gap: 8 }]}>
106
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{words.yourRsvp}</Text>
107
+ <Text style={t.styles.text.muted}>{words.loadError}</Text>
108
+ <Button label={words.tryAgain} variant="outline" onPress={() => void managed.refetch()} />
109
+ </View>
110
+ );
111
+ if ((stored && mine) || made) {
112
+ const state = mine?.booking.status ?? made!.status;
113
+ const who = mine?.booking.email ?? made!.email;
114
+ const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
115
+ const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
116
+ return (
117
+ <View style={[t.styles.card, { gap: 8 }]}>
118
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
119
+ <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
120
+ {seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
121
+ </Text>
122
+ <Text style={t.styles.text.body}>
123
+ {state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
124
+ : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
125
+ </Text>
126
+ {state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
127
+ {canCancel && state !== 'declined' && (confirming ? (
128
+ <View style={{ gap: 8 }}>
129
+ <Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
130
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
131
+ <Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
132
+ <Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
133
+ </View>
134
+ </View>
135
+ ) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
136
+ {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
137
+ {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
138
+ </View>
139
+ );
140
+ }
141
+
142
+ if (s.reason === 'disabled') return null;
143
+ if (!s.open) return (
144
+ <View style={[t.styles.card, { gap: 6 }]}>
145
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
146
+ {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
147
+ </View>
148
+ );
149
+
150
+ // ---- the form ----
151
+ const form = s.form;
152
+ const fields = form?.fields ?? [];
153
+ const answers: Answers = Object.fromEntries(fields.filter((f) => f.type !== 'heading').map((f) => [f.id, withOther(f, values[f.id], others[f.id] ?? '')]));
154
+ const problems = answerProblems(fields, answers);
155
+ const nameProblem = !name.trim() ? words.nameProblem : '';
156
+ const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
157
+ // the form may ask for a phone itself
158
+ const askPhone = !fields.some((f) => f.type === 'phone');
159
+ const chosen = Math.min(seats, s.maxSeats) || 1;
160
+ const submit = () => {
161
+ setTried(true);
162
+ setNotice('');
163
+ if (nameProblem || emailProblem || Object.keys(problems).length) return;
164
+ rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers }, {
165
+ onSuccess: (data) => { if (key) void tokens.set(key, data.manageToken); setStored(data.manageToken); },
166
+ });
167
+ };
168
+ const set = (id: string, v: string | string[]) => setValues((a) => ({ ...a, [id]: v }));
169
+ const label = (text: string, required: boolean) => (
170
+ <Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel={words.required}> *</Text> : ''}</Text>
171
+ );
172
+ const text = (lbl: string, value: string, onChange: (v: string) => void, o: { required?: boolean; keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel'; placeholder?: string; help?: string; problem?: string } = {}) => (
173
+ <View key={lbl} style={{ gap: 6 }}>
174
+ {label(lbl, !!o.required)}
175
+ {!!o.help && <Text style={t.styles.text.small}>{o.help}</Text>}
176
+ <TextInput value={value} onChangeText={onChange} keyboardType={o.keyboard} multiline={o.multiline} autoComplete={o.auto} placeholder={o.placeholder}
177
+ autoCapitalize={o.auto === 'email' || o.keyboard === 'email-address' || o.keyboard === 'url' ? 'none' : 'sentences'} placeholderTextColor={t.colors.muted}
178
+ accessibilityLabel={lbl} accessibilityHint={o.problem}
179
+ style={[input, o.multiline && { minHeight: 96, textAlignVertical: 'top' }, !!o.problem && { borderColor: t.colors.ink }]} />
180
+ {!!o.problem && <Text style={errorText}>{o.problem}</Text>}
181
+ </View>
182
+ );
183
+ const field = (f: FormField) => {
184
+ const problem = tried && problems[f.id] ? answerProblemText(f, problems[f.id], words) : '';
185
+ if (f.type === 'heading') return (
186
+ <View key={f.id} style={{ gap: 2, paddingTop: 4 }}>
187
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{f.label}</Text>
188
+ {!!f.help && <Text style={t.styles.text.muted}>{f.help}</Text>}
189
+ </View>
190
+ );
191
+ if (f.type === 'checkbox' || f.type === 'consent') return (
192
+ <View key={f.id} style={{ gap: 6 }}>
193
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
194
+ <Text style={[t.styles.text.body, { flex: 1 }]}>{f.label}{f.required ? ' *' : ''}</Text>
195
+ <Switch accessibilityLabel={f.label} value={values[f.id] === 'yes'} onValueChange={(v) => set(f.id, v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} />
196
+ </View>
197
+ {!!f.help && <Text style={t.styles.text.small}>{f.help}</Text>}
198
+ {!!problem && <Text style={errorText}>{problem}</Text>}
199
+ </View>
200
+ );
201
+ if (f.type === 'select' || f.type === 'radio' || f.type === 'checkboxes') {
202
+ const many = f.type === 'checkboxes';
203
+ const current = values[f.id];
204
+ const isOn = (o: string) => (many ? Array.isArray(current) && current.includes(o) : current === o);
205
+ const toggle = (o: string) => {
206
+ if (!many) return set(f.id, current === o && !f.required ? '' : o);
207
+ const list = Array.isArray(current) ? current : [];
208
+ set(f.id, list.includes(o) ? list.filter((x) => x !== o) : [...list, o]);
209
+ };
210
+ const choices = f.allowOther && f.type !== 'select' ? [...f.options, OTHER] : f.options;
211
+ return (
212
+ <View key={f.id} style={{ gap: 6 }}>
213
+ {label(f.label, f.required)}
214
+ {!!f.help && <Text style={t.styles.text.small}>{f.help}</Text>}
215
+ <View accessibilityRole={many ? undefined : 'radiogroup'} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
216
+ {choices.map((o) => (
217
+ <Pressable key={o} accessibilityRole={many ? 'checkbox' : 'radio'} accessibilityState={{ checked: isOn(o) }} onPress={() => toggle(o)} style={chip(isOn(o))}>
218
+ <Text style={[t.styles.text.body, isOn(o) && { fontWeight: '600' }]}>{o === OTHER ? words.other : o}</Text>
219
+ </Pressable>
220
+ ))}
221
+ </View>
222
+ {isOn(OTHER) && (
223
+ <TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder={words.other} accessibilityLabel={fill(words.otherAnswer, { label: f.label })}
224
+ placeholderTextColor={t.colors.muted} style={input} />
225
+ )}
226
+ {!!problem && <Text style={errorText}>{problem}</Text>}
227
+ </View>
228
+ );
229
+ }
230
+ return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
231
+ required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
232
+ placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
233
+ });
234
+ };
235
+
236
+ const error = rsvp.error;
237
+ return (
238
+ <View style={[t.styles.card, { gap: 16 }]}>
239
+ <View style={{ gap: 4 }}>
240
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
241
+ {going}
242
+ {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
243
+ {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
244
+ </View>
245
+ {text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
246
+ {text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
247
+ {askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
248
+ {s.maxSeats > 1 && (
249
+ <View style={{ gap: 6 }}>
250
+ {label(words.seatsField, false)}
251
+ <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
252
+ {seatChoices(s).map((n) => (
253
+ <Pressable key={n} accessibilityRole="radio" accessibilityLabel={seatsText(n)} accessibilityState={{ checked: n === chosen }} onPress={() => setSeats(n)} style={[chip(n === chosen), { minWidth: 48, alignItems: 'center' }]}>
254
+ <Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
255
+ </Pressable>
256
+ ))}
257
+ </View>
258
+ </View>
259
+ )}
260
+ {fields.map(field)}
261
+ {error && (
262
+ <View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
263
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
264
+ <Text style={t.styles.text.muted}>
265
+ {isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
266
+ </Text>
267
+ </View>
268
+ )}
269
+ {tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
270
+ <Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
271
+ <Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
272
+ </View>
273
+ );
274
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "kind": "client",
3
+ "title": "RSVP",
4
+ "description": "An RSVP panel for an event date: going and spots left, seats, the event's questions with inline errors, then the person's RSVP with Cancel, or why the date is full or closed. Already part of the events recipe; add it to an event screen of your own.",
5
+ "packages": [
6
+ "expo-crypto",
7
+ "expo-secure-store"
8
+ ],
9
+ "routes": [],
10
+ "next": [
11
+ "Mount it on your event screen where the date is known: <RsvpPanel slug={occurrence.event.slug} start={occurrence.start} /> from '../../components/RsvpPanel', with start exactly as useEvents returns it (the UTC ISO string, e.g. pass o.start through router params), and only when occurrence.event.rsvp?.enabled",
12
+ "The screen should scroll and avoid the keyboard: wrap it in <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}> with <ScrollView keyboardShouldPersistTaps=\"handled\">",
13
+ "RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too"
14
+ ]
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.35",
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>
@@ -179,6 +179,7 @@ const chrome: SiteChrome = {
179
179
  schemeToggle,
180
180
  liveHeader: Boolean(liveSignals?.header),
181
181
  headerStyle: String(tokens.header), footerStyle: String(tokens.footer),
182
+ lang: htmlLang, words,
182
183
  };
183
184
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
185
  const HeaderTpl = design.theme.HeaderTemplate as any;
@@ -31,8 +31,8 @@ function privatePrefixes() {
31
31
  ...(CALENDAR.enabled ? [CALENDAR.bookBase] : []),
32
32
  ];
33
33
  }
34
- // Public app API routes that are personal, transactional or write-only.
35
- const PRIVATE_API = /^\/api\/v1\/(?:admin|auth|guest|bookings|availability|devices|push|forms)(?:\/|$)/;
34
+ // Public app API routes that are personal, transactional, live or write-only.
35
+ const PRIVATE_API = /^\/api\/v1\/(?:admin|auth|guest|bookings|availability|devices|push|forms|events\/[^/]+\/rsvp)(?:\/|$)/;
36
36
 
37
37
  const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}.`);
38
38
 
@@ -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
+ }
@@ -10,15 +10,17 @@ export interface AppPrivacyAnswers { rows: StoreRow[]; tracking: string; notes:
10
10
 
11
11
  export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacyAnswers {
12
12
  const rows: StoreRow[] = [];
13
- const contact = [...(f.bookings ? ['bookings'] : []), ...(f.appForms ? ['forms'] : [])];
13
+ // apps can RSVP wherever the calendar is on; room bookings need bookings too
14
+ const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs'] : []), ...(f.appForms ? ['forms'] : [])];
15
+ const sends = contact.length > 1 ? `${contact.slice(0, -1).join(', ')} or ${contact.at(-1)}` : contact[0];
14
16
  if (contact.length) {
15
17
  rows.push({
16
- data: 'Name, email address, phone number', when: `When someone sends ${contact.join(' or ')} from the app`,
18
+ data: 'Name, email address, phone number', when: `When someone sends ${sends} from the app`,
17
19
  apple: 'Contact Info → Name, Email Address, Phone Number. Purpose: App Functionality. Linked to the user. Not used for tracking.',
18
20
  google: 'Personal info → Name, Email address, Phone number. Collected, not shared. Purpose: App functionality. Optional (people choose to send it).',
19
21
  });
20
22
  rows.push({
21
- data: 'Answers typed into a form or booking question', when: `When someone sends ${contact.join(' or ')} from the app`,
23
+ data: 'Answers typed into a form or booking question', when: `When someone sends ${sends} from the app`,
22
24
  apple: 'User Content → Other User Content. Purpose: App Functionality. Linked to the user. Not used for tracking.',
23
25
  google: 'App activity → Other user-generated content. Collected, not shared. Purpose: App functionality. Optional.',
24
26
  });
@@ -82,6 +82,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
82
82
  ...(app ? [f.announcements
83
83
  ? 'Our app may keep the content it last loaded on your phone so it works offline, and remembers there your notification choice and which announcements you’ve read or closed. That stays on your phone and is never sent to us.'
84
84
  : 'Our app may keep the content it last loaded on your phone so it works offline, and remembers your notification choice there.'] : []),
85
+ ...(app && f.calendar ? ['When you RSVP or book in the app, it keeps the private key that lets you see or cancel it in your phone’s secure storage, like the private link we email you.'] : []),
85
86
  ] });
86
87
  s.push({ id: 'sharing', title: 'Who receives your information', text: [
87
88
  `We don’t sell your personal information${pixels ? '' : ' or share it for advertising'}. These service providers process it on our behalf and only for the purposes above: ${list(processors(f))}. They must protect it at least as this notice describes. Some of them may store it outside your country, including in the United States.`,
@@ -83,6 +83,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
83
83
  ...(app ? [f.announcements
84
84
  ? 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones y qué anuncios leyó o cerró. Eso se queda en su teléfono y nunca se nos envía.'
85
85
  : 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones.'] : []),
86
+ ...(app && f.calendar ? ['Cuando confirma su asistencia o reserva en la app, esta guarda en el almacenamiento seguro de su teléfono la clave privada que le permite verla o cancelarla, igual que el enlace privado que le enviamos por correo.'] : []),
86
87
  ] });
87
88
  s.push({ id: 'sharing', title: 'Quién recibe sus datos', text: [
88
89
  `No vendemos sus datos personales${pixels ? '' : ' ni los compartimos con fines publicitarios'}. Estos proveedores los tratan por cuenta nuestra y solo para los fines descritos: ${list(processors(f))}. Deben protegerlos al menos como lo describe este aviso. Algunos pueden guardarlos fuera de su país, incluido Estados Unidos.`,
@@ -4,33 +4,38 @@ import type { Booking } from '../schema';
4
4
  import { ApiError, readJson } from '../api';
5
5
  import { sha256Hex } from '../api-keys';
6
6
  import { siteOrigin } from '../seo';
7
- import { eventIsPublic, expandEvents } from '../calendar/events';
8
7
  import { createResourceBooking, createRsvp, bookingJson } from '../booking/service';
9
8
  import { waitUntilFrom } from '../webhooks';
10
9
  import { requireCalendar } from './calendar';
10
+ import { appAnswers, joinAnswers } from './forms';
11
+ import { rsvpClosed, rsvpOccurrence, rsvpQuestions } from './rsvp';
11
12
 
12
13
  const common = {
13
14
  start: z.string().datetime({ offset: true }),
14
15
  name: z.string().trim().min(1).max(120), email: z.string().trim().email().max(200).transform(v => v.toLowerCase()),
15
16
  phone: z.string().trim().max(40).default(''),
17
+ answers: appAnswers.default({}),
16
18
  };
17
19
  export const bookingInput = z.union([
18
20
  z.object({ ...common, resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
19
- minutes: z.number().int().min(1).max(44640).optional(), answers: z.record(z.string().max(2000)).default({}),
21
+ minutes: z.number().int().min(1).max(44640).optional(),
20
22
  }).strict(),
21
- z.object({ ...common, event: z.string().min(1).max(200), seats: z.number().int().min(1).max(1000).default(1), answers: z.record(z.string().max(2000)).default({}), formVersion: z.string().optional() }).strict(),
23
+ // formVersion: from GET /events/{slug}/rsvp; required when the event asks questions
24
+ z.object({ ...common, event: z.string().min(1).max(200), seats: z.number().int().min(1).max(1000).default(1), formVersion: z.string().max(100).optional() }).strict(),
22
25
  ]);
23
26
  function canonical(value: unknown): string {
24
27
  if (value && typeof value === 'object' && !Array.isArray(value)) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k,v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
25
28
  return JSON.stringify(value);
26
29
  }
27
30
  const bookingData = (booking: Booking) => ({ booking: bookingJson(booking), manageToken: booking.manageToken });
31
+ // RSVPs need only the calendar (like the event's web page); room bookings need bookings too
28
32
  export async function submitBooking(repo: ContentRepository, request: Request, locals?: unknown) {
29
- requireCalendar(true);
33
+ requireCalendar();
30
34
  const key = request.headers.get('idempotency-key') ?? '';
31
35
  // Treat the key as a secret receipt. Require a fresh UUID per user intent.
32
36
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
33
37
  const body = bookingInput.parse(await readJson(request, 50000));
38
+ if (!('event' in body)) requireCalendar(true);
34
39
  const idempotency = { keyHash: await sha256Hex(key.toLowerCase()), requestHash: await sha256Hex(canonical(body)) };
35
40
  const replay = async () => {
36
41
  const record = await repo.getBookingRequest(idempotency.keyHash);
@@ -46,21 +51,22 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
46
51
  const settings = await repo.getSettings();
47
52
  const flow = { origin: siteOrigin(settings, new URL(request.url)), locals, waitUntil: waitUntilFrom(locals), idempotency };
48
53
  const start = Date.parse(body.start);
54
+ const booker = { ...body, answers: joinAnswers(body.answers) };
49
55
  let booking: Booking;
50
56
  if ('event' in body) {
51
- const event = await repo.getEventBySlug(body.event) ?? await repo.getEvent(body.event);
52
- const calendars = new Map((await repo.listCalendars()).map(c => [c.id, c]));
53
- if (!event || !eventIsPublic(event, calendars)) throw new ApiError(404, 'Event not found.');
57
+ const occurrence = await rsvpOccurrence(repo, body.event, body.start);
58
+ const { event } = occurrence;
54
59
  if (body.seats > (event.rsvp.maxSeats || 1)) throw new ApiError(400, 'Too many seats for one RSVP.');
55
- const occurrence = expandEvents([event], settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: start, windowEndMs: start + 1 }).find(o => o.start === start);
56
- if (!occurrence) throw new ApiError(400, 'Start must match an event occurrence exactly.');
57
- booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker: body, source: 'web' }, flow);
60
+ // the app must have seen the current questions: a changed form then answers 409, as on the website
61
+ // (closed dates fall through to createRsvp's own answer)
62
+ if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
63
+ booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app' }, flow);
58
64
  } else {
59
65
  const resource = await repo.getResourceBySlug(body.resource);
60
66
  const link = resource ? await repo.getBookingLinkBySlug(resource.id, body.link) : null;
61
67
  if (!resource || !link) throw new ApiError(404, 'Resource or booking link not found.');
62
68
  const minutes = body.minutes ?? (link.mode === 'day' ? 1440 : link.mode === 'range' ? link.lengths[0] ?? link.duration : link.duration);
63
- booking = await createResourceBooking(repo, { resource, link, start, end: start + minutes * 60000, booker: body, source: 'web' }, flow);
69
+ booking = await createResourceBooking(repo, { resource, link, start, end: start + minutes * 60000, booker, source: 'app' }, flow);
64
70
  }
65
71
  return { data: bookingData(booking), replayed: false };
66
72
  } catch (error) {