@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.
- package/CHANGELOG.md +29 -0
- package/cli/migrations.generated.mjs +16 -4
- package/docs/calendar.md +189 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +84 -1
- package/integration.mjs +34 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +87 -17
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/api.ts +23 -2
- package/src/lib/calendar/board.ts +3 -2
- package/src/lib/calendar/cards.ts +4 -4
- package/src/lib/calendar/change-migration.ts +17 -3
- package/src/lib/calendar/data.ts +9 -2
- package/src/lib/calendar/event-words.ts +8 -0
- package/src/lib/calendar/events.ts +128 -15
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/calendar/host-requests.ts +65 -10
- package/src/lib/calendar/ics-write.ts +65 -15
- package/src/lib/calendar/member-words.ts +14 -1
- package/src/lib/calendar/moves.ts +15 -9
- package/src/lib/calendar/request-admin.ts +212 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/editor/schedule.ts +2 -2
- package/src/lib/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/calendar.ts +1 -0
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +24 -6
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +3 -1
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +24 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +67 -2
- package/src/lib/sections.ts +1 -1
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +11 -8
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/members/events/index.astro +84 -6
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +102 -4
- package/src/styles/editor.css +2 -0
- package/src/styles/site.css +29 -0
- 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
package/src/actions/index.ts
CHANGED
|
@@ -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)
|
|
1157
|
-
//
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
},
|
|
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>
|