@acms-subir/core 0.3.55 → 0.3.57

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 (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +189 -2
  4. package/docs/mobile.md +3 -1
  5. package/docs/server.md +84 -1
  6. package/integration.mjs +34 -0
  7. package/mobile-template/client/AGENTS.md +1 -0
  8. package/mobile-template/client/README.md +1 -1
  9. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  10. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  12. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  13. package/mobile-template/recipes/event-request/recipe.json +21 -0
  14. package/package.json +1 -1
  15. package/src/actions/index.ts +52 -9
  16. package/src/components/EventPage.astro +6 -5
  17. package/src/components/MobileApp.tsx +2 -2
  18. package/src/components/calendar/CalendarBoard.tsx +6 -3
  19. package/src/components/editor/event/SingleDates.tsx +87 -17
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/api.ts +23 -2
  22. package/src/lib/calendar/board.ts +3 -2
  23. package/src/lib/calendar/cards.ts +4 -4
  24. package/src/lib/calendar/change-migration.ts +17 -3
  25. package/src/lib/calendar/data.ts +9 -2
  26. package/src/lib/calendar/event-words.ts +8 -0
  27. package/src/lib/calendar/events.ts +128 -15
  28. package/src/lib/calendar/google/map.ts +1 -1
  29. package/src/lib/calendar/host-requests.ts +65 -10
  30. package/src/lib/calendar/ics-write.ts +65 -15
  31. package/src/lib/calendar/member-words.ts +14 -1
  32. package/src/lib/calendar/moves.ts +15 -9
  33. package/src/lib/calendar/request-admin.ts +212 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +42 -8
  41. package/src/lib/calendar/segments.ts +522 -0
  42. package/src/lib/editor/schedule.ts +2 -2
  43. package/src/lib/email/service.ts +6 -1
  44. package/src/lib/legal/app-privacy.ts +2 -2
  45. package/src/lib/legal/facts.ts +4 -0
  46. package/src/lib/legal/text-en.ts +3 -0
  47. package/src/lib/legal/text-es.ts +3 -0
  48. package/src/lib/mobile/calendar.ts +1 -0
  49. package/src/lib/mobile/http.ts +2 -1
  50. package/src/lib/mobile/openapi.ts +8 -0
  51. package/src/lib/mobile/requests.ts +27 -0
  52. package/src/lib/mobile/schemas.ts +24 -6
  53. package/src/lib/mobile/site.ts +3 -0
  54. package/src/lib/push/service.ts +42 -10
  55. package/src/lib/push/types.ts +3 -1
  56. package/src/lib/repo/migrations.ts +5 -2
  57. package/src/lib/repo/sql.ts +24 -8
  58. package/src/lib/repo/types.ts +3 -1
  59. package/src/lib/scheduler.ts +3 -0
  60. package/src/lib/schema.ts +67 -2
  61. package/src/lib/sections.ts +1 -1
  62. package/src/lib/sync/catalog.ts +1 -0
  63. package/src/pages/admin/calendar/events/index.astro +11 -8
  64. package/src/pages/admin/calendar/requests.astro +298 -0
  65. package/src/pages/api/calendar/events/index.ts +2 -1
  66. package/src/pages/api/v1/requests/index.ts +6 -0
  67. package/src/pages/api/v1/requests/options.ts +7 -0
  68. package/src/pages/members/events/index.astro +84 -6
  69. package/src/pages/request/index.astro +182 -0
  70. package/src/server/index.ts +102 -4
  71. package/src/styles/editor.css +2 -0
  72. package/src/styles/site.css +29 -0
  73. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,305 @@
1
+ import { useEffect, useState, type ReactNode } from 'react';
2
+ import { ActivityIndicator, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
3
+ import { Stack, router } from 'expo-router';
4
+ import * as Crypto from 'expo-crypto';
5
+ import * as SecureStore from 'expo-secure-store';
6
+ import type { RequestInput } from '@acms-subir/client';
7
+ import { Button, ErrorView, fill, formatClock, formatEventDate, localDay, parseClock, requestEnd, requestProblemText, requestProblems, requestSummary, siteInstant, useDebounced, useGuest, useLayout, useLocale, useRequestOptions, useRoomFree, useSite, useStrings, useSubmitRequest, useTheme, type RequestField, type RequestProblem } from '@acms-subir/native';
8
+ import { DayPicker } from '../components/DayPicker';
9
+
10
+ // Request an event, without an account: something new (what, kind, when,
11
+ // how it repeats, rooms with a live "looks free" line, people) or a change
12
+ // to one of the site's events, then how to reach them. Staff answer in the
13
+ // site's Calendar → Requests; nothing is on the calendar until they approve
14
+ // it. While the site isn't taking requests the screen still opens and says
15
+ // so, with the office phone: keep the More row that leads here, so App Review
16
+ // always finds it. useSubmitRequest keeps one idempotency key per request, so
17
+ // sending again after a timeout never asks twice. Words: useStrings(), in the
18
+ // app's language (the website's form says the same).
19
+
20
+ // Name, email and phone stay on this phone for next time: secure storage,
21
+ // this device only, never sent anywhere but the site. Web keeps nothing.
22
+ const CONTACT_KEY = 'acms.request.contact';
23
+ const keychain = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
24
+ type Contact = { name: string; email: string; phone: string };
25
+ const contact = {
26
+ get: async (): Promise<Contact | null> => {
27
+ if (Platform.OS === 'web') return null;
28
+ try {
29
+ const v = JSON.parse((await SecureStore.getItemAsync(CONTACT_KEY, keychain)) ?? 'null');
30
+ return v && typeof v.name === 'string' && typeof v.email === 'string' ? { name: v.name, email: v.email, phone: typeof v.phone === 'string' ? v.phone : '' } : null;
31
+ } catch { return null; }
32
+ },
33
+ set: async (c: Contact) => { if (Platform.OS !== 'web') await SecureStore.setItemAsync(CONTACT_KEY, JSON.stringify(c), keychain).catch(() => {}); },
34
+ };
35
+
36
+ const call = (phone: string) => { Linking.openURL(`tel:${phone.replace(/[^\d+]/g, '')}`).catch(() => {}); };
37
+ type Repeat = 'once' | 'weekly' | 'other';
38
+ const UNSURE = '__unsure__';
39
+
40
+ export default function RequestScreen() {
41
+ const t = useTheme();
42
+ const words = useStrings();
43
+ const { locale } = useLocale();
44
+ const { gutter } = useLayout();
45
+ const { data: site } = useSite();
46
+ const guest = useGuest();
47
+ const options = useRequestOptions();
48
+ const send = useSubmitRequest({ createKey: Crypto.randomUUID });
49
+ const o = options.data;
50
+ const tz = o?.timezone ?? 'UTC';
51
+ const [kind, setKind] = useState<'new' | 'change'>('new');
52
+ const [title, setTitle] = useState('');
53
+ const [type, setType] = useState('');
54
+ const [date, setDate] = useState('');
55
+ const [starts, setStarts] = useState('');
56
+ const [ends, setEnds] = useState('');
57
+ const [repeat, setRepeat] = useState<Repeat>('once');
58
+ const [repeatText, setRepeatText] = useState('');
59
+ const [rooms, setRooms] = useState<string[]>([]);
60
+ const [unsure, setUnsure] = useState(false);
61
+ const [people, setPeople] = useState('');
62
+ const [message, setMessage] = useState('');
63
+ const [event, setEvent] = useState('');
64
+ const [eventDate, setEventDate] = useState('');
65
+ const [change, setChange] = useState('');
66
+ const [name, setName] = useState('');
67
+ const [email, setEmail] = useState('');
68
+ const [phone, setPhone] = useState('');
69
+ const [tried, setTried] = useState(false);
70
+
71
+ // details from last time, else the email they signed in with for "My bookings"
72
+ useEffect(() => {
73
+ let live = true;
74
+ void contact.get().then((c) => {
75
+ if (!live || !c) return;
76
+ setName((v) => v || c.name); setEmail((v) => v || c.email); setPhone((v) => v || c.phone);
77
+ });
78
+ return () => { live = false; };
79
+ }, []);
80
+ useEffect(() => {
81
+ if (site?.features.guestSignIn && guest.email) setEmail((v) => v || guest.email!);
82
+ }, [site?.features.guestSignIn, guest.email]);
83
+
84
+ // ---- what goes out ----
85
+ const startTime = parseClock(starts), endTime = parseClock(ends);
86
+ const count = people.replace(/\D/g, '');
87
+ const input: RequestInput = kind === 'new'
88
+ ? { kind, type: type || undefined, title, start: date && startTime ? `${date}T${startTime}` : '', end: endTime ?? '', repeat, repeatText,
89
+ rooms: unsure ? [] : rooms, roomsUnsure: unsure, people: count ? Number(count) : null, message, name, email, phone }
90
+ : { kind, event, date: eventDate || undefined, message: change, name, email, phone };
91
+ const local = requestProblems(input, o);
92
+ // the site's answer shows by its field until the next send
93
+ const problems: Partial<Record<RequestField, RequestProblem>> = tried ? { ...local, ...(send.field && send.problem ? { [send.field]: send.problem } : {}) } : {};
94
+ const invalid = tried && Object.keys(problems).length > 0;
95
+
96
+ // the live "looks free" line (needs bookings on the site): the first date's times as instants, since rooms/free needs a zone
97
+ const check = input.kind === 'new' && !local.start && !local.end && !unsure && rooms.length > 0 && site?.features.bookings === true;
98
+ const from = check ? siteInstant(input.start, tz) : null, to = check ? siteInstant(requestEnd(input.start, input.end), tz) : null;
99
+ const span = from && to ? `${from}|${to}` : '';
100
+ // asked once typing settles (30 checks a minute per address)
101
+ const checked = useDebounced(span, 400);
102
+
103
+ const submit = () => {
104
+ setTried(true);
105
+ if (Object.keys(local).length) return;
106
+ send.mutate(input, { onSuccess: () => { void contact.set({ name: name.trim(), email: email.trim(), phone: phone.trim() }); } });
107
+ };
108
+ const startOver = () => {
109
+ send.reset(); setTried(false);
110
+ setTitle(''); setType(''); setDate(''); setStarts(''); setEnds(''); setRepeat('once'); setRepeatText(''); setRooms([]); setUnsure(false);
111
+ setPeople(''); setMessage(''); setEvent(''); setEventDate(''); setChange('');
112
+ };
113
+
114
+ const screen = (children: ReactNode) => (
115
+ <KeyboardAvoidingView style={{ flex: 1, backgroundColor: t.colors.bg }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
116
+ <Stack.Screen options={{ title: words.requestEvent }} />
117
+ <ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: gutter, gap: 20, width: '100%', maxWidth: 640, alignSelf: 'center' }}>{children}</ScrollView>
118
+ </KeyboardAvoidingView>
119
+ );
120
+ const officePhone = o?.phone ?? '';
121
+ const phoneLink = (text: string) => (
122
+ <Pressable accessibilityRole="link" onPress={() => call(officePhone)} style={{ minHeight: 44, justifyContent: 'center' }}>
123
+ <Text style={t.styles.text.link}>{text}</Text>
124
+ </Pressable>
125
+ );
126
+
127
+ // ---- loading, off or closed, sent ----
128
+ if (!site || (site.features.requests && options.isPending && !options.off)) return screen(<ActivityIndicator style={{ marginTop: 32 }} color={t.colors.accent} />);
129
+ if (options.error && !options.off && !o) return screen(<ErrorView error={options.error} retry={() => void options.refetch()} />);
130
+ if (options.off || o?.open === false || send.problem === 'closed') return screen(
131
+ <View style={[t.styles.card, { gap: 8 }]}>
132
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{words.requestClosed}</Text>
133
+ {!!officePhone && phoneLink(fill(words.requestCall, { phone: officePhone }))}
134
+ </View>,
135
+ );
136
+ if (send.data && send.variables) {
137
+ const sent = send.variables;
138
+ const eventTitle = sent.kind === 'change' ? o?.events.find((e) => e.slug === sent.event)?.title : undefined;
139
+ const lines = requestSummary(sent, words, { options: o, locale, eventTitle });
140
+ const to = sent.email.trim();
141
+ return screen(<>
142
+ <View style={{ gap: 8 }}>
143
+ <Text accessibilityRole="header" style={t.styles.text.display}>{words.requestSentTitle}</Text>
144
+ <Text style={t.styles.text.body}>{o?.replyTime ? fill(words.requestAnswerWithin, { time: o.replyTime, email: to }) : fill(words.requestAnswerBy, { email: to })}</Text>
145
+ </View>
146
+ <View style={[t.styles.card, { gap: 4 }]}>
147
+ <Text style={t.styles.text.label}>{words.requestYours}</Text>
148
+ {lines.map((line, i) => <Text key={i} style={i ? t.styles.text.muted : t.styles.text.heading}>{line}</Text>)}
149
+ </View>
150
+ <View style={{ gap: 4 }}>
151
+ <Text style={t.styles.text.muted}>{words.requestNotYet}</Text>
152
+ {!!officePhone && phoneLink(fill(words.requestSooner, { phone: officePhone }))}
153
+ </View>
154
+ <View style={{ gap: 10 }}>
155
+ <Button label={words.requestDone} onPress={() => { if (router.canGoBack()) router.back(); else startOver(); }} />
156
+ <Button label={words.requestAnother} variant="outline" onPress={startOver} />
157
+ </View>
158
+ </>);
159
+ }
160
+
161
+ // ---- the form ----
162
+ const radius = Math.min(t.radius, 10);
163
+ const field = { borderWidth: 1, borderColor: t.colors.line, borderRadius: radius, padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
164
+ const chip = (on: boolean) => ({ paddingVertical: 10, paddingHorizontal: 12, minHeight: 44, justifyContent: 'center' as const, borderRadius: radius, borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel });
165
+ const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
166
+ const problem = (f: RequestField) => (problems[f] ? <Text style={errorText}>{requestProblemText(problems[f]!, words)}</Text> : null);
167
+ const label = (text: string, optional = false) => (
168
+ <Text style={t.styles.text.label}>{text}{optional ? <Text style={{ fontWeight: '400' }}> · {words.requestOptional}</Text> : null}</Text>
169
+ );
170
+ const text = (lbl: string, value: string, onChange: (v: string) => void, f: RequestField | null, x: { optional?: boolean; keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel'; placeholder?: string; onBlur?: () => void } = {}) => (
171
+ <View style={{ gap: 6 }}>
172
+ {label(lbl, x.optional)}
173
+ <TextInput value={value} onChangeText={onChange} onBlur={x.onBlur} keyboardType={x.keyboard} multiline={x.multiline} autoComplete={x.auto} placeholder={x.placeholder}
174
+ autoCapitalize={x.auto === 'email' ? 'none' : 'sentences'} autoCorrect={x.auto !== 'email'} placeholderTextColor={t.colors.muted}
175
+ accessibilityLabel={lbl} accessibilityHint={f && problems[f] ? requestProblemText(problems[f]!, words) : undefined}
176
+ style={[field, x.multiline && { minHeight: 96, textAlignVertical: 'top' }, !!f && !!problems[f] && { borderColor: t.colors.ink }]} />
177
+ {f && problem(f)}
178
+ </View>
179
+ );
180
+ const choices = <V extends string>(lbl: string, list: { value: V; label: string }[], isOn: (v: V) => boolean, toggle: (v: V) => void, many = false) => (
181
+ <View accessibilityRole={many ? undefined : 'radiogroup'} accessibilityLabel={lbl} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
182
+ {list.map((c) => (
183
+ <Pressable key={c.value} accessibilityRole={many ? 'checkbox' : 'radio'} accessibilityState={{ checked: isOn(c.value) }} onPress={() => toggle(c.value)} style={chip(isOn(c.value))}>
184
+ <Text style={[t.styles.text.body, isOn(c.value) && { fontWeight: '600' }]}>{c.label}</Text>
185
+ </Pressable>
186
+ ))}
187
+ </View>
188
+ );
189
+ // times show back in the app's format once typed ("18:30" → "6:30 PM")
190
+ const tidy = (raw: string, set: (v: string) => void) => () => { const v = parseClock(raw); if (v) set(formatClock(v, locale)); };
191
+ const today = localDay(new Date().toISOString(), tz);
192
+ // the change picker: the site's public events, each with its next dates (the site's days)
193
+ const choosable = o?.events ?? [];
194
+ const chosen = choosable.find((e) => e.slug === event);
195
+ const showDay = (d: string) => formatEventDate({ start: `${d}T00:00:00.000Z`, allDay: true }, locale);
196
+
197
+ return screen(<>
198
+ <Text style={t.styles.text.muted}>{o?.notice || words.requestNotice}</Text>
199
+ <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', borderWidth: 1, borderColor: t.colors.line, borderRadius: radius, overflow: 'hidden' }}>
200
+ {(['new', 'change'] as const).map((k, i) => (
201
+ <Pressable key={k} accessibilityRole="radio" accessibilityState={{ checked: kind === k }} onPress={() => { setKind(k); setTried(false); if (send.error) send.reset(); }}
202
+ style={{ flex: 1, minHeight: 44, paddingHorizontal: 8, alignItems: 'center', justifyContent: 'center', backgroundColor: kind === k ? t.colors.soft : t.colors.panel, borderLeftWidth: i ? 1 : 0, borderLeftColor: t.colors.line }}>
203
+ <Text style={[t.styles.text.body, { textAlign: 'center' }, kind === k && { fontWeight: '600' }]}>{k === 'new' ? words.requestNew : words.requestChange}</Text>
204
+ </Pressable>
205
+ ))}
206
+ </View>
207
+
208
+ {kind === 'new' ? (<>
209
+ {text(words.requestWhat, title, setTitle, 'title')}
210
+ {!!o?.types.length && (
211
+ <View style={{ gap: 6 }}>
212
+ {label(words.requestKind)}
213
+ {choices(words.requestKind, o.types.map((x) => ({ value: x.id, label: x.label })), (v) => v === type, (v) => setType(v === type ? '' : v))}
214
+ {problem('type')}
215
+ </View>
216
+ )}
217
+ <View style={{ gap: 6 }}>
218
+ {label(words.requestDate)}
219
+ <DayPicker value={date} onChange={setDate} min={today} max={`${Number(today.slice(0, 4)) + 2}${today.slice(4)}`} />
220
+ </View>
221
+ {/* no time picker in the template: typed times, read forgivingly ("6:30 pm", "18:30", "18h30") */}
222
+ <View style={{ flexDirection: 'row', gap: 12 }}>
223
+ <View style={{ flex: 1 }}>{text(words.requestStarts, starts, setStarts, 'start', { keyboard: 'numbers-and-punctuation', placeholder: formatClock('18:00', locale), onBlur: tidy(starts, setStarts) })}</View>
224
+ <View style={{ flex: 1 }}>{text(words.requestEnds, ends, setEnds, 'end', { keyboard: 'numbers-and-punctuation', placeholder: formatClock('20:00', locale), onBlur: tidy(ends, setEnds) })}</View>
225
+ </View>
226
+ <View style={{ gap: 6 }}>
227
+ {label(words.requestRepeats)}
228
+ {choices<Repeat>(words.requestRepeats, [{ value: 'once', label: words.requestOnce }, { value: 'weekly', label: words.requestWeekly }, { value: 'other', label: words.requestRepeatOther }], (v) => v === repeat, setRepeat)}
229
+ {repeat === 'other' && (
230
+ <TextInput value={repeatText} onChangeText={setRepeatText} placeholder={words.requestRepeatHint} accessibilityLabel={words.requestRepeatHint} placeholderTextColor={t.colors.muted}
231
+ style={[field, !!problems.repeat && { borderColor: t.colors.ink }]} />
232
+ )}
233
+ {problem('repeat')}
234
+ </View>
235
+ {!!o?.rooms.length && (
236
+ <View style={{ gap: 6 }}>
237
+ {label(words.requestRoom)}
238
+ {/* rooms, or Not sure (not both) */}
239
+ {choices(words.requestRoom, [...o.rooms.map((r) => ({ value: r.slug, label: r.name })), { value: UNSURE, label: words.requestNotSure }],
240
+ (v) => (v === UNSURE ? unsure : !unsure && rooms.includes(v)),
241
+ (v) => { if (v === UNSURE) { setUnsure(!unsure); return; } setUnsure(false); setRooms(rooms.includes(v) ? rooms.filter((x) => x !== v) : [...rooms, v]); }, true)}
242
+ {problem('room')}
243
+ {!!checked && checked === span && rooms.map((slug) => (
244
+ <RoomLine key={slug} room={slug} name={o.rooms.find((r) => r.slug === slug)?.name ?? slug} start={checked.split('|')[0]} end={checked.split('|')[1]} />
245
+ ))}
246
+ </View>
247
+ )}
248
+ <View style={{ maxWidth: 200 }}>{text(words.requestPeople, people, (v) => setPeople(v.replace(/\D/g, '')), 'people', { optional: true, keyboard: 'number-pad' })}</View>
249
+ {text(words.requestMessage, message, setMessage, null, { optional: true, multiline: true })}
250
+ </>) : (<>
251
+ <View style={{ gap: 6 }}>
252
+ {label(words.requestWhichEvent)}
253
+ {!choosable.length ? <Text style={t.styles.text.muted}>{words.requestNoEvents}</Text> : (
254
+ <View accessibilityRole="radiogroup" accessibilityLabel={words.requestPickEvent} style={[t.styles.card, { paddingVertical: 4, paddingHorizontal: 0 }]}>
255
+ {choosable.map((e, i) => (
256
+ <Pressable key={e.slug} accessibilityRole="radio" accessibilityState={{ checked: e.slug === event }} onPress={() => { setEvent(e.slug); setEventDate(''); }}
257
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 48, paddingHorizontal: 16, borderTopWidth: i ? 1 : 0, borderTopColor: t.colors.line, backgroundColor: e.slug === event ? t.colors.soft : undefined }}>
258
+ <Text style={[t.styles.text.body, { flex: 1 }, e.slug === event && { fontWeight: '600' }]}>{e.title}</Text>
259
+ {e.slug === event && <Text style={[t.styles.text.body, { color: t.colors.accent }]} importantForAccessibility="no" accessibilityElementsHidden>✓</Text>}
260
+ </Pressable>
261
+ ))}
262
+ </View>
263
+ )}
264
+ {problem('event')}
265
+ </View>
266
+ {!!chosen && (
267
+ <View style={{ gap: 6 }}>
268
+ {label(words.requestWhichDate)}
269
+ {choices(words.requestWhichDate, [{ value: '', label: words.requestEveryDate }, ...chosen.dates.slice(0, 12).map((d) => ({ value: d, label: showDay(d) }))], (v) => v === eventDate, setEventDate)}
270
+ {problem('date')}
271
+ </View>
272
+ )}
273
+ {text(words.requestWhatChange, change, setChange, 'what', { multiline: true })}
274
+ </>)}
275
+
276
+ <View style={{ gap: 12 }}>
277
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{words.requestContact}</Text>
278
+ {text(words.requestName, name, setName, 'name', { auto: 'name' })}
279
+ {text(words.requestEmail, email, setEmail, 'email', { auto: 'email', keyboard: 'email-address' })}
280
+ {text(words.requestPhone, phone, setPhone, 'phone', { auto: 'tel', keyboard: 'phone-pad', optional: true })}
281
+ <Text style={t.styles.text.small}>
282
+ {words.requestContactWhy}
283
+ {!!site.legal.privacy && <> <Text accessibilityRole="link" style={[t.styles.text.small, { color: t.colors.accent, textDecorationLine: 'underline' }]} onPress={() => { Linking.openURL(site.legal.privacy!).catch(() => {}); }}>{words.requestPrivacy}</Text></>}
284
+ </Text>
285
+ </View>
286
+
287
+ {!!send.problem && !send.field && (
288
+ <View accessibilityRole="alert" style={{ backgroundColor: t.colors.soft, borderRadius: radius, padding: 12, gap: 4 }}>
289
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{requestProblemText(send.problem, words)}</Text>
290
+ </View>
291
+ )}
292
+ {invalid && <Text accessibilityRole="alert" style={errorText}>{words.requestCheck}</Text>}
293
+ <Button label={send.isPending ? words.sending : words.requestSend} disabled={send.isPending} onPress={submit} />
294
+ </>);
295
+ }
296
+
297
+ // "Hall looks free then." for one chosen room, over the first date's times (free or busy only, never what)
298
+ function RoomLine({ room, name, start, end }: { room: string; name: string; start: string; end: string }) {
299
+ const t = useTheme();
300
+ const words = useStrings();
301
+ const free = useRoomFree({ room, start, end });
302
+ // nothing while it loads, or when the site can't say (an error, too many checks)
303
+ if (!free.data) return null;
304
+ return <Text style={t.styles.text.small}>{fill(free.data.free ? words.requestRoomFree : words.requestRoomTaken, { room: name })}</Text>;
305
+ }
@@ -0,0 +1,66 @@
1
+ import { useState } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import { useLocale, useTheme } from '@acms-subir/native';
4
+
5
+ // One month to pick a day from (YYYY-MM-DD), with no native date picker to
6
+ // install: ‹ › change the month, and days before `min` or after `max` can't
7
+ // be picked. Days are plain calendar days (the site's, for requests), so
8
+ // everything here is UTC and never shifts with the phone's time zone.
9
+ const pad = (n: number) => String(n).padStart(2, '0');
10
+ const iso = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
11
+
12
+ export function DayPicker({ value, onChange, min, max }: { value: string; onChange: (day: string) => void; min: string; max?: string }) {
13
+ const t = useTheme();
14
+ const { locale } = useLocale();
15
+ const [month, setMonth] = useState(() => (value || min).slice(0, 7));
16
+ const [y, m] = month.split('-').map(Number);
17
+ const f = (o: Intl.DateTimeFormatOptions) => {
18
+ try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }); } catch { return new Intl.DateTimeFormat(undefined, { ...o, timeZone: 'UTC' }); }
19
+ };
20
+ const monthName = f({ month: 'long', year: 'numeric' });
21
+ const at = (n: number) => new Date(Date.UTC(y, m - 1 + n, 1));
22
+ const shift = (n: number) => setMonth(`${at(n).getUTCFullYear()}-${pad(at(n).getUTCMonth() + 1)}`);
23
+ const earlier = month > min.slice(0, 7), later = !max || month < max.slice(0, 7);
24
+ const length = new Date(Date.UTC(y, m, 0)).getUTCDate();
25
+ // weeks start on Sunday
26
+ const cells = [...Array<string | null>(at(0).getUTCDay()).fill(null), ...Array.from({ length }, (_, i) => iso(y, m, i + 1))];
27
+ const weekday = f({ weekday: 'short' });
28
+ const fullDay = f({ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
29
+ const radius = Math.min(t.radius, 10);
30
+ const arrow = (n: number, on: boolean, glyph: string) => (
31
+ // read out as the month it goes to ("December 2026"), so it needs no words of its own
32
+ <Pressable accessibilityRole="button" accessibilityLabel={monthName.format(at(n))} accessibilityState={{ disabled: !on }} disabled={!on} onPress={() => shift(n)}
33
+ style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center', opacity: on ? 1 : 0.3 }}>
34
+ <Text style={[t.styles.text.heading, { color: t.colors.accent }]}>{glyph}</Text>
35
+ </Pressable>
36
+ );
37
+ return (
38
+ <View style={[t.styles.card, { padding: 8, gap: 4 }]}>
39
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
40
+ {arrow(-1, earlier, '‹')}
41
+ <Text accessibilityRole="header" style={[t.styles.text.body, { flex: 1, textAlign: 'center', fontWeight: '600' }]}>{monthName.format(at(0))}</Text>
42
+ {arrow(1, later, '›')}
43
+ </View>
44
+ <View style={{ flexDirection: 'row' }} importantForAccessibility="no-hide-descendants" accessibilityElementsHidden>
45
+ {/* 2026-01-04 was a Sunday */}
46
+ {Array.from({ length: 7 }, (_, i) => (
47
+ <Text key={i} style={[t.styles.text.small, { width: `${100 / 7}%`, textAlign: 'center' }]}>{weekday.format(new Date(Date.UTC(2026, 0, 4 + i)))}</Text>
48
+ ))}
49
+ </View>
50
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
51
+ {cells.map((d, i) => {
52
+ if (!d) return <View key={`_${i}`} style={{ width: `${100 / 7}%`, height: 44 }} />;
53
+ const off = d < min || (!!max && d > max), on = d === value;
54
+ return (
55
+ <Pressable key={d} accessibilityRole="button" accessibilityLabel={fullDay.format(new Date(`${d}T00:00:00Z`))} accessibilityState={{ selected: on, disabled: off }}
56
+ disabled={off} onPress={() => onChange(d)} style={{ width: `${100 / 7}%`, height: 44, padding: 2 }}>
57
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius, backgroundColor: on ? t.colors.accent : undefined }}>
58
+ <Text style={[t.styles.text.body, { color: on ? t.colors.onAccent : off ? t.colors.line : t.colors.ink }, on && { fontWeight: '600' }]}>{Number(d.slice(8))}</Text>
59
+ </View>
60
+ </Pressable>
61
+ );
62
+ })}
63
+ </View>
64
+ </View>
65
+ );
66
+ }
@@ -0,0 +1,19 @@
1
+ import { Pressable, Text, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { Link } from 'expo-router';
3
+ import { useStrings, useTheme } from '@acms-subir/native';
4
+
5
+ // "Planning something? Request an event" for the end of the events list,
6
+ // e.g. ListFooterComponent={<RequestEventLink />} in app/events/index.tsx.
7
+ export function RequestEventLink({ style }: { style?: StyleProp<ViewStyle> }) {
8
+ const t = useTheme();
9
+ const words = useStrings();
10
+ return (
11
+ <Link href="/request" asChild>
12
+ <Pressable accessibilityRole="link" style={({ pressed }) => [{ minHeight: 44, paddingVertical: 16, justifyContent: 'center', opacity: pressed ? 0.7 : 1 }, style]}>
13
+ <Text style={[t.styles.text.muted, { textAlign: 'center' }]}>
14
+ {words.requestPlanning} <Text style={{ color: t.colors.accent, fontWeight: '600' }}>{words.requestEvent}</Text>
15
+ </Text>
16
+ </Pressable>
17
+ </Link>
18
+ );
19
+ }
@@ -0,0 +1,20 @@
1
+ import { Pressable, Text, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { Link } from 'expo-router';
3
+ import { useStrings, useTheme } from '@acms-subir/native';
4
+
5
+ // The More tab's row that opens /request: "Request an event" ("Solicitar un
6
+ // evento"). Always shown, even while the site isn't taking requests: the
7
+ // screen then says so and gives the office phone. Hiding it would hide a
8
+ // feature from App Review, so don't wrap it in a check.
9
+ export function RequestEventRow({ style }: { style?: StyleProp<ViewStyle> }) {
10
+ const t = useTheme();
11
+ const words = useStrings();
12
+ return (
13
+ <Link href="/request" asChild>
14
+ <Pressable accessibilityRole="button" style={({ pressed }) => [{ flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 52, paddingVertical: 12, opacity: pressed ? 0.7 : 1 }, style]}>
15
+ <Text style={[t.styles.text.body, { flex: 1 }]}>{words.requestEvent}</Text>
16
+ <Text style={[t.styles.text.heading, { color: t.colors.muted }]} importantForAccessibility="no" accessibilityElementsHidden>›</Text>
17
+ </Pressable>
18
+ </Link>
19
+ );
20
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "kind": "client",
3
+ "title": "Event requests",
4
+ "description": "Request an event without an account: something new (what, kind, date and times, repeats, rooms with a live \"looks free\" line, people) or a change to an event, then name, email and phone, and Request sent with the reply time. When the site isn't taking requests the screen still opens and says so, with the office phone. Includes a row for the More tab and a link for the end of the events list. Needs requests turned on for the site.",
5
+ "packages": [
6
+ "expo-crypto",
7
+ "expo-secure-store"
8
+ ],
9
+ "routes": [
10
+ "/request"
11
+ ],
12
+ "next": [
13
+ "Turn requests on for the site: acms({ requests: true }) in astro.config.mjs (needs the calendar), then restart or deploy. Set the notice, reply time, office phone, kinds and the rooms people may ask for in Admin → Calendar → Requests; staff answer requests there",
14
+ "Add the row to your More tab: import { RequestEventRow } from '../../components/RequestEventRow' in app/(tabs)/more.tsx (or '../components/RequestEventRow' from a screen in app/) and render <RequestEventRow /> in its list. Keep it there even while requests are closed: the screen says so and gives the office phone, and App Review looks for it",
15
+ "Optional, at the end of the events list: ListFooterComponent={<RequestEventLink />} in app/events/index.tsx (from '../../components/RequestEventLink')",
16
+ "Open the screen from site links: in onNavigate, send '/request' (or your acms({ requests: { path } }); '/es/request' in Spanish) to router.push('/request')",
17
+ "The live \"looks free\" line under the rooms needs bookings on the site (it uses GET /api/v1/rooms/free, free or busy only); without them the screen works the same, without that line",
18
+ "App Store Connect → App Review Information → Notes: \"Request an event: open the More tab and tap Request an event. No sign-in is needed. The screen asks for a name, an email address, an optional phone number and details or a message about the event (something new, or a change to an existing event). Staff review every request on the website before anything goes on the calendar, and answer by email. If you send a test request, please write App Review in the message so staff can decline it.\"",
19
+ "App Privacy (App Store Connect) and Data safety (Google Play) for what the screen sends: Contact Info → Name, Email Address, Phone Number (Play: Personal info → Name, Email address, Phone number) and User Content → Other User Content (Play: App activity → Other user-generated content). Purpose: App Functionality; linked to the person; not used for tracking; not shared; optional (people choose to send it). Name, email and phone also stay on the phone in secure storage for next time"
20
+ ]
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.55",
3
+ "version": "0.3.57",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -11,7 +11,7 @@ import { validateRequestSession } from '../lib/auth/request';
11
11
  import { accessFor } from '../lib/permissions/access';
12
12
  import { deniedMessage, type Permission } from '../lib/permissions/catalog';
13
13
  import { isStaffRole } from '../lib/roles';
14
- import { DocTranslations, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
14
+ import { DocTranslations, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind, SeriesSegment, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
15
15
  import { env } from '../lib/env';
16
16
  import { CONTENT, collectionUrl } from '../lib/content';
17
17
  import { invalidateRedirectCache } from '../middleware';
@@ -30,8 +30,9 @@ import { loadFeed } from '../lib/calendar/ics';
30
30
  import { NATIVE_ALL } from '../lib/calendar/render';
31
31
  import { collectViewEvents } from '../lib/calendar/data';
32
32
  import { loadBoard } from '../lib/calendar/board';
33
- import { cancelOccurrence, duplicateEvent, moveDateBy, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
33
+ import { cancelOccurrence, duplicateEvent, moveDateBy, moveFollowingBy, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
34
34
  import { moveOccurrence, moveOccurrenceBack, upcomingDates } from '../lib/calendar/moves';
35
+ import { changeFromDate, changeFromDefaults, revertFromDate, upcomingSegments } from '../lib/calendar/segments';
35
36
  import type { BusySlot } from '../lib/booking/holds';
36
37
  import { waitUntilFrom } from '../lib/webhooks';
37
38
  import { siteOrigin } from '../lib/seo';
@@ -1153,16 +1154,17 @@ export const server = {
1153
1154
  }),
1154
1155
  // Schedule: drag to another day (and, in the week view, another time:
1155
1156
  // `minutes` more). `occurrence` + mode 'one' moves a single date of a
1156
- // series (a moved date that keeps its RSVPs); otherwise the whole event
1157
- // moves. `warning`: what else takes its rooms at the new time.
1157
+ // series (a moved date that keeps its RSVPs), mode 'following' that date
1158
+ // and every one after it (a change from that date on); otherwise the
1159
+ // whole event moves. `warning`: what else takes its rooms at the new time.
1158
1160
  move: defineAction({
1159
- input: z.object({ id: z.string(), days: z.number().int(), minutes: z.number().int().min(-1440).max(1440).default(0), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
1161
+ input: z.object({ id: z.string(), days: z.number().int(), minutes: z.number().int().min(-1440).max(1440).default(0), mode: z.enum(['series', 'one', 'following']).default('series'), occurrence: z.string().max(20).default('') }),
1160
1162
  handler: async ({ id, days, minutes, mode, occurrence }, ctx) => {
1161
1163
  const who = actorOf(await requireCan(ctx, 'content.edit'));
1162
1164
  const repo = await getRepo(ctx.locals);
1163
1165
  try {
1164
- if (mode === 'one') {
1165
- const r = await moveDateBy(repo, who, id, occurrence, days * 1440 + minutes, await publishHooks(ctx));
1166
+ if (mode === 'one' || mode === 'following') {
1167
+ const r = await (mode === 'one' ? moveDateBy : moveFollowingBy)(repo, who, id, occurrence, days * 1440 + minutes, await publishHooks(ctx));
1166
1168
  return { id: r.event.id, undo: r.undo, warning: await clashWarning(repo, r.clashes) };
1167
1169
  }
1168
1170
  const r = await moveSeries(repo, id, days, await publishHooks(ctx), minutes);
@@ -1178,6 +1180,7 @@ export const server = {
1178
1180
  location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(2000).default('') }).partial().optional(),
1179
1181
  }).nullable() }),
1180
1182
  z.object({ kind: z.literal('split'), id: z.string(), seriesId: z.string(), occurrence: z.string().max(20) }),
1183
+ z.object({ kind: z.literal('following'), id: z.string(), from: z.string().max(20), previous: z.array(SeriesSegment).max(50) }),
1181
1184
  ]),
1182
1185
  handler: async (undo, ctx) => {
1183
1186
  const who = actorOf(await requireCan(ctx, 'content.edit'));
@@ -1212,7 +1215,8 @@ export const server = {
1212
1215
  },
1213
1216
  }),
1214
1217
  // Event editor → Single dates: the next dates of a series (as saved), each
1215
- // moved, cancelled or as planned, and the rooms a date can move into
1218
+ // moved, cancelled or as planned, the rooms a date can move into, and the
1219
+ // changes from a date on still to come or in effect
1216
1220
  dates: defineAction({
1217
1221
  input: z.object({ id: z.string(), limit: z.number().int().min(1).max(52).default(12) }),
1218
1222
  handler: async ({ id, limit }, ctx) => {
@@ -1220,7 +1224,46 @@ export const server = {
1220
1224
  const repo = await getRepo(ctx.locals);
1221
1225
  const [e, settings, resources] = await Promise.all([repo.getEvent(id), repo.getSettings(), bookingsEnabled() ? repo.listResources() : Promise.resolve([])]);
1222
1226
  if (!e) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
1223
- return { allDay: e.allDay, roomIds: e.roomHold.resourceIds, dates: upcomingDates(e, settings, limit), rooms: resources.map((r) => ({ id: r.id, name: r.name })) };
1227
+ return { allDay: e.allDay, roomIds: e.roomHold.resourceIds, dates: upcomingDates(e, settings, limit), rooms: resources.map((r) => ({ id: r.id, name: r.name })),
1228
+ segments: upcomingSegments(e, settings, new Map(resources.map((r) => [r.id, r.name]))) };
1229
+ },
1230
+ }),
1231
+ // …change the series from one of those dates on (new first date, times,
1232
+ // repeat, rooms; the dates before it stay). `rrule` omitted: the rule in
1233
+ // effect, on the new weekday. `warning`: what else takes the rooms then.
1234
+ changeFromDate: defineAction({
1235
+ input: z.object({ id: z.string(), key: z.string().max(20), start: z.string().max(20), end: z.string().max(20).default(''), rrule: z.string().max(500).optional(),
1236
+ roomIds: z.array(z.string()).max(50).optional(), note: z.string().max(500).default('') }),
1237
+ handler: async ({ id, key, start, end, rrule, roomIds, note }, ctx) => {
1238
+ const who = actorOf(await requireCan(ctx, 'content.edit'));
1239
+ const repo = await getRepo(ctx.locals);
1240
+ try {
1241
+ const r = await changeFromDate(repo, who, id, key, { start, end: end || undefined, rrule, roomIds, note }, await publishHooks(ctx));
1242
+ return { id, changed: r.changed, rsvps: r.rsvps, dropped: r.dropped.length, warning: await clashWarning(repo, r.clashes) };
1243
+ } catch (e) { asActionError(e); }
1244
+ },
1245
+ }),
1246
+ // …what that form starts with for a date (its times, the rule in effect there, its rooms)
1247
+ fromDateDefaults: defineAction({
1248
+ input: z.object({ id: z.string(), key: z.string().max(20) }),
1249
+ handler: async ({ id, key }, ctx) => {
1250
+ await requireAdmin(ctx);
1251
+ const repo = await getRepo(ctx.locals);
1252
+ const d = await changeFromDefaults(repo, id, key);
1253
+ if (!d) throw new ActionError({ code: 'BAD_REQUEST', message: 'That date isn’t part of this event.' });
1254
+ return d;
1255
+ },
1256
+ }),
1257
+ // …and undo one: the rule before it carries on
1258
+ revertFromDate: defineAction({
1259
+ input: z.object({ id: z.string(), from: z.string().max(20) }),
1260
+ handler: async ({ id, from }, ctx) => {
1261
+ const who = actorOf(await requireCan(ctx, 'content.edit'));
1262
+ const repo = await getRepo(ctx.locals);
1263
+ try {
1264
+ const r = await revertFromDate(repo, who, id, from, await publishHooks(ctx));
1265
+ return { id, changed: r.changed, rsvps: r.rsvps };
1266
+ } catch (e) { asActionError(e); }
1224
1267
  },
1225
1268
  }),
1226
1269
  // Schedule: cancel (or restore) one date of a repeating event, at once
@@ -6,8 +6,8 @@
6
6
  // the page's language (lib/calendar/event-words.ts, also in the theme SDK).
7
7
  import type { CalEvent, Calendar, Settings } from '../lib/schema';
8
8
  import { CALENDAR, eventIcsUrl, eventUrl, calendarPageUrl } from '../lib/calendar/config';
9
- import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
10
- import { describeRepeat } from '../lib/calendar/rrule';
9
+ import { addToCalendarLinks, describeSeries, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
10
+
11
11
  import { eventCardHtml, safeHref } from '../lib/calendar/cards';
12
12
  import { siteLang, siteLocale } from '../lib/dates';
13
13
  import { I18N, localePath, requestLocale } from '../lib/i18n';
@@ -36,7 +36,7 @@ const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, pr
36
36
  // the whole event, or just this date of a repeating one
37
37
  const cancelled = e.cancelled || Boolean(o.cancelled);
38
38
  // a moved date may be at its own place
39
- const where = o.moved?.location ?? e.location;
39
+ const where = o.location ?? e.location;
40
40
  const image = eventImage(e, settings);
41
41
  const tz = eventTimeZone(e, settings);
42
42
  const locale = siteLocale(settings);
@@ -49,10 +49,11 @@ const ew = eventStrings(lang);
49
49
  const here = (path: string) => localePath(path, reqLocale);
50
50
  const pageUrl = origin + here(eventUrl(e.slug));
51
51
  const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
52
- const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
52
+ // the rule in effect, and a change from a date on still to come (CalEvent.segments)
53
+ const repeats = describeSeries(e, settings, Date.now(), lang, (ymd) => {
53
54
  const [y, m, d] = ymd.split('-').map(Number);
54
55
  return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
55
- }, lang);
56
+ }, locale);
56
57
  const ctaHref = safeHref(e.cta.href);
57
58
  const mapHref = where.url
58
59
  ? safeHref(where.url)
@@ -38,7 +38,7 @@ function ago(ms: number, now: number) {
38
38
  : d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
39
39
  }
40
40
 
41
- 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', test: 'Test by', form: 'New form response' };
41
+ 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', test: 'Test by', form: 'New form response', request: 'New event request' };
42
42
 
43
43
  export default function MobileApp() {
44
44
  const [state, setState] = useState<AdminPushState | null>(null);
@@ -462,7 +462,7 @@ function DeliveryLog({ log, topicLabel }: { log: PushNotification[]; topicLabel:
462
462
  {n.body && <div className="ma-body">{n.body}</div>}
463
463
  {n.url && <div className="t-sub ma-url">{n.url}</div>}
464
464
  </td>
465
- <td data-l="Audience" className="ma-aud">{n.source === 'test' ? 'Their test phones' : n.source === 'form' ? 'Staff on the form’s alert list' : n.topics.map(topicLabel).join(', ')}</td>
465
+ <td data-l="Audience" className="ma-aud">{n.source === 'test' ? 'Their test phones' : n.source === 'form' ? 'Staff on the form’s alert list' : n.source === 'request' ? 'Staff on the requests alert list' : n.topics.map(topicLabel).join(', ')}</td>
466
466
  <td data-l="From">{SOURCE[n.source]}{(n.source === 'manual' || n.source === 'test') && n.author ? ` ${n.author}` : n.source === 'manual' || n.source === 'test' ? ' an owner' : ''}</td>
467
467
  <td data-l="Delivered" className="num ma-result">
468
468
  {n.status === 'sending' ? <span className="ma-st" data-tone="info">Sending…</span>