@acms-subir/core 0.3.38 → 0.3.39
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 +11 -0
- package/docs/mobile.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +11 -7
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +11 -7
- package/package.json +1 -1
- package/src/actions/index.ts +2 -1
- package/src/components/EventPage.astro +14 -5
- package/src/components/MobileApp.tsx +44 -23
- package/src/components/editor/EventPanel.tsx +2 -1
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +51 -1
- package/src/components/editor/event/registration.ts +18 -1
- package/src/lib/booking/emails.ts +3 -1
- package/src/lib/booking/service.ts +37 -10
- package/src/lib/calendar/events.ts +11 -0
- package/src/lib/calendar/rsvp-words.ts +31 -2
- package/src/lib/content-service.ts +26 -14
- package/src/lib/drafts.ts +2 -0
- package/src/lib/editor/documents.ts +3 -1
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +10 -1
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/http.ts +1 -1
- package/src/lib/mobile/openapi.ts +1 -1
- package/src/lib/mobile/rsvp.ts +14 -8
- package/src/lib/mobile/schemas.ts +5 -2
- package/src/lib/push/api.ts +7 -8
- package/src/lib/push/service.ts +21 -16
- package/src/lib/push/types.ts +18 -8
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +3 -2
- package/src/lib/schema.ts +5 -1
- package/src/pages/events/[slug].astro +10 -3
- package/src/styles/editor.css +4 -0
- package/src/styles/mobile-app.css +7 -0
- package/src/styles/site.css +3 -0
- package/src/theme-sdk/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.39
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4c33f48: **When an event changes** in **Admin → Mobile app** now takes a list of rules (up to 10), each with its own calendars, **Only events tagged** and **Send to**. Use **Add a rule** to add one and **Remove** to take one away; with no rules, change notices are off. A site can now send classes tagged "Class" in a shared rooms calendar to a `classes` topic, and every event in its Events calendar to that event's own topics (Events and its calendar). An event that several rules cover gets one notice, sent to all of their topics, so each phone gets it once. Events in the app API list those same topics in `notify.changes`. The rule you already have becomes the first rule, with nothing to redo.
|
|
8
|
+
- 527ba2f: Events can stop taking RSVPs before they happen. In the event's CTA card (Built-in form), a one-time event sets when RSVPs close (a date and time in the event's time zone), and a repeating one closes each date some hours or days before it starts ("Close RSVPs 2 hours before each date", up to 60 days). Until then the event page says "RSVPs close Fri, Oct 24 at 9 PM"; afterwards the form gives way to "RSVPs are closed." and "They closed on Fri, Oct 24. Questions? Contact us.", in the page's language. With no deadline, RSVPs stay open until the date ends, as before. The website and apps both turn late RSVPs away (410 in the app API). `GET /api/v1/events/:slug/rsvp` has `closesAt` (the date's deadline, or `null`) and the new reason `closed`, and events in the app API carry `rsvp.closesAt` and `rsvp.closeBefore`. Saves that leave the new settings out, from older editors and editor apps, keep them.
|
|
9
|
+
|
|
10
|
+
A second RSVP from the same email for the same date no longer takes more seats. The page says "You already have an RSVP for this date. We sent you an email with a link to change it. Need more seats? Change it there." and keeps the form, and the address gets its RSVP's link again by email, at most once every 10 minutes per RSVP, so the form can't flood someone's inbox. Nothing else about the RSVP is shown, since anyone can type an address. In the app API that's a 409 with the new error code `duplicate`. Cancelled and declined RSVPs don't count, and other dates are separate. The check is part of the same database write as the seat count, so two at once can't both get in.
|
|
11
|
+
|
|
12
|
+
The RSVP panel in the app recipes shows when RSVPs close, says when they closed, and shows the "You already have an RSVP" notice. Update it with `npx acms mobile recipe add rsvp --force`; that overwrites `components/RsvpPanel.tsx`, including any changes you made to it, and needs this release's `@acms-subir/native`. Theme event templates get `rsvp.closesAt` and `rsvp.duplicate`; a duplicate's words also come in `rsvp.error`, so templates that don't know it still say something.
|
|
13
|
+
|
|
3
14
|
## 0.3.38
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
package/docs/mobile.md
CHANGED
|
@@ -120,7 +120,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
120
120
|
| `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
|
|
121
121
|
| `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90). `from`/`to` take a site-local day (`'2026-10-01'`) or an ISO date-time with a zone (`new Date().toISOString()`) |
|
|
122
122
|
| `useEvent(slug)` | one event |
|
|
123
|
-
| `useRsvpStatus(slug, start)` / `useRsvp(slug, start)` | RSVP to one date (`start` exactly as `useEvents` returns it, a UTC ISO string) when `event.rsvp.enabled`: `open` or `reason` (`full`, `ended`, `cancelled`, `disabled`), live `going`/`left`, `maxSeats`, the questions in `form`; `answerProblems(form.fields, answers)` for inline errors. Needs only the calendar (`features.rsvp`). Keep `manageToken` in `expo-secure-store` to show or cancel the RSVP later (`useManagedBooking`, `useCancelBooking`) |
|
|
123
|
+
| `useRsvpStatus(slug, start)` / `useRsvp(slug, start)` | RSVP to one date (`start` exactly as `useEvents` returns it, a UTC ISO string) when `event.rsvp.enabled`: `open` or `reason` (`full`, `ended`, `closed`, `cancelled`, `disabled`), `closesAt` (the deadline; `rsvpDeadlineText` words it), live `going`/`left`, `maxSeats`, the questions in `form`; `answerProblems(form.fields, answers)` for inline errors. One RSVP per email and date: `rsvp.duplicate` means that email already has one (the site emailed its link again); say so and keep the form. Needs only the calendar (`features.rsvp`). Keep `manageToken` in `expo-secure-store` to show or cancel the RSVP later (`useManagedBooking`, `useCancelBooking`) |
|
|
124
124
|
| `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
|
|
125
125
|
| `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
|
|
126
126
|
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
|
|
@@ -3,15 +3,16 @@ import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View,
|
|
|
3
3
|
import * as Crypto from 'expo-crypto';
|
|
4
4
|
import * as SecureStore from 'expo-secure-store';
|
|
5
5
|
import { isAcmsError, type FormField } from '@acms-subir/client';
|
|
6
|
-
import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
6
|
+
import { Button, answerProblemText, answerProblems, countWords, fill, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
7
7
|
|
|
8
8
|
// RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
|
|
9
9
|
// with `start` exactly as useEvents returns it. Shows going and spots left,
|
|
10
10
|
// seats, contact details and the event's questions, then the person's RSVP
|
|
11
11
|
// with a way to cancel it; or why the date takes no RSVPs. Same rules as the
|
|
12
|
-
// event's web page: open until the
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// event's web page: open until the event's deadline or the date ends, one RSVP
|
|
13
|
+
// per email and date, and the organiser may approve each RSVP. useRsvp keeps
|
|
14
|
+
// one idempotency key per RSVP, so sending again after a timeout can't
|
|
15
|
+
// register anyone twice. Its words come from
|
|
15
16
|
// useStrings() in the app's language; the event's own form title, labels and
|
|
16
17
|
// button win when the site has them.
|
|
17
18
|
|
|
@@ -91,6 +92,8 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
91
92
|
const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
|
|
92
93
|
const num = (n: number) => n.toLocaleString(locale);
|
|
93
94
|
const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
|
|
95
|
+
// "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
|
|
96
|
+
const deadline = rsvpDeadlineText(s, words, locale);
|
|
94
97
|
const going = (
|
|
95
98
|
<Text style={t.styles.text.body}>
|
|
96
99
|
{bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
|
|
@@ -146,7 +149,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
146
149
|
if (!s.open) return (
|
|
147
150
|
<View style={[t.styles.card, { gap: 6 }]}>
|
|
148
151
|
<Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
|
|
149
|
-
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
|
|
152
|
+
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
|
|
150
153
|
</View>
|
|
151
154
|
);
|
|
152
155
|
|
|
@@ -242,6 +245,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
242
245
|
<View style={{ gap: 4 }}>
|
|
243
246
|
<Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
|
|
244
247
|
{going}
|
|
248
|
+
{!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
|
|
245
249
|
{!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
|
|
246
250
|
{!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
|
|
247
251
|
</View>
|
|
@@ -263,9 +267,9 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
263
267
|
{fields.map(field)}
|
|
264
268
|
{error && (
|
|
265
269
|
<View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
|
|
266
|
-
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
|
|
270
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{rsvp.duplicate ? words.alreadyRsvped : words.notSent}</Text>
|
|
267
271
|
<Text style={t.styles.text.muted}>
|
|
268
|
-
{isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
|
|
272
|
+
{rsvp.duplicate ? words.alreadyRsvpedNote : isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
|
|
269
273
|
</Text>
|
|
270
274
|
</View>
|
|
271
275
|
)}
|
|
@@ -3,15 +3,16 @@ import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View,
|
|
|
3
3
|
import * as Crypto from 'expo-crypto';
|
|
4
4
|
import * as SecureStore from 'expo-secure-store';
|
|
5
5
|
import { isAcmsError, type FormField } from '@acms-subir/client';
|
|
6
|
-
import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
6
|
+
import { Button, answerProblemText, answerProblems, countWords, fill, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
7
7
|
|
|
8
8
|
// RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
|
|
9
9
|
// with `start` exactly as useEvents returns it. Shows going and spots left,
|
|
10
10
|
// seats, contact details and the event's questions, then the person's RSVP
|
|
11
11
|
// with a way to cancel it; or why the date takes no RSVPs. Same rules as the
|
|
12
|
-
// event's web page: open until the
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// event's web page: open until the event's deadline or the date ends, one RSVP
|
|
13
|
+
// per email and date, and the organiser may approve each RSVP. useRsvp keeps
|
|
14
|
+
// one idempotency key per RSVP, so sending again after a timeout can't
|
|
15
|
+
// register anyone twice. Its words come from
|
|
15
16
|
// useStrings() in the app's language; the event's own form title, labels and
|
|
16
17
|
// button win when the site has them.
|
|
17
18
|
|
|
@@ -91,6 +92,8 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
91
92
|
const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
|
|
92
93
|
const num = (n: number) => n.toLocaleString(locale);
|
|
93
94
|
const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
|
|
95
|
+
// "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
|
|
96
|
+
const deadline = rsvpDeadlineText(s, words, locale);
|
|
94
97
|
const going = (
|
|
95
98
|
<Text style={t.styles.text.body}>
|
|
96
99
|
{bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
|
|
@@ -146,7 +149,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
146
149
|
if (!s.open) return (
|
|
147
150
|
<View style={[t.styles.card, { gap: 6 }]}>
|
|
148
151
|
<Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
|
|
149
|
-
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
|
|
152
|
+
{s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
|
|
150
153
|
</View>
|
|
151
154
|
);
|
|
152
155
|
|
|
@@ -242,6 +245,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
242
245
|
<View style={{ gap: 4 }}>
|
|
243
246
|
<Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
|
|
244
247
|
{going}
|
|
248
|
+
{!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
|
|
245
249
|
{!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
|
|
246
250
|
{!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
|
|
247
251
|
</View>
|
|
@@ -263,9 +267,9 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
263
267
|
{fields.map(field)}
|
|
264
268
|
{error && (
|
|
265
269
|
<View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
|
|
266
|
-
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
|
|
270
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{rsvp.duplicate ? words.alreadyRsvped : words.notSent}</Text>
|
|
267
271
|
<Text style={t.styles.text.muted}>
|
|
268
|
-
{isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
|
|
272
|
+
{rsvp.duplicate ? words.alreadyRsvpedNote : isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
|
|
269
273
|
</Text>
|
|
270
274
|
</View>
|
|
271
275
|
)}
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -994,7 +994,8 @@ export const server = {
|
|
|
994
994
|
cta: z.object({ label: z.string().max(120), href: z.string().max(1000), style: z.enum(['primary', 'outline']) }).optional(),
|
|
995
995
|
calendarIds: z.array(z.string()).max(50).optional(),
|
|
996
996
|
fields: z.record(z.string().max(2000)).optional(),
|
|
997
|
-
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
|
|
997
|
+
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
|
|
998
|
+
closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), form: z.any().optional() }).optional(),
|
|
998
999
|
roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
|
|
999
1000
|
scheduledAt: nullableTs.optional(),
|
|
1000
1001
|
}),
|
|
@@ -11,7 +11,7 @@ import { describeRepeat } from '../lib/calendar/rrule';
|
|
|
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';
|
|
14
|
-
import { aroundCount, rsvpStrings } from '../lib/calendar/rsvp-words';
|
|
14
|
+
import { aroundCount, deadlineParts, rsvpStrings } from '../lib/calendar/rsvp-words';
|
|
15
15
|
import { eventStrings } from '../lib/calendar/event-words';
|
|
16
16
|
import { fill } from '../lib/live/words';
|
|
17
17
|
import { eventImage } from '../lib/calendar/images';
|
|
@@ -26,8 +26,10 @@ interface Props {
|
|
|
26
26
|
upcoming: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean })[];
|
|
27
27
|
origin: string;
|
|
28
28
|
preview?: boolean;
|
|
29
|
-
// RSVP state when the event takes RSVPs (null otherwise)
|
|
30
|
-
|
|
29
|
+
// RSVP state when the event takes RSVPs (null otherwise). closesAt: this
|
|
30
|
+
// date's RSVP deadline (ms; null = open until it ends); duplicate: the
|
|
31
|
+
// address already has an RSVP for it (error then holds the same words)
|
|
32
|
+
rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; closesAt?: number | null; duplicate?: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
|
|
31
33
|
}
|
|
32
34
|
const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
|
|
33
35
|
// the whole event, or just this date of a repeating one
|
|
@@ -78,6 +80,11 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
78
80
|
const w = rsvpStrings(lang);
|
|
79
81
|
const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
|
|
80
82
|
const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.spotsLeft) : ['', ''];
|
|
83
|
+
const taking = Boolean(rsvp?.open) && (rsvp!.left === null || rsvp!.left > 0);
|
|
84
|
+
// "RSVPs close Fri, Oct 24 at 9 PM" while open; once past, "They closed on Fri, Oct 24."
|
|
85
|
+
const deadline = typeof rsvp?.closesAt === 'number' ? deadlineParts(rsvp.closesAt, tz, locale) : null;
|
|
86
|
+
const closesLine = deadline && taking ? fill(w.closesOn, deadline) : '';
|
|
87
|
+
const closedLine = deadline && !rsvp!.open && !cancelled && rsvp!.closesAt! <= Date.now() ? fill(w.closedOn, deadline) : '';
|
|
81
88
|
const dateCancelled = fill(ew.dateCancelled, { date: f.date });
|
|
82
89
|
const showing = fill(ew.showing, { date: f.date });
|
|
83
90
|
const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: true, href: here(eventUrl(u.event.slug)) })).join('');
|
|
@@ -151,14 +158,15 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
|
|
|
151
158
|
{rsvp ? (
|
|
152
159
|
<div class="pc-ev-rsvp">
|
|
153
160
|
<p class="pc-ev-going">{going[0]}<b>{rsvp.going}</b>{going[1]}{rsvp.left !== null && <> · {left[0]}<b>{rsvp.left}</b>{left[1]}</>}</p>
|
|
154
|
-
{
|
|
161
|
+
{closesLine && <p class="pc-ev-closes">{closesLine}</p>}
|
|
162
|
+
{taking ? (
|
|
155
163
|
<details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
|
|
156
164
|
<summary class="pc-ev-cta">{w.saveSeat}</summary>
|
|
157
165
|
<form method="post">
|
|
158
166
|
<Fragment set:html={rsvp.formHtml ?? ''}/>
|
|
159
167
|
{e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
|
|
160
168
|
{e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
|
|
161
|
-
{rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
|
|
169
|
+
{rsvp.duplicate ? <p class="pc-ev-notice" role="status"><b>{w.duplicate}</b> {w.duplicateNote}</p> : rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
|
|
162
170
|
<label class="pc-bk-field"><span>{w.nameField}</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
|
|
163
171
|
<label class="pc-bk-field"><span>{w.emailField}</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
|
|
164
172
|
{rsvp.maxSeats > 1 && (
|
|
@@ -171,6 +179,7 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
|
|
|
171
179
|
</form>
|
|
172
180
|
</details>
|
|
173
181
|
) : <p class="pc-ev-full">{rsvp.open ? w.full : w.closed}</p>}
|
|
182
|
+
{closedLine && <p class="pc-ev-closes">{closedLine}</p>}
|
|
174
183
|
</div>
|
|
175
184
|
) : e.cta.label && ctaHref && !cancelled && (
|
|
176
185
|
<a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type { AdminPushState } from '../lib/push/api';
|
|
3
|
-
import type { PushAuto, PushNotification } from '../lib/push/types';
|
|
3
|
+
import type { ChangeRule, PushAuto, PushNotification } from '../lib/push/types';
|
|
4
4
|
import '../styles/mobile-app.css';
|
|
5
5
|
|
|
6
6
|
// Admin → Mobile app: send a notification to the site's own app, choose the
|
|
@@ -10,6 +10,8 @@ import '../styles/mobile-app.css';
|
|
|
10
10
|
const ENDPOINT = '/api/push/admin';
|
|
11
11
|
const CUSTOM = '__custom';
|
|
12
12
|
const SOON_MINUTES = [10, 15, 30, 60, 120, 1440];
|
|
13
|
+
// MAX_CHANGE_RULES (lib/push/types, which brings zod)
|
|
14
|
+
const MAX_RULES = 10;
|
|
13
15
|
|
|
14
16
|
async function api<T>(body?: Record<string, unknown>): Promise<T> {
|
|
15
17
|
const res = await fetch(ENDPOINT, {
|
|
@@ -262,10 +264,14 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
262
264
|
} catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
|
|
263
265
|
};
|
|
264
266
|
const topicName = (id: string) => state.topics.find((t) => t.id === id)?.label ?? id;
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
+
const uid = useId();
|
|
268
|
+
const addRule = useRef<HTMLButtonElement>(null);
|
|
269
|
+
const rules = auto.eventChanges;
|
|
270
|
+
const setRule = (i: number, patch: Partial<ChangeRule>) => setAuto({ ...auto, eventChanges: rules.map((r, j) => (j === i ? { ...r, ...patch } : r)) });
|
|
271
|
+
// focus moves to "Add a rule" (enabled again once the tenth rule goes)
|
|
272
|
+
const removeRule = (i: number) => { setAuto({ ...auto, eventChanges: rules.filter((_, j) => j !== i) }); requestAnimationFrame(() => addRule.current?.focus()); };
|
|
273
|
+
const calendarChoices = (list: string[], set: (calendars: string[]) => void) => {
|
|
267
274
|
const all = list.includes('*');
|
|
268
|
-
const set = (calendars: string[]) => setAuto({ ...auto, [key]: { ...auto[key], calendars } });
|
|
269
275
|
return (
|
|
270
276
|
<div className="ma-choices">
|
|
271
277
|
<label className="ma-check"><input type="checkbox" checked={all} onChange={() => set(all ? [] : ['*'])} /><span>All public calendars</span></label>
|
|
@@ -296,11 +302,11 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
296
302
|
<>
|
|
297
303
|
<div className="ma-rule">
|
|
298
304
|
<div className="ma-rule-head"><b>New event published</b><small>Name, date and place, opens the event.</small></div>
|
|
299
|
-
{calendarChoices(
|
|
305
|
+
{calendarChoices(auto.newEvent.calendars, (calendars) => setAuto({ ...auto, newEvent: { calendars } }))}
|
|
300
306
|
</div>
|
|
301
307
|
<div className="ma-rule">
|
|
302
308
|
<div className="ma-rule-head"><b>Starting soon</b><small>A reminder before each event time, once per date.</small></div>
|
|
303
|
-
{calendarChoices(
|
|
309
|
+
{calendarChoices(auto.startingSoon.calendars, (calendars) => setAuto({ ...auto, startingSoon: { ...auto.startingSoon, calendars } }))}
|
|
304
310
|
<label className="field ma-lead">
|
|
305
311
|
<span>Send</span>
|
|
306
312
|
<select value={auto.startingSoon.minutes} onChange={(e) => setAuto({ ...auto, startingSoon: { ...auto.startingSoon, minutes: Number(e.target.value) } })}>
|
|
@@ -310,24 +316,39 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
310
316
|
</div>
|
|
311
317
|
<div className="ma-rule">
|
|
312
318
|
<div className="ma-rule-head"><b>When an event changes</b><small>New date, time or place, or a cancellation, opens the event. Sent 10 minutes after the last edit (1 minute when it starts within 3 hours), so a quick fix doesn’t notify twice.</small></div>
|
|
313
|
-
{
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
+
{rules.length ? (
|
|
320
|
+
<div className="ma-subrules">
|
|
321
|
+
{/* rules have no ids; inputs are controlled, so a position is enough */}
|
|
322
|
+
{rules.map((r, i) => (
|
|
323
|
+
<div key={i} className="ma-subrule" role="group" aria-labelledby={`${uid}-rule-${i}`}>
|
|
324
|
+
<div className="ma-subrule-head">
|
|
325
|
+
<b id={`${uid}-rule-${i}`}>Rule {i + 1}</b>
|
|
326
|
+
<button type="button" className="btn ghost sm" aria-label={`Remove rule ${i + 1}`} onClick={() => removeRule(i)}>Remove</button>
|
|
327
|
+
</div>
|
|
328
|
+
{calendarChoices(r.calendars, (calendars) => setRule(i, { calendars }))}
|
|
329
|
+
<div className="ma-fields">
|
|
330
|
+
<div className="field ma-lead">
|
|
331
|
+
<span>Only events tagged</span>
|
|
332
|
+
<TagChips tags={r.tags} suggestions={state.tags} onChange={(tags) => setRule(i, { tags })} />
|
|
333
|
+
</div>
|
|
334
|
+
<label className="field ma-lead">
|
|
335
|
+
<span>Send to</span>
|
|
336
|
+
<select value={r.topics.join(',')} onChange={(e) => setRule(i, { topics: e.target.value ? e.target.value.split(',') : [] })}>
|
|
337
|
+
<option value="">The event’s topics (Events, its calendar)</option>
|
|
338
|
+
{/* several topics can only come from the API: keep them choosable */}
|
|
339
|
+
{r.topics.length > 1 && <option value={r.topics.join(',')}>{r.topics.map(topicName).join(', ')}</option>}
|
|
340
|
+
{state.topics.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
|
|
341
|
+
</select>
|
|
342
|
+
</label>
|
|
343
|
+
</div>
|
|
344
|
+
{!r.calendars.length && <p className="hint">Choose a calendar, or this rule is removed when you save.</p>}
|
|
345
|
+
</div>
|
|
346
|
+
))}
|
|
319
347
|
</div>
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
<option value="">The event’s topics (Events, its calendar)</option>
|
|
325
|
-
{/* several topics can only come from the API: keep them choosable */}
|
|
326
|
-
{auto.eventChanges.topics.length > 1 && <option value={auto.eventChanges.topics.join(',')}>{auto.eventChanges.topics.map(topicName).join(', ')}</option>}
|
|
327
|
-
{state.topics.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
|
|
328
|
-
</select>
|
|
329
|
-
</label>
|
|
330
|
-
</div>
|
|
348
|
+
) : <p className="hint">Off. Add a rule to choose which events notify and who hears.</p>}
|
|
349
|
+
{rules.length > 1 && <p className="hint">An event that fits several rules gets one notice, sent to all of their topics.</p>}
|
|
350
|
+
<button type="button" ref={addRule} className="btn sm ma-add" disabled={rules.length >= MAX_RULES}
|
|
351
|
+
onClick={() => setAuto({ ...auto, eventChanges: [...rules, { calendars: [], tags: [], topics: [] }] })}>Add a rule</button>
|
|
331
352
|
</div>
|
|
332
353
|
</>
|
|
333
354
|
)}
|
|
@@ -20,7 +20,8 @@ export interface EventMeta {
|
|
|
20
20
|
cta: EventCta;
|
|
21
21
|
calendarIds: string[];
|
|
22
22
|
fields: Record<string, string>;
|
|
23
|
-
|
|
23
|
+
// closesAt: wall time like start; closeBefore: minutes before each date (0 = until it ends)
|
|
24
|
+
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; form?: FormDefinition };
|
|
24
25
|
roomHold: RoomHoldValue;
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -169,7 +169,7 @@ const NO_MAPS: MapsStatus = { enabled: false, provider: '', label: '', search: f
|
|
|
169
169
|
const NO_EVENT: EventMeta = {
|
|
170
170
|
start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
|
|
171
171
|
location: NO_LOCATION, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
|
|
172
|
-
rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4 },
|
|
172
|
+
rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0 },
|
|
173
173
|
roomHold: NO_HOLD,
|
|
174
174
|
};
|
|
175
175
|
|
|
@@ -4,7 +4,8 @@ import type { EventCta } from '../../../lib/schema';
|
|
|
4
4
|
import type { EventMeta } from '../EventPanel';
|
|
5
5
|
import { Section } from './Section';
|
|
6
6
|
import { RegistrationFormEditor } from './RegistrationFormEditor';
|
|
7
|
-
import { ctaHrefValid, normalizeCtaHref, registrationMode, registrationPatch, type RegistrationMode } from './registration';
|
|
7
|
+
import { closeBeforeAmount, closeBeforeMinutes, closeBeforeUnit, ctaHrefValid, deadlineAfterEnd, normalizeCtaHref, registrationMode, registrationPatch, type CloseUnit, type RegistrationMode } from './registration';
|
|
8
|
+
import { datePart, timePart } from './dates';
|
|
8
9
|
|
|
9
10
|
const MODES: { id: RegistrationMode; label: string; sub: string }[] = [
|
|
10
11
|
{ id: 'none', label: 'None', sub: 'Just the details' },
|
|
@@ -73,6 +74,7 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
73
74
|
<input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.autoConfirm} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, autoConfirm: e.target.checked } })} />
|
|
74
75
|
</label>
|
|
75
76
|
</div>
|
|
77
|
+
<Deadline meta={meta} onChange={onChange} />
|
|
76
78
|
<div className="pe-evr-form">
|
|
77
79
|
<div className="pe-evr-form-t">
|
|
78
80
|
<b>{form.title || 'Registration form'}</b>
|
|
@@ -90,6 +92,54 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
90
92
|
);
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
// When RSVPs close: a date and time for a one-time event, "[N] hours/days
|
|
96
|
+
// before each date" for a repeating one. A setting the other kind left behind
|
|
97
|
+
// (the event changed kind) is shown with a way to remove it, never hidden.
|
|
98
|
+
function Deadline({ meta, onChange }: { meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void }) {
|
|
99
|
+
const closesAt = meta.rsvp.closesAt ?? null;
|
|
100
|
+
const before = meta.rsvp.closeBefore ?? 0;
|
|
101
|
+
const [unit, setUnit] = useState<CloseUnit>(() => closeBeforeUnit(before));
|
|
102
|
+
const set = (patch: Partial<EventMeta['rsvp']>) => onChange({ rsvp: { ...meta.rsvp, ...patch } });
|
|
103
|
+
const amount = closeBeforeAmount(before, unit);
|
|
104
|
+
const ahead = `${amount} ${amount === 1 ? unit.slice(0, -1) : unit}`;
|
|
105
|
+
const at = (wall: string) => `${new Date(`${datePart(wall)}T12:00:00Z`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' })}, ${timePart(wall)}`;
|
|
106
|
+
const remove = (patch: Partial<EventMeta['rsvp']>) => <button type="button" className="pe-evw-link" onClick={() => set(patch)}>Remove</button>;
|
|
107
|
+
|
|
108
|
+
if (meta.rrule) return (
|
|
109
|
+
<div className="pe-field pe-evr-close" role="group" aria-labelledby="ev-rsvp-close">
|
|
110
|
+
<span id="ev-rsvp-close">Close RSVPs</span>
|
|
111
|
+
<div className="pe-evr-when">
|
|
112
|
+
<input type="number" min={0} max={unit === 'days' ? 60 : 1440} placeholder="0" aria-label={`${unit} before each date`} value={before ? amount : ''}
|
|
113
|
+
onChange={(e) => set({ closeBefore: closeBeforeMinutes(Number(e.target.value), unit) })} />
|
|
114
|
+
<select aria-label="Unit" value={unit} onChange={(e) => { const next = e.target.value as CloseUnit; setUnit(next); if (before) set({ closeBefore: closeBeforeMinutes(amount, next) }); }}>
|
|
115
|
+
<option value="hours">{amount === 1 ? 'hour' : 'hours'}</option>
|
|
116
|
+
<option value="days">{amount === 1 ? 'day' : 'days'}</option>
|
|
117
|
+
</select>
|
|
118
|
+
<span>before each date</span>
|
|
119
|
+
</div>
|
|
120
|
+
<small className="pe-evs-hint">{before ? `Each date stops taking RSVPs ${ahead} before it starts.` : 'Leave empty to take RSVPs until each date ends.'}</small>
|
|
121
|
+
{closesAt && <small className="pe-evs-hint">Every date also closes {at(closesAt)}. {remove({ closesAt: null })}</small>}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
const late = closesAt ? deadlineAfterEnd(closesAt, meta) : false;
|
|
125
|
+
return (
|
|
126
|
+
<div className="pe-field pe-evr-close" role="group" aria-labelledby="ev-rsvp-close">
|
|
127
|
+
<span id="ev-rsvp-close">RSVPs close</span>
|
|
128
|
+
<div className="pe-evr-when">
|
|
129
|
+
<input type="date" aria-label="RSVPs close on" max={datePart(meta.end || meta.start) || undefined} value={closesAt ? datePart(closesAt) : ''}
|
|
130
|
+
onChange={(e) => set({ closesAt: e.target.value ? `${e.target.value}T${(closesAt && timePart(closesAt)) || timePart(meta.start) || '12:00'}` : null })} />
|
|
131
|
+
<input type="time" aria-label="RSVPs close at" step={300} disabled={!closesAt} value={closesAt ? timePart(closesAt) : ''}
|
|
132
|
+
onChange={(e) => { if (closesAt && e.target.value) set({ closesAt: `${datePart(closesAt)}T${e.target.value}` }); }} />
|
|
133
|
+
{closesAt && remove({ closesAt: null })}
|
|
134
|
+
</div>
|
|
135
|
+
<small className={`pe-evs-hint ${late ? 'is-error' : ''}`}>
|
|
136
|
+
{!closesAt ? 'Leave empty to take RSVPs until the event ends.' : late ? 'That’s after the event ends, so RSVPs stay open until it ends.' : 'In the event’s time zone. Then the page says RSVPs are closed.'}
|
|
137
|
+
</small>
|
|
138
|
+
{!closesAt && before > 0 && <small className="pe-evs-hint">It closes {ahead} before it starts. {remove({ closeBefore: 0 })}</small>}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
93
143
|
// The link only reaches the event (and autosave) once it's valid, so a
|
|
94
144
|
// half-typed link never blocks saving the rest. Blur fills in https://,
|
|
95
145
|
// mailto: or tel: for bare domains, emails and phone numbers.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EventCta } from '../../../lib/schema';
|
|
2
|
+
import { toMin } from './dates';
|
|
2
3
|
|
|
3
4
|
// The Registration card is one choice over two stored fields: `cta` (a link
|
|
4
5
|
// button) and `rsvp` (the "Save my seat" form, which replaces the button on
|
|
@@ -8,7 +9,7 @@ export type RegistrationMode = 'none' | 'link' | 'rsvp';
|
|
|
8
9
|
|
|
9
10
|
interface RegistrationData {
|
|
10
11
|
cta: EventCta;
|
|
11
|
-
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number };
|
|
12
|
+
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number };
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
|
|
@@ -37,3 +38,19 @@ export function normalizeCtaHref(raw: string): string {
|
|
|
37
38
|
if (/^([\w-]+\.)+[a-z]{2,}(:\d+)?([/?#]\S*)?$/i.test(v)) return `https://${v}`;
|
|
38
39
|
return v;
|
|
39
40
|
}
|
|
41
|
+
|
|
42
|
+
// RSVP deadline. One-time events pick when RSVPs close (closesAt); repeating
|
|
43
|
+
// ones close each date "[N] [hours|days] before" it starts (closeBefore, in
|
|
44
|
+
// minutes, at most 60 days). The server checks both again on save.
|
|
45
|
+
export type CloseUnit = 'hours' | 'days';
|
|
46
|
+
export const closeBeforeUnit = (minutes: number): CloseUnit => (minutes > 0 && minutes % 1440 === 0 ? 'days' : 'hours');
|
|
47
|
+
export const closeBeforeAmount = (minutes: number, unit: CloseUnit) => minutes / (unit === 'days' ? 1440 : 60);
|
|
48
|
+
export const closeBeforeMinutes = (amount: number, unit: CloseUnit) =>
|
|
49
|
+
Math.min(60 * 1440, Math.max(0, Math.round((Number.isFinite(amount) ? amount : 0) * (unit === 'days' ? 1440 : 60))));
|
|
50
|
+
|
|
51
|
+
// a deadline at or after the end changes nothing: RSVPs close then anyway
|
|
52
|
+
export function deadlineAfterEnd(closesAt: string, when: { start: string; end: string; allDay: boolean }): boolean {
|
|
53
|
+
const end = when.end || when.start;
|
|
54
|
+
if (!closesAt || !end) return false;
|
|
55
|
+
return toMin(closesAt) >= (when.allDay ? toMin(end.slice(0, 10)) + 1440 : toMin(end));
|
|
56
|
+
}
|
|
@@ -97,7 +97,8 @@ ${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="disp
|
|
|
97
97
|
return { html, text };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
// link: the booking's own link again, after someone tried to make it twice
|
|
101
|
+
export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'staff-new';
|
|
101
102
|
|
|
102
103
|
export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
103
104
|
const w = bookingStrings(c.lang);
|
|
@@ -118,6 +119,7 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
118
119
|
case 'updated': return { ...guest('updated'), attachments: ics() };
|
|
119
120
|
case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
|
|
120
121
|
case 'reminder': return guest('reminder');
|
|
122
|
+
case 'link': return guest('link');
|
|
121
123
|
case 'staff-new': {
|
|
122
124
|
const pending = c.booking.status === 'pending';
|
|
123
125
|
const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
|
|
@@ -10,7 +10,7 @@ import { emitWebhook } from '../webhooks';
|
|
|
10
10
|
import { parseFrom } from '../email/config';
|
|
11
11
|
import { kickEmail } from '../email/service';
|
|
12
12
|
import { eventUrl } from '../calendar/config';
|
|
13
|
-
import { eventTimeZone, locationLine, occurrenceKey } from '../calendar/events';
|
|
13
|
+
import { eventTimeZone, locationLine, occurrenceKey, rsvpDeadline } from '../calendar/events';
|
|
14
14
|
import { normalizePageLocale } from '../i18n';
|
|
15
15
|
import { localizeDoc } from '../translations';
|
|
16
16
|
import { bookingStrings } from '../email/booking-words';
|
|
@@ -28,7 +28,8 @@ export interface FlowOptions {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export class BookingError extends Error {
|
|
31
|
-
|
|
31
|
+
// code: why, when the status alone doesn't say (apps get it as the error code)
|
|
32
|
+
constructor(public status: number, message: string, public code?: 'duplicate') { super(message); }
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
const token = () => {
|
|
@@ -113,10 +114,11 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
|
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
// snapshot.locale: the language to use instead of the booking's (a link sent again)
|
|
118
|
+
export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number; locale?: string }) {
|
|
117
119
|
const site = await repo.getSettings();
|
|
118
120
|
// the guest hears in the language they booked in; staff in the main one
|
|
119
|
-
const language = bookingLanguage(kind === 'staff-new' ? { locale: '' } : b, site);
|
|
121
|
+
const language = bookingLanguage(kind === 'staff-new' ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
|
|
120
122
|
const settings = language.settings;
|
|
121
123
|
const d = await describe(repo,b,settings,language.locale);
|
|
122
124
|
if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
|
|
@@ -125,7 +127,7 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
|
|
|
125
127
|
d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
|
|
126
128
|
}
|
|
127
129
|
return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
|
|
128
|
-
manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + bookingManagePath(b),
|
|
130
|
+
manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
|
|
129
131
|
siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
|
|
130
132
|
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang });
|
|
131
133
|
}
|
|
@@ -275,29 +277,54 @@ export async function createRsvp(
|
|
|
275
277
|
opts: FlowOptions
|
|
276
278
|
): Promise<Booking> {
|
|
277
279
|
const e = args.event;
|
|
280
|
+
const now = opts.now ?? Date.now();
|
|
278
281
|
if (!e.rsvp.enabled) throw new BookingError(400, 'This event doesn’t take RSVPs.');
|
|
279
282
|
if (e.cancelled) throw new BookingError(410, 'This event is cancelled.');
|
|
280
|
-
if (args.end <=
|
|
281
|
-
|
|
283
|
+
if (args.end <= now) throw new BookingError(410, 'This event has already happened.');
|
|
284
|
+
const settings = await repo.getSettings();
|
|
285
|
+
if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, args.start, settings))) {
|
|
282
286
|
throw new BookingError(410, 'This date is cancelled.');
|
|
283
287
|
}
|
|
288
|
+
const deadline = rsvpDeadline({ event: e, start: args.start, end: args.end }, settings);
|
|
289
|
+
if (deadline !== null && deadline <= now) throw new BookingError(410, 'RSVPs for this date are closed.');
|
|
284
290
|
const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
|
|
285
291
|
const booker = validateBooker(null, args.booker);
|
|
286
|
-
await checkEmailCap(repo, booker.email, args.source,
|
|
292
|
+
await checkEmailCap(repo, booker.email, args.source, settings, opts.now);
|
|
287
293
|
const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
|
|
288
294
|
if (formResponse) booker.answers = displayAnswers(formResponse);
|
|
295
|
+
// one RSVP per address and date on public channels; staff may add more
|
|
296
|
+
const once = args.source !== 'admin';
|
|
289
297
|
const b = await repo.createBooking({
|
|
290
298
|
kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
|
|
291
299
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
292
300
|
formResponse, notes: '', manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
293
|
-
}, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
|
|
294
|
-
if (!b)
|
|
301
|
+
}, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency });
|
|
302
|
+
if (!b) {
|
|
303
|
+
// a retry of a request that just committed: the caller replays that one
|
|
304
|
+
const retry = opts.idempotency ? await repo.getBookingRequest(opts.idempotency.keyHash) : null;
|
|
305
|
+
const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
|
|
306
|
+
if (mine) {
|
|
307
|
+
await resendManageLink(repo, mine, args.booker.locale === undefined ? mine.locale : normalizePageLocale(args.booker.locale), opts);
|
|
308
|
+
// anyone can type an address: say only that it has one
|
|
309
|
+
throw new BookingError(409, 'You already have an RSVP for this date. We sent you an email with a link to change it.', 'duplicate');
|
|
310
|
+
}
|
|
311
|
+
throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
|
|
312
|
+
}
|
|
295
313
|
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
|
|
296
314
|
await hook(repo, 'booking.created', b, opts);
|
|
297
315
|
if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
|
|
298
316
|
return b;
|
|
299
317
|
}
|
|
300
318
|
|
|
319
|
+
// The address gets its RSVP's own link again, in the language it just used,
|
|
320
|
+
// at most once per 10 minutes per RSVP so the form can't flood an inbox.
|
|
321
|
+
async function resendManageLink(repo: ContentRepository, b: Booking, locale: string, opts: FlowOptions) {
|
|
322
|
+
if (!await repo.email.rateLimit(`rsvp-link:${b.id}`, 1, 10 * 60_000)) return;
|
|
323
|
+
await repo.email.enqueue({dedupe:`${b.id}:${b.notificationVersion}:link:${Date.now()}`,kind:'booking',recipient:b.email,
|
|
324
|
+
payload:JSON.stringify({bookingId:b.id,version:b.notificationVersion,kind:'link',locale}),configVersion:(await repo.email.config())?.version||0,expiresAt:b.end});
|
|
325
|
+
await kickEmail(repo,opts.locals,opts.waitUntil);
|
|
326
|
+
}
|
|
327
|
+
|
|
301
328
|
const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
|
302
329
|
pending: ['confirmed', 'declined', 'cancelled'],
|
|
303
330
|
confirmed: ['cancelled'],
|