@acms-subir/core 0.3.35 → 0.3.36
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 +15 -0
- package/docs/mobile.md +7 -4
- package/docs/theme-authoring.md +5 -4
- package/integration.mjs +3 -1
- package/mobile-template/client/AGENTS.md +2 -1
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
- package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +270 -0
- package/mobile-template/recipes/events/recipe.json +6 -3
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +270 -0
- package/mobile-template/recipes/rsvp/recipe.json +15 -0
- package/package.json +1 -1
- package/src/layouts/Site.astro +1 -0
- package/src/lib/cache/policy.ts +2 -2
- package/src/lib/legal/app-privacy.ts +5 -3
- package/src/lib/legal/text-en.ts +1 -0
- package/src/lib/legal/text-es.ts +1 -0
- package/src/lib/mobile/bookings.ts +17 -11
- package/src/lib/mobile/forms.ts +21 -11
- package/src/lib/mobile/manage.ts +2 -0
- package/src/lib/mobile/openapi.ts +8 -3
- package/src/lib/mobile/rsvp.ts +65 -0
- package/src/lib/mobile/schemas.ts +9 -1
- package/src/lib/mobile/site.ts +3 -1
- package/src/lib/schema.ts +2 -1
- package/src/lib/site-chrome.ts +8 -0
- package/src/pages/admin/calendar/bookings.astro +3 -1
- package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
- package/src/theme-sdk/index.ts +2 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { 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, answerProblems, seatChoices, useCancelBooking, useGuest, useManagedBooking, useRsvp, useRsvpStatus, useSite, 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.
|
|
15
|
+
|
|
16
|
+
// The RSVP's private manage token, one per event date, lives only in secure
|
|
17
|
+
// storage (never a URL or log). Keys allow letters, digits, ".", "-" and "_".
|
|
18
|
+
// Web has no secure storage: there the confirmation email's link is the way back.
|
|
19
|
+
const tokenKey = (eventId: string, start: string) => `rsvp.${eventId}.${start}`.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
20
|
+
const keychain = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
|
|
21
|
+
const tokens = {
|
|
22
|
+
get: async (key: string) => (Platform.OS === 'web' ? null : SecureStore.getItemAsync(key, keychain).catch(() => null)),
|
|
23
|
+
set: async (key: string, token: string) => { if (Platform.OS !== 'web') await SecureStore.setItemAsync(key, token, keychain).catch(() => {}); },
|
|
24
|
+
remove: async (key: string) => { if (Platform.OS !== 'web') await SecureStore.deleteItemAsync(key, keychain).catch(() => {}); },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Answers = Record<string, string | string[]>;
|
|
28
|
+
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
|
+
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
|
|
33
|
+
// "Other" choices send the typed text instead
|
|
34
|
+
const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
|
|
35
|
+
if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
|
|
36
|
+
return value === OTHER ? other.trim() : (value ?? '');
|
|
37
|
+
};
|
|
38
|
+
const seatsText = (n: number) => (n === 1 ? '1 seat' : `${n} seats`);
|
|
39
|
+
|
|
40
|
+
export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
41
|
+
const t = useTheme();
|
|
42
|
+
const { data: site } = useSite();
|
|
43
|
+
const guest = useGuest();
|
|
44
|
+
const status = useRsvpStatus(slug, start);
|
|
45
|
+
const rsvp = useRsvp(slug, start, { createKey: Crypto.randomUUID });
|
|
46
|
+
const s = status.data;
|
|
47
|
+
const key = s ? tokenKey(s.event.id, s.start) : null;
|
|
48
|
+
// undefined until secure storage has answered
|
|
49
|
+
const [stored, setStored] = useState<string | null | undefined>(undefined);
|
|
50
|
+
const changes = site?.features.bookingChanges === true;
|
|
51
|
+
const managed = useManagedBooking(changes ? stored : null);
|
|
52
|
+
const cancel = useCancelBooking(stored);
|
|
53
|
+
const [confirming, setConfirming] = useState(false);
|
|
54
|
+
const [notice, setNotice] = useState('');
|
|
55
|
+
const [name, setName] = useState('');
|
|
56
|
+
const [email, setEmail] = useState('');
|
|
57
|
+
const [phone, setPhone] = useState('');
|
|
58
|
+
const [seats, setSeats] = useState(1);
|
|
59
|
+
const [values, setValues] = useState<Answers>({});
|
|
60
|
+
const [others, setOthers] = useState<Record<string, string>>({});
|
|
61
|
+
const [tried, setTried] = useState(false);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!key) return;
|
|
65
|
+
let live = true;
|
|
66
|
+
void tokens.get(key).then((token) => { if (live) setStored(token); });
|
|
67
|
+
return () => { live = false; };
|
|
68
|
+
}, [key]);
|
|
69
|
+
// signed in for "My bookings": that's their email (guest sign-in knows no name)
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (site?.features.guestSignIn && guest.email) setEmail((e) => e || guest.email!);
|
|
72
|
+
}, [site?.features.guestSignIn, guest.email]);
|
|
73
|
+
// cancelled (here, from the email link or by the organiser) or gone: forget the token
|
|
74
|
+
const gone = managed.data?.booking.status === 'cancelled' || (isAcmsError(managed.error) && managed.error.status === 404);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!key || !stored || !gone) return;
|
|
77
|
+
void tokens.remove(key);
|
|
78
|
+
setStored(null);
|
|
79
|
+
setConfirming(false);
|
|
80
|
+
rsvp.reset();
|
|
81
|
+
if (managed.data?.booking.status === 'cancelled') setNotice('Your RSVP is cancelled.');
|
|
82
|
+
}, [key, stored, gone]);
|
|
83
|
+
|
|
84
|
+
if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
|
|
85
|
+
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
|
+
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
|
+
const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
|
|
88
|
+
const going = (
|
|
89
|
+
<Text style={t.styles.text.body}>
|
|
90
|
+
<Text style={{ fontWeight: '700' }}>{s.going}</Text> going
|
|
91
|
+
{s.left !== null && <> · <Text style={{ fontWeight: '700' }}>{s.left}</Text> {s.left === 1 ? 'spot' : 'spots'} left</>}
|
|
92
|
+
</Text>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// ---- their RSVP: just sent, or kept from before ----
|
|
96
|
+
const mine = managed.data && managed.data.booking.status !== 'cancelled' ? managed.data : null;
|
|
97
|
+
const made = rsvp.data?.booking;
|
|
98
|
+
if (stored === undefined || (stored && changes && managed.isPending && !made)) return <ActivityIndicator color={t.colors.accent} />;
|
|
99
|
+
// offline or busy: say so rather than offer a second RSVP
|
|
100
|
+
if (stored && changes && managed.error && !gone && !made) return (
|
|
101
|
+
<View style={[t.styles.card, { gap: 8 }]}>
|
|
102
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>Your RSVP</Text>
|
|
103
|
+
<Text style={t.styles.text.muted}>Couldn’t load it right now.</Text>
|
|
104
|
+
<Button label="Try again" variant="outline" onPress={() => void managed.refetch()} />
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
if ((stored && mine) || made) {
|
|
108
|
+
const state = mine?.booking.status ?? made!.status;
|
|
109
|
+
const who = mine?.booking.email ?? made!.email;
|
|
110
|
+
const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
|
|
111
|
+
const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
|
|
112
|
+
return (
|
|
113
|
+
<View style={[t.styles.card, { gap: 8 }]}>
|
|
114
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? 'Your RSVP' : state === 'confirmed' ? 'Confirmed' : 'Request sent'}</Text>
|
|
115
|
+
<Text style={[t.styles.text.label, { color: t.colors.ink }]}>
|
|
116
|
+
{seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? 'Confirmed' : state === 'pending' ? 'Waiting for approval' : 'Not confirmed'}
|
|
117
|
+
</Text>
|
|
118
|
+
<Text style={t.styles.text.body}>
|
|
119
|
+
{state === 'confirmed' ? thanks || `A confirmation is on its way to ${who}.`
|
|
120
|
+
: state === 'pending' ? `We’ll email ${who} once it’s approved.` : 'Sorry, the organiser couldn’t confirm it.'}
|
|
121
|
+
</Text>
|
|
122
|
+
{state === 'declined' && <Button label="RSVP again" variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
|
|
123
|
+
{canCancel && state !== 'declined' && (confirming ? (
|
|
124
|
+
<View style={{ gap: 8 }}>
|
|
125
|
+
<Text style={t.styles.text.body}>Cancel your RSVP for {seatsText(mine!.booking.seats)}?</Text>
|
|
126
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
|
|
127
|
+
<Button label={cancel.isPending ? 'Cancelling…' : 'Yes, cancel it'} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
|
|
128
|
+
<Button label="Keep it" variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
|
|
129
|
+
</View>
|
|
130
|
+
</View>
|
|
131
|
+
) : <Button label="Cancel my RSVP" variant="outline" onPress={() => setConfirming(true)} />)}
|
|
132
|
+
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? 'Check your connection and try again.' : cancel.error.message}</Text>}
|
|
133
|
+
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>The confirmation email has a link to change or cancel it.</Text>}
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (s.reason === 'disabled') return null;
|
|
139
|
+
if (!s.open) return (
|
|
140
|
+
<View style={[t.styles.card, { gap: 6 }]}>
|
|
141
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? 'Full' : s.reason === 'cancelled' ? 'This date is cancelled' : 'RSVPs are closed'}</Text>
|
|
142
|
+
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? 'Nobody needs to RSVP for it.' : 'This date has already happened.'}</Text>}
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// ---- the form ----
|
|
147
|
+
const form = s.form;
|
|
148
|
+
const fields = form?.fields ?? [];
|
|
149
|
+
const answers: Answers = Object.fromEntries(fields.filter((f) => f.type !== 'heading').map((f) => [f.id, withOther(f, values[f.id], others[f.id] ?? '')]));
|
|
150
|
+
const problems = answerProblems(fields, answers);
|
|
151
|
+
const nameProblem = !name.trim() ? 'Add your name.' : '';
|
|
152
|
+
const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? 'Add a valid email address.' : '';
|
|
153
|
+
// the form may ask for a phone itself
|
|
154
|
+
const askPhone = !fields.some((f) => f.type === 'phone');
|
|
155
|
+
const chosen = Math.min(seats, s.maxSeats) || 1;
|
|
156
|
+
const submit = () => {
|
|
157
|
+
setTried(true);
|
|
158
|
+
setNotice('');
|
|
159
|
+
if (nameProblem || emailProblem || Object.keys(problems).length) return;
|
|
160
|
+
rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers }, {
|
|
161
|
+
onSuccess: (data) => { if (key) void tokens.set(key, data.manageToken); setStored(data.manageToken); },
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
const set = (id: string, v: string | string[]) => setValues((a) => ({ ...a, [id]: v }));
|
|
165
|
+
const label = (text: string, required: boolean) => (
|
|
166
|
+
<Text style={t.styles.text.label}>{text}{required ? <Text accessibilityLabel="required"> *</Text> : ''}</Text>
|
|
167
|
+
);
|
|
168
|
+
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
|
+
<View key={lbl} style={{ gap: 6 }}>
|
|
170
|
+
{label(lbl, !!o.required)}
|
|
171
|
+
{!!o.help && <Text style={t.styles.text.small}>{o.help}</Text>}
|
|
172
|
+
<TextInput value={value} onChangeText={onChange} keyboardType={o.keyboard} multiline={o.multiline} autoComplete={o.auto} placeholder={o.placeholder}
|
|
173
|
+
autoCapitalize={o.auto === 'email' || o.keyboard === 'email-address' || o.keyboard === 'url' ? 'none' : 'sentences'} placeholderTextColor={t.colors.muted}
|
|
174
|
+
accessibilityLabel={lbl} accessibilityHint={o.problem}
|
|
175
|
+
style={[input, o.multiline && { minHeight: 96, textAlignVertical: 'top' }, !!o.problem && { borderColor: t.colors.ink }]} />
|
|
176
|
+
{!!o.problem && <Text style={errorText}>{o.problem}</Text>}
|
|
177
|
+
</View>
|
|
178
|
+
);
|
|
179
|
+
const field = (f: FormField) => {
|
|
180
|
+
const problem = tried && problems[f.id] ? problemText(f, problems[f.id]) : '';
|
|
181
|
+
if (f.type === 'heading') return (
|
|
182
|
+
<View key={f.id} style={{ gap: 2, paddingTop: 4 }}>
|
|
183
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{f.label}</Text>
|
|
184
|
+
{!!f.help && <Text style={t.styles.text.muted}>{f.help}</Text>}
|
|
185
|
+
</View>
|
|
186
|
+
);
|
|
187
|
+
if (f.type === 'checkbox' || f.type === 'consent') return (
|
|
188
|
+
<View key={f.id} style={{ gap: 6 }}>
|
|
189
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
190
|
+
<Text style={[t.styles.text.body, { flex: 1 }]}>{f.label}{f.required ? ' *' : ''}</Text>
|
|
191
|
+
<Switch accessibilityLabel={f.label} value={values[f.id] === 'yes'} onValueChange={(v) => set(f.id, v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} />
|
|
192
|
+
</View>
|
|
193
|
+
{!!f.help && <Text style={t.styles.text.small}>{f.help}</Text>}
|
|
194
|
+
{!!problem && <Text style={errorText}>{problem}</Text>}
|
|
195
|
+
</View>
|
|
196
|
+
);
|
|
197
|
+
if (f.type === 'select' || f.type === 'radio' || f.type === 'checkboxes') {
|
|
198
|
+
const many = f.type === 'checkboxes';
|
|
199
|
+
const current = values[f.id];
|
|
200
|
+
const isOn = (o: string) => (many ? Array.isArray(current) && current.includes(o) : current === o);
|
|
201
|
+
const toggle = (o: string) => {
|
|
202
|
+
if (!many) return set(f.id, current === o && !f.required ? '' : o);
|
|
203
|
+
const list = Array.isArray(current) ? current : [];
|
|
204
|
+
set(f.id, list.includes(o) ? list.filter((x) => x !== o) : [...list, o]);
|
|
205
|
+
};
|
|
206
|
+
const choices = f.allowOther && f.type !== 'select' ? [...f.options, OTHER] : f.options;
|
|
207
|
+
return (
|
|
208
|
+
<View key={f.id} style={{ gap: 6 }}>
|
|
209
|
+
{label(f.label, f.required)}
|
|
210
|
+
{!!f.help && <Text style={t.styles.text.small}>{f.help}</Text>}
|
|
211
|
+
<View accessibilityRole={many ? undefined : 'radiogroup'} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
212
|
+
{choices.map((o) => (
|
|
213
|
+
<Pressable key={o} accessibilityRole={many ? 'checkbox' : 'radio'} accessibilityState={{ checked: isOn(o) }} onPress={() => toggle(o)} style={chip(isOn(o))}>
|
|
214
|
+
<Text style={[t.styles.text.body, isOn(o) && { fontWeight: '600' }]}>{o === OTHER ? 'Other' : o}</Text>
|
|
215
|
+
</Pressable>
|
|
216
|
+
))}
|
|
217
|
+
</View>
|
|
218
|
+
{isOn(OTHER) && (
|
|
219
|
+
<TextInput value={others[f.id] ?? ''} onChangeText={(v) => setOthers((x) => ({ ...x, [f.id]: v }))} placeholder="Other" accessibilityLabel={`${f.label}: other`}
|
|
220
|
+
placeholderTextColor={t.colors.muted} style={input} />
|
|
221
|
+
)}
|
|
222
|
+
{!!problem && <Text style={errorText}>{problem}</Text>}
|
|
223
|
+
</View>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
|
|
227
|
+
required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
|
|
228
|
+
placeholder: f.placeholder || (f.type === 'date' ? 'YYYY-MM-DD' : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const error = rsvp.error;
|
|
233
|
+
return (
|
|
234
|
+
<View style={[t.styles.card, { gap: 16 }]}>
|
|
235
|
+
<View style={{ gap: 4 }}>
|
|
236
|
+
<Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || 'Save your seat'}</Text>
|
|
237
|
+
{going}
|
|
238
|
+
{!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
|
|
239
|
+
{!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
|
|
240
|
+
</View>
|
|
241
|
+
{text('Your name', name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
|
|
242
|
+
{text('Email', email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
|
|
243
|
+
{askPhone && text('Phone (optional)', phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
|
|
244
|
+
{s.maxSeats > 1 && (
|
|
245
|
+
<View style={{ gap: 6 }}>
|
|
246
|
+
{label('Seats', false)}
|
|
247
|
+
<View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
248
|
+
{seatChoices(s).map((n) => (
|
|
249
|
+
<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>
|
|
251
|
+
</Pressable>
|
|
252
|
+
))}
|
|
253
|
+
</View>
|
|
254
|
+
</View>
|
|
255
|
+
)}
|
|
256
|
+
{fields.map(field)}
|
|
257
|
+
{error && (
|
|
258
|
+
<View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
|
|
259
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>That didn’t go through.</Text>
|
|
260
|
+
<Text style={t.styles.text.muted}>
|
|
261
|
+
{isAcmsError(error) && error.status === 0 ? 'Check your connection and try again. You won’t be registered twice.' : error.message}
|
|
262
|
+
</Text>
|
|
263
|
+
</View>
|
|
264
|
+
)}
|
|
265
|
+
{tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>Check the answers marked above.</Text>}
|
|
266
|
+
<Button label={rsvp.isPending ? 'Sending…' : form?.submitLabel || (s.autoConfirm ? 'Confirm my seat' : 'Request a seat')} disabled={rsvp.isPending} onPress={submit} />
|
|
267
|
+
<Text style={t.styles.text.small}>Your details go only to the organiser of this event.</Text>
|
|
268
|
+
</View>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -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
package/src/layouts/Site.astro
CHANGED
|
@@ -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;
|
package/src/lib/cache/policy.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
});
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -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.`,
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -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(),
|
|
21
|
+
minutes: z.number().int().min(1).max(44640).optional(),
|
|
20
22
|
}).strict(),
|
|
21
|
-
|
|
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(
|
|
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
|
|
52
|
-
const
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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) {
|
package/src/lib/mobile/forms.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import type { SavedForm } from '../forms/schema';
|
|
3
|
+
import type { FormDefinition, SavedForm } from '../forms/schema';
|
|
4
4
|
import { collectResponse, formVersion } from '../forms/service';
|
|
5
5
|
import { ApiError, readJson } from '../api';
|
|
6
6
|
import { sha256Hex } from '../api-keys';
|
|
@@ -24,14 +24,11 @@ async function publishedForm(repo: ContentRepository, id: string): Promise<Saved
|
|
|
24
24
|
return form;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
await requireAccess(repo, 'forms');
|
|
30
|
-
const form = await publishedForm(repo, id);
|
|
31
|
-
const d = form.definition;
|
|
27
|
+
/** A form's questions for apps; `version` is the one collectResponse accepts (also for event RSVPs). */
|
|
28
|
+
export async function formJson(d: FormDefinition) {
|
|
32
29
|
return {
|
|
33
|
-
|
|
34
|
-
title: d.title
|
|
30
|
+
version: await formVersion(d),
|
|
31
|
+
title: d.title, intro: d.intro, submitLabel: d.submitLabel, confirmation: d.confirmation,
|
|
35
32
|
fields: d.fields.map((f) => ({
|
|
36
33
|
id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
|
|
37
34
|
placeholder: f.placeholder ?? '', width: f.width ?? 'full', allowOther: f.allowOther ?? false,
|
|
@@ -39,9 +36,22 @@ export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
|
39
36
|
};
|
|
40
37
|
}
|
|
41
38
|
|
|
39
|
+
export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
40
|
+
z.object({}).strict().parse(queryParams(url));
|
|
41
|
+
await requireAccess(repo, 'forms');
|
|
42
|
+
const form = await publishedForm(repo, id);
|
|
43
|
+
const d = form.definition;
|
|
44
|
+
return { id: form.id, ...await formJson(d), title: d.title || form.name, submitLabel: d.submitLabel || 'Submit' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// field id → answer; checkbox groups send a list. "Other" answers send the typed text.
|
|
48
|
+
export const appAnswers = z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)]));
|
|
49
|
+
/** lists become the stored newline-joined form */
|
|
50
|
+
export const joinAnswers = (answers: z.infer<typeof appAnswers>): Record<string, string> =>
|
|
51
|
+
Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => x.trim()).filter(Boolean).join('\n') : v]));
|
|
52
|
+
|
|
42
53
|
export const formResponseInput = z.object({
|
|
43
|
-
|
|
44
|
-
answers: z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)])).refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
|
|
54
|
+
answers: appAnswers.refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
|
|
45
55
|
// the version from GET /forms/{id}; a changed form answers 409 instead of saving
|
|
46
56
|
version: z.string().max(100).optional(),
|
|
47
57
|
// spam trap: apps leave it out or empty
|
|
@@ -57,7 +67,7 @@ export async function submitFormResponse(c: PublicContext) {
|
|
|
57
67
|
if (body.website) throw new ApiError(400, 'Could not submit this form.');
|
|
58
68
|
const form = await publishedForm(c.repo, id);
|
|
59
69
|
if (!await c.repo.email.rateLimit(`forms:app:${await sha256Hex(`${form.id}:${c.address}`)}`, 10, 60_000)) throw new ApiError(429, 'Too many responses. Please try again in a minute.');
|
|
60
|
-
const answers =
|
|
70
|
+
const answers = joinAnswers(body.answers);
|
|
61
71
|
// the key is the stored response id: a retry of a saved response answers
|
|
62
72
|
// as before and costs nothing (also after the form changed); the same key
|
|
63
73
|
// with different answers is a 409
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -63,6 +63,8 @@ function view(m: Managed, booking = m.booking) {
|
|
|
63
63
|
// the web page for this booking (the same private link the emails carry)
|
|
64
64
|
manageUrl: origin + manageUrl(booking.manageToken),
|
|
65
65
|
can,
|
|
66
|
+
// the RSVP form as it was when they sent it (like the web page), else the booking type's message
|
|
67
|
+
confirmation: booking.formResponse?.definition.confirmation || link?.confirmMessage || null,
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
const flow = (m: Managed, c: PublicContext) => ({ origin: m.origin, locals: c.locals, waitUntil: waitUntilFrom(c.locals) });
|
|
@@ -4,6 +4,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
4
4
|
import { contentQuery, contentDetailQuery } from './content';
|
|
5
5
|
import { eventsQuery, availabilityQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
|
+
import { rsvpQuery } from './rsvp';
|
|
7
8
|
import { pagesQuery } from './pages';
|
|
8
9
|
import { searchQuery } from './search';
|
|
9
10
|
import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
@@ -33,7 +34,10 @@ const errors = Object.fromEntries([400, 404, 405, 429, 500].map(status => [statu
|
|
|
33
34
|
...(status === 429 ? { headers: { 'Retry-After': { description: 'Seconds to wait: 60 for per-minute limits, longer for hourly, daily and lockout limits.', schema: { type: 'string' } } } } : {}),
|
|
34
35
|
}]));
|
|
35
36
|
function parameters(query: z.AnyZodObject) {
|
|
36
|
-
return Object.entries(query.shape).map(([name, schema]) =>
|
|
37
|
+
return Object.entries(query.shape).map(([name, schema]) => {
|
|
38
|
+
const s = schema as z.ZodTypeAny;
|
|
39
|
+
return { name, in: 'query', required: !s.isOptional(), ...(s.description ? { description: s.description } : {}), schema: jsonSchema(s) };
|
|
40
|
+
});
|
|
37
41
|
}
|
|
38
42
|
export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
39
43
|
empty.parse(queryParams(url));
|
|
@@ -48,6 +52,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
48
52
|
['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
|
|
49
53
|
['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
|
|
50
54
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
55
|
+
['/events/{slug}/rsvp', 'RSVP status of one date (start = the occurrence start exactly): open or why not, live seats, the questions and their version; never cached; requires calendar', rsvpQuery, ref('RsvpStatus'), false],
|
|
51
56
|
['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
|
|
52
57
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
53
58
|
['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
|
|
@@ -73,8 +78,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
73
78
|
}
|
|
74
79
|
paths['/api/v1/bookings'] = {
|
|
75
80
|
post: {
|
|
76
|
-
summary: 'Create a guest booking or replay an existing request;
|
|
77
|
-
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent.',
|
|
81
|
+
summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
|
|
82
|
+
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409.',
|
|
78
83
|
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
|
|
79
84
|
requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
|
|
80
85
|
responses: { ...errors,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { CalEvent } from '../schema';
|
|
4
|
+
import { ApiError } from '../api';
|
|
5
|
+
import { answerFields } from '../forms/schema';
|
|
6
|
+
import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
|
|
7
|
+
import { seatsTaken } from '../booking/service';
|
|
8
|
+
import { localizeDoc } from '../translations';
|
|
9
|
+
import { queryParams } from './http';
|
|
10
|
+
import { requireCalendar } from './calendar';
|
|
11
|
+
import { formJson } from './forms';
|
|
12
|
+
import { localeQuery, mobileLocale } from './locale';
|
|
13
|
+
|
|
14
|
+
// RSVP status of one date of an event, for apps: whether it takes RSVPs now,
|
|
15
|
+
// live seats and the questions with their version. The same rules as the
|
|
16
|
+
// event's web page: open until the date ends, no deadline or waitlist.
|
|
17
|
+
// Never cached; seats change with every RSVP. POST /bookings saves one.
|
|
18
|
+
|
|
19
|
+
/** a public event (slug or id) and its occurrence starting exactly at `start` */
|
|
20
|
+
export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
|
|
21
|
+
const event = await repo.getEventBySlug(ref) ?? await repo.getEvent(ref);
|
|
22
|
+
const calendars = new Map((await repo.listCalendars()).map(c => [c.id, c]));
|
|
23
|
+
if (!event || !eventIsPublic(event, calendars)) throw new ApiError(404, 'Event not found.');
|
|
24
|
+
const settings = await repo.getSettings();
|
|
25
|
+
const at = Date.parse(start);
|
|
26
|
+
const occurrence = expandEvents([event], settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: at, windowEndMs: at + 1 }).find(o => o.start === at);
|
|
27
|
+
if (!occurrence) throw new ApiError(400, 'Start must match an event occurrence exactly.');
|
|
28
|
+
return occurrence;
|
|
29
|
+
}
|
|
30
|
+
/** the RSVP form when it asks something; apps must then send its version */
|
|
31
|
+
export const rsvpQuestions = (e: CalEvent) => e.rsvp.form && answerFields(e.rsvp.form).length ? e.rsvp.form : null;
|
|
32
|
+
/** why this date takes no RSVPs (seats aside), or null */
|
|
33
|
+
export function rsvpClosed(o: Occurrence, now = Date.now()) {
|
|
34
|
+
if (!o.event.rsvp.enabled) return 'disabled' as const;
|
|
35
|
+
if (o.cancelled) return 'cancelled' as const;
|
|
36
|
+
if (o.end <= now) return 'ended' as const;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const rsvpQuery = z.object({
|
|
41
|
+
start: z.string().datetime({ offset: true }).describe('The occurrence’s start exactly as GET /events returns it, a UTC ISO date-time such as 2026-10-05T14:00:00.000Z. A start that isn’t one of the event’s dates answers 400.'),
|
|
42
|
+
locale: localeQuery,
|
|
43
|
+
}).strict();
|
|
44
|
+
export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: string) {
|
|
45
|
+
requireCalendar();
|
|
46
|
+
const q = rsvpQuery.parse(queryParams(url));
|
|
47
|
+
const locale = mobileLocale(q.locale);
|
|
48
|
+
const o = await rsvpOccurrence(repo, slug, q.start);
|
|
49
|
+
const { rsvp } = o.event;
|
|
50
|
+
// pending and confirmed RSVPs hold seats, as in the capacity check
|
|
51
|
+
const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
|
|
52
|
+
const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
|
|
53
|
+
const closed = rsvpClosed(o);
|
|
54
|
+
const reason = closed ?? (left === 0 ? 'full' as const : null);
|
|
55
|
+
const form = rsvpQuestions(o.event);
|
|
56
|
+
const title = localizeDoc(o.event, locale ?? undefined).title;
|
|
57
|
+
return {
|
|
58
|
+
event: { id: o.event.id, slug: o.event.slug, title },
|
|
59
|
+
start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
60
|
+
open: !reason, reason, going, left, capacity: rsvp.capacity,
|
|
61
|
+
// seats one RSVP may take right now
|
|
62
|
+
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
63
|
+
form: form ? { ...await formJson(form), submitLabel: form.submitLabel || (rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat') } : null,
|
|
64
|
+
};
|
|
65
|
+
}
|