@acms-subir/core 0.3.34 → 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 +29 -0
- package/cli/migrations.generated.mjs +7 -1
- package/docs/mobile.md +9 -6
- 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/components/Announcements.tsx +14 -1
- package/src/components/LiveCard.astro +23 -10
- package/src/components/MobileApp.tsx +54 -4
- package/src/layouts/Site.astro +45 -38
- package/src/lib/announcements/api.ts +8 -4
- package/src/lib/announcements/store.ts +4 -0
- package/src/lib/cache/policy.ts +2 -2
- package/src/lib/calendar/hooks.ts +5 -1
- 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/live/view.ts +28 -24
- package/src/lib/live/words.ts +94 -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/push/api.ts +13 -1
- package/src/lib/push/service.ts +211 -24
- package/src/lib/push/store.ts +35 -8
- package/src/lib/push/types.ts +23 -2
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/schema.ts +2 -1
- package/src/lib/search-i18n.ts +41 -4
- package/src/lib/site-chrome.ts +8 -0
- package/src/lib/site-words.ts +62 -0
- package/src/pages/admin/calendar/bookings.astro +3 -1
- package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
- package/src/styles/mobile-app.css +7 -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
|
@@ -359,6 +359,16 @@ function toForm(a: Announcement | null, state: State): Form {
|
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
const linkOf = (f: Form, state: State) => (f.linkRef === CUSTOM ? f.customUrl.trim() : state.links.find((l) => l.ref === f.linkRef)?.url ?? '');
|
|
362
|
+
// a link's site path as the server compares it: no origin, language prefix, query or hash ('' = another site)
|
|
363
|
+
function sitePath(link: string, state: State) {
|
|
364
|
+
try {
|
|
365
|
+
const u = new URL(link, state.site.origin || 'https://site.invalid');
|
|
366
|
+
if (state.site.origin && u.origin !== new URL(state.site.origin).origin) return '';
|
|
367
|
+
const path = u.pathname.replace(/(.)\/+$/, '$1');
|
|
368
|
+
const lang = state.languages.extra.find((l) => path === `/${l.code}` || path.startsWith(`/${l.code}/`));
|
|
369
|
+
return lang ? path.slice(lang.code.length + 1) || '/' : path;
|
|
370
|
+
} catch { return ''; }
|
|
371
|
+
}
|
|
362
372
|
|
|
363
373
|
function toInput(f: Form, state: State, original: Announcement | null): AnnouncementInput {
|
|
364
374
|
// once it went up, the server keeps its push, audience and start as they were
|
|
@@ -519,6 +529,9 @@ function Composer({ state, setState, startId, onClose }: {
|
|
|
519
529
|
? (original.push ? (original.notificationId ? 'Pushed when it went up. A push only goes out once.' : 'No push went out when it went up.') : 'A push only goes out when it goes up.')
|
|
520
530
|
: !state.push.enabled ? 'Push notifications are off on this site.'
|
|
521
531
|
: state.push.devices === 0 ? 'No phone has turned notifications on yet.' : undefined;
|
|
532
|
+
// the button opens an event whose automatic change notice just went out: a push would repeat it
|
|
533
|
+
const changed = link ? state.recentChanges.find((c) => c.path === sitePath(link, state)) : undefined;
|
|
534
|
+
const changeNote = changed ? `An automatic change notice for this event went out at ${clock(changed.at)}.` : undefined;
|
|
522
535
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
523
536
|
const showPush = wentUp ? original.push : state.push.enabled && form.push;
|
|
524
537
|
const words = lang === main.code ? form.words : form.translations[lang] ?? BLANK;
|
|
@@ -605,7 +618,7 @@ function Composer({ state, setState, startId, onClose }: {
|
|
|
605
618
|
<legend className="an-legend">Where it shows</legend>
|
|
606
619
|
<Toggle label="Announcements list (app and website)" checked={form.listed} onChange={(v) => set({ listed: v })} />
|
|
607
620
|
<Toggle label="Push to phones" checked={wentUp ? original.push : form.push && state.push.enabled} disabled={wentUp || !state.push.enabled}
|
|
608
|
-
note={pushNote} onChange={(v) => set({ push: v })} />
|
|
621
|
+
note={pushNote ?? changeNote} onChange={(v) => set({ push: v })} />
|
|
609
622
|
<Toggle label="Pin as a banner until it ends" checked={form.pinned} note="Closing it hides this announcement only; the next one still shows." onChange={(v) => set({ pinned: v })} />
|
|
610
623
|
</fieldset>
|
|
611
624
|
</div>
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { getRepo } from '../lib/repo';
|
|
3
3
|
import { liveState, publicSnapshot } from '../lib/live/service';
|
|
4
4
|
import { liveView } from '../lib/live/view';
|
|
5
|
+
import { liveStrings } from '../lib/live/words';
|
|
6
|
+
import { I18N, requestLocale } from '../lib/i18n';
|
|
7
|
+
import { siteLang, siteLocale } from '../lib/dates';
|
|
5
8
|
import '../styles/live.css';
|
|
6
9
|
|
|
7
10
|
// One component for every live placement. Layout:
|
|
@@ -10,10 +13,16 @@ import '../styles/live.css';
|
|
|
10
13
|
// Theme hooks: .pc-live-* classes, --live-* tokens, [data-layout], [data-status].
|
|
11
14
|
interface Props { compact?: boolean; display?: string }
|
|
12
15
|
const { compact = false, display = 'card' } = Astro.props;
|
|
13
|
-
const
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const [{ config, snapshot: raw }, settings] = await Promise.all([liveState(repo), repo.getSettings()]);
|
|
14
18
|
const snapshot = publicSnapshot(raw, config);
|
|
15
19
|
const now = Date.now();
|
|
16
|
-
|
|
20
|
+
// the page's language, as in Site.astro's <html lang>; the site's own locale
|
|
21
|
+
// keeps its region for times. The browser script re-renders in it too.
|
|
22
|
+
const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
23
|
+
const locale = reqLocale && reqLocale !== siteLang(settings) ? reqLocale : siteLocale(settings);
|
|
24
|
+
const w = liveStrings(locale);
|
|
25
|
+
const v = liveView(snapshot, now, w, locale);
|
|
17
26
|
const player = !compact && display === 'player';
|
|
18
27
|
const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
19
28
|
---
|
|
@@ -26,12 +35,13 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
26
35
|
data-status={v?.status ?? 'offline'}
|
|
27
36
|
data-snapshot={JSON.stringify(snapshot)}
|
|
28
37
|
data-server-now={now}
|
|
38
|
+
data-locale={locale}
|
|
29
39
|
hidden={!v}
|
|
30
|
-
aria-label=
|
|
40
|
+
aria-label={w.video}
|
|
31
41
|
>
|
|
32
42
|
<div class="pc-live-off">
|
|
33
43
|
<i class="pc-live-off-dot" aria-hidden="true"></i>
|
|
34
|
-
<span>
|
|
44
|
+
<span>{w.notLive}</span>
|
|
35
45
|
<a data-live-watch href={v?.watchUrl || '#'} target="_blank" rel="noopener noreferrer">{v?.watchLabel}</a>
|
|
36
46
|
</div>
|
|
37
47
|
<div class="pc-live-body">
|
|
@@ -52,7 +62,7 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
52
62
|
<h2 class="pc-live-title"><span class="pc-live-title-text" data-live-title>{v?.title}</span></h2>
|
|
53
63
|
<p class="pc-live-meta" data-live-meta hidden={!v?.meta}>{v?.meta}</p>
|
|
54
64
|
<div class="pc-live-actions">
|
|
55
|
-
<button type="button" class="pc-live-btn" data-live-load hidden>
|
|
65
|
+
<button type="button" class="pc-live-btn" data-live-load hidden>{w.playHere}</button>
|
|
56
66
|
<a class="pc-live-btn" data-live-watch href={v?.watchUrl || '#'} target="_blank" rel="noopener noreferrer">{v?.watchLabel}</a>
|
|
57
67
|
</div>
|
|
58
68
|
<small class="pc-live-privacy" data-live-privacy hidden></small>
|
|
@@ -62,6 +72,7 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
62
72
|
<script>
|
|
63
73
|
import { subscribeLive, currentLive, serverNow } from '../lib/live/client';
|
|
64
74
|
import { liveView, PROVIDER_NAMES } from '../lib/live/view';
|
|
75
|
+
import { fill, liveStrings } from '../lib/live/words';
|
|
65
76
|
import type { PublicLiveSnapshot } from '../lib/live/types';
|
|
66
77
|
|
|
67
78
|
const q = <T extends HTMLElement>(root: Element, sel: string) => root.querySelector<T>(sel)!;
|
|
@@ -71,6 +82,8 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
71
82
|
const cards = Array.from(document.querySelectorAll<HTMLElement>('[data-live-card]')).filter((c) => !c.dataset.initialized);
|
|
72
83
|
for (const card of cards) {
|
|
73
84
|
card.dataset.initialized = 'true';
|
|
85
|
+
const locale = card.dataset.locale || undefined;
|
|
86
|
+
const words = liveStrings(locale);
|
|
74
87
|
let shownKey = '';
|
|
75
88
|
// the links page lifts its live block to the top while live; a marker
|
|
76
89
|
// remembers where the owner placed it
|
|
@@ -110,7 +123,7 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
110
123
|
if (!src) return false;
|
|
111
124
|
const iframe = document.createElement('iframe');
|
|
112
125
|
iframe.src = src;
|
|
113
|
-
iframe.title = s.title ||
|
|
126
|
+
iframe.title = s.title || words.video;
|
|
114
127
|
iframe.allowFullscreen = true;
|
|
115
128
|
iframe.allow = 'fullscreen; picture-in-picture; encrypted-media';
|
|
116
129
|
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
|
@@ -131,7 +144,7 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
function render(s: PublicLiveSnapshot | null, now: number) {
|
|
134
|
-
const v = liveView(s, now);
|
|
147
|
+
const v = liveView(s, now, words, locale);
|
|
135
148
|
card.hidden = !v;
|
|
136
149
|
const key = s ? `${s.provider}:${s.id}:${s.status}:${s.embedId}` : '';
|
|
137
150
|
if (key !== shownKey || !eligible(s)) { if (!player.hidden) reset(); }
|
|
@@ -153,13 +166,13 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
153
166
|
if (!v.thumbUrl) { img.hidden = true; img.removeAttribute('src'); }
|
|
154
167
|
else if (img.getAttribute('src') !== v.thumbUrl) img.src = v.thumbUrl;
|
|
155
168
|
const canPlay = eligible(s);
|
|
156
|
-
const name = PROVIDER_NAMES[v.provider] ||
|
|
157
|
-
poster.setAttribute('aria-label', canPlay ?
|
|
169
|
+
const name = PROVIDER_NAMES[v.provider] || words.platform;
|
|
170
|
+
poster.setAttribute('aria-label', canPlay ? fill(words.play, { title: v.title }) : `${v.watchLabel.replace(' ↗', '')}: ${v.title}`);
|
|
158
171
|
q(card, '[data-live-playicon]').hidden = !canPlay;
|
|
159
172
|
load.hidden = !canPlay || !player.hidden;
|
|
160
173
|
const privacy = q(card, '[data-live-privacy]');
|
|
161
174
|
privacy.hidden = !canPlay;
|
|
162
|
-
privacy.textContent =
|
|
175
|
+
privacy.textContent = fill(words.privacy, { provider: name });
|
|
163
176
|
card.dataset.playable = canPlay ? 'true' : 'false';
|
|
164
177
|
}
|
|
165
178
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type { AdminPushState } from '../lib/push/api';
|
|
3
3
|
import type { PushAuto, PushNotification } from '../lib/push/types';
|
|
4
4
|
import '../styles/mobile-app.css';
|
|
@@ -35,7 +35,7 @@ function ago(ms: number, now: number) {
|
|
|
35
35
|
: d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by' };
|
|
38
|
+
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed' };
|
|
39
39
|
|
|
40
40
|
export default function MobileApp() {
|
|
41
41
|
const [state, setState] = useState<AdminPushState | null>(null);
|
|
@@ -261,7 +261,8 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
261
261
|
setState(r.state); setAuto(r.auto); setMsg({ ok: 'Saved.' });
|
|
262
262
|
} catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
|
|
263
263
|
};
|
|
264
|
-
const
|
|
264
|
+
const topicName = (id: string) => state.topics.find((t) => t.id === id)?.label ?? id;
|
|
265
|
+
const calendarChoices = (key: 'newEvent' | 'startingSoon' | 'eventChanges') => {
|
|
265
266
|
const list = auto[key].calendars;
|
|
266
267
|
const all = list.includes('*');
|
|
267
268
|
const set = (calendars: string[]) => setAuto({ ...auto, [key]: { ...auto[key], calendars } });
|
|
@@ -279,7 +280,7 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
279
280
|
<section className="card card-pad" aria-labelledby="ma-auto-h">
|
|
280
281
|
<header className="ma-head">
|
|
281
282
|
<h2 id="ma-auto-h">Automatic notifications</h2>
|
|
282
|
-
<p>Sent once, when something is published for the first time or a broadcast starts.
|
|
283
|
+
<p>Sent once, when something is published for the first time{state.calendarEnabled ? ', an event’s date, time or place changes' : ''} or a broadcast starts. Other edits never notify.</p>
|
|
283
284
|
</header>
|
|
284
285
|
<div className="ma-rules">
|
|
285
286
|
<div className="ma-rule">
|
|
@@ -307,6 +308,27 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
307
308
|
</select>
|
|
308
309
|
</label>
|
|
309
310
|
</div>
|
|
311
|
+
<div className="ma-rule">
|
|
312
|
+
<div className="ma-rule-head"><b>When an event changes</b><small>New date, time or place, or a cancellation, opens the event. Sent 10 minutes after the last edit (1 minute when it starts within 3 hours), so a quick fix doesn’t notify twice.</small></div>
|
|
313
|
+
{calendarChoices('eventChanges')}
|
|
314
|
+
<div className="ma-fields">
|
|
315
|
+
<div className="field ma-lead">
|
|
316
|
+
<span>Only events tagged</span>
|
|
317
|
+
<TagChips tags={auto.eventChanges.tags} suggestions={state.tags}
|
|
318
|
+
onChange={(tags) => setAuto({ ...auto, eventChanges: { ...auto.eventChanges, tags } })} />
|
|
319
|
+
</div>
|
|
320
|
+
<label className="field ma-lead">
|
|
321
|
+
<span>Send to</span>
|
|
322
|
+
<select value={auto.eventChanges.topics.join(',')}
|
|
323
|
+
onChange={(e) => setAuto({ ...auto, eventChanges: { ...auto.eventChanges, topics: e.target.value ? e.target.value.split(',') : [] } })}>
|
|
324
|
+
<option value="">The event’s topics (Events, its calendar)</option>
|
|
325
|
+
{/* several topics can only come from the API: keep them choosable */}
|
|
326
|
+
{auto.eventChanges.topics.length > 1 && <option value={auto.eventChanges.topics.join(',')}>{auto.eventChanges.topics.map(topicName).join(', ')}</option>}
|
|
327
|
+
{state.topics.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
|
|
328
|
+
</select>
|
|
329
|
+
</label>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
310
332
|
</>
|
|
311
333
|
)}
|
|
312
334
|
<div className="ma-rule">
|
|
@@ -329,6 +351,34 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
329
351
|
);
|
|
330
352
|
}
|
|
331
353
|
|
|
354
|
+
// Chips for the rule's tags; the box adds one on Enter, a comma or leaving it
|
|
355
|
+
// (suggestions: the site's tags). Empty = every event in the chosen calendars.
|
|
356
|
+
function TagChips({ tags, suggestions, onChange }: { tags: string[]; suggestions: string[]; onChange: (tags: string[]) => void }) {
|
|
357
|
+
const [draft, setDraft] = useState('');
|
|
358
|
+
const list = useId();
|
|
359
|
+
const add = () => {
|
|
360
|
+
const t = draft.replace(/,/g, ' ').trim();
|
|
361
|
+
if (t && !tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...tags, t]);
|
|
362
|
+
setDraft('');
|
|
363
|
+
};
|
|
364
|
+
return (
|
|
365
|
+
<div className="ma-tags">
|
|
366
|
+
{tags.map((t) => (
|
|
367
|
+
<span key={t} className="ma-tag">{t}<button type="button" aria-label={`Remove ${t}`} onClick={() => onChange(tags.filter((x) => x !== t))}>✕</button></span>
|
|
368
|
+
))}
|
|
369
|
+
{tags.length < 20 && (
|
|
370
|
+
<input type="text" list={list} value={draft} maxLength={100} placeholder={tags.length ? '' : 'Any tag'} aria-label="Add a tag"
|
|
371
|
+
onChange={(e) => setDraft(e.target.value)} onBlur={add}
|
|
372
|
+
onKeyDown={(e) => {
|
|
373
|
+
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); add(); }
|
|
374
|
+
if (e.key === 'Backspace' && !draft && tags.length) onChange(tags.slice(0, -1));
|
|
375
|
+
}} />
|
|
376
|
+
)}
|
|
377
|
+
<datalist id={list}>{suggestions.filter((s) => !tags.some((t) => t.toLowerCase() === s.toLowerCase())).map((s) => <option key={s} value={s} />)}</datalist>
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
332
382
|
// ---- audience -----------------------------------------------------------------
|
|
333
383
|
|
|
334
384
|
function Audience({ state, topicLabel }: { state: AdminPushState; topicLabel: (id: string) => string }) {
|