@acms-subir/core 0.3.38 → 0.3.40
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 +18 -0
- package/docs/mobile.md +1 -1
- package/integration.mjs +1 -0
- 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 +3 -1
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +55 -1
- package/src/components/editor/event/registration.ts +18 -1
- package/src/lib/booking/emails.ts +37 -11
- package/src/lib/booking/service.ts +96 -19
- package/src/lib/calendar/api.ts +3 -2
- package/src/lib/calendar/events.ts +11 -0
- package/src/lib/calendar/members-config.ts +5 -0
- package/src/lib/calendar/requests.ts +4 -4
- package/src/lib/calendar/rsvp-words.ts +31 -2
- package/src/lib/content-service.ts +29 -14
- package/src/lib/drafts.ts +2 -0
- package/src/lib/editor/documents.ts +4 -1
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +66 -1
- package/src/lib/email/service.ts +1 -1
- package/src/lib/email/store.ts +17 -1
- package/src/lib/email/types.ts +3 -1
- package/src/lib/legal/text-en.ts +1 -1
- package/src/lib/legal/text-es.ts +1 -1
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/calendar.ts +23 -1
- package/src/lib/mobile/http.ts +1 -1
- package/src/lib/mobile/manage.ts +1 -1
- package/src/lib/mobile/openapi.ts +3 -2
- package/src/lib/mobile/rsvp.ts +14 -8
- package/src/lib/mobile/schemas.ts +7 -3
- 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 +7 -3
- package/src/lib/repo/types.ts +8 -4
- package/src/lib/schema.ts +8 -1
- package/src/pages/api/v1/events/[slug]/index.ts +8 -0
- 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,23 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.40
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b71f8c3: New `GET /api/v1/events/:slug` (or the event's id) returns one public event and its next date, or the date on `?on=YYYY-MM-DD` as the website's links use, however far ahead it is. Apps opening an event from a link, a push or an announcement no longer get "Not found" for events more than 30 days out. Drafts and events in private calendars answer 404.
|
|
8
|
+
- 7fc0c3f: Each new RSVP, and each RSVP the person cancels with its link or in the app, now emails the event's hosts. Events without hosts email the contact address in Settings → Legal, or the owners when there isn't one. The email is in the site's main language: "New RSVP: Youth retreat, Fri Nov 7", then the person's name, email and phone, the date, the seats and status ("2 · Confirmed", or "2 · Waiting for your approval" when RSVPs need approving), their answers, how many seats are taken now ("30 of 40 seats taken"), a button to the RSVP in Bookings (or, for a host who can't manage bookings, such as a member who hosts a class, "Open in My events", their page for the event), and whether it came from the website, the app or staff. A cancellation says "RSVP cancelled: …" and how many seats it freed. Replying writes to the person. Staff cancelling, approving or declining an RSVP, a cancelled date or event, a second RSVP from the same email and app retries send no staff email. Each event has **Email the hosts** in its CTA card, on for every event including existing ones; **Staff notifications** in Email settings still turns off all staff emails. Editor documents carry the setting as `rsvp.notifyStaff`; the public event JSON and the app API leave it out. The privacy notice now says the event's hosts (or staff) get an email with RSVP details, so its "Last updated" date moves.
|
|
9
|
+
|
|
10
|
+
## 0.3.39
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
3
21
|
## 0.3.38
|
|
4
22
|
|
|
5
23
|
### 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` |
|
package/integration.mjs
CHANGED
|
@@ -835,6 +835,7 @@ export default function acms(options = {}) {
|
|
|
835
835
|
['/api/v1/events', 'api/v1/events.ts'],
|
|
836
836
|
['/api/v1/calendars', 'api/v1/calendars.ts'],
|
|
837
837
|
// RSVPs from apps: a date's status, then POST /bookings (room bookings there need bookings on)
|
|
838
|
+
['/api/v1/events/[slug]', 'api/v1/events/[slug]/index.ts'],
|
|
838
839
|
['/api/v1/events/[slug]/rsvp', 'api/v1/events/[slug]/rsvp.ts'],
|
|
839
840
|
['/api/v1/bookings', 'api/v1/bookings.ts'],
|
|
840
841
|
['/api/calendar/events', 'api/calendar/events/index.ts'],
|
|
@@ -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(), notifyStaff: z.boolean().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,9 @@ 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
|
+
// notifyStaff: the hosts get an email about each new or cancelled RSVP
|
|
25
|
+
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; form?: FormDefinition };
|
|
24
26
|
roomHold: RoomHoldValue;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -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, notifyStaff: true },
|
|
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' },
|
|
@@ -72,7 +73,12 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
72
73
|
<span><b>Confirm automatically</b><small>Off: RSVPs wait for your approval in Bookings</small></span>
|
|
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>
|
|
76
|
+
<label className="pe-switch-row">
|
|
77
|
+
<span><b>Email the hosts</b><small>About each new RSVP and cancellation. Without hosts, the contact email in Settings → Legal gets them</small></span>
|
|
78
|
+
<input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.notifyStaff !== false} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, notifyStaff: e.target.checked } })} />
|
|
79
|
+
</label>
|
|
75
80
|
</div>
|
|
81
|
+
<Deadline meta={meta} onChange={onChange} />
|
|
76
82
|
<div className="pe-evr-form">
|
|
77
83
|
<div className="pe-evr-form-t">
|
|
78
84
|
<b>{form.title || 'Registration form'}</b>
|
|
@@ -90,6 +96,54 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
90
96
|
);
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
// When RSVPs close: a date and time for a one-time event, "[N] hours/days
|
|
100
|
+
// before each date" for a repeating one. A setting the other kind left behind
|
|
101
|
+
// (the event changed kind) is shown with a way to remove it, never hidden.
|
|
102
|
+
function Deadline({ meta, onChange }: { meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void }) {
|
|
103
|
+
const closesAt = meta.rsvp.closesAt ?? null;
|
|
104
|
+
const before = meta.rsvp.closeBefore ?? 0;
|
|
105
|
+
const [unit, setUnit] = useState<CloseUnit>(() => closeBeforeUnit(before));
|
|
106
|
+
const set = (patch: Partial<EventMeta['rsvp']>) => onChange({ rsvp: { ...meta.rsvp, ...patch } });
|
|
107
|
+
const amount = closeBeforeAmount(before, unit);
|
|
108
|
+
const ahead = `${amount} ${amount === 1 ? unit.slice(0, -1) : unit}`;
|
|
109
|
+
const at = (wall: string) => `${new Date(`${datePart(wall)}T12:00:00Z`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' })}, ${timePart(wall)}`;
|
|
110
|
+
const remove = (patch: Partial<EventMeta['rsvp']>) => <button type="button" className="pe-evw-link" onClick={() => set(patch)}>Remove</button>;
|
|
111
|
+
|
|
112
|
+
if (meta.rrule) return (
|
|
113
|
+
<div className="pe-field pe-evr-close" role="group" aria-labelledby="ev-rsvp-close">
|
|
114
|
+
<span id="ev-rsvp-close">Close RSVPs</span>
|
|
115
|
+
<div className="pe-evr-when">
|
|
116
|
+
<input type="number" min={0} max={unit === 'days' ? 60 : 1440} placeholder="0" aria-label={`${unit} before each date`} value={before ? amount : ''}
|
|
117
|
+
onChange={(e) => set({ closeBefore: closeBeforeMinutes(Number(e.target.value), unit) })} />
|
|
118
|
+
<select aria-label="Unit" value={unit} onChange={(e) => { const next = e.target.value as CloseUnit; setUnit(next); if (before) set({ closeBefore: closeBeforeMinutes(amount, next) }); }}>
|
|
119
|
+
<option value="hours">{amount === 1 ? 'hour' : 'hours'}</option>
|
|
120
|
+
<option value="days">{amount === 1 ? 'day' : 'days'}</option>
|
|
121
|
+
</select>
|
|
122
|
+
<span>before each date</span>
|
|
123
|
+
</div>
|
|
124
|
+
<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>
|
|
125
|
+
{closesAt && <small className="pe-evs-hint">Every date also closes {at(closesAt)}. {remove({ closesAt: null })}</small>}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
const late = closesAt ? deadlineAfterEnd(closesAt, meta) : false;
|
|
129
|
+
return (
|
|
130
|
+
<div className="pe-field pe-evr-close" role="group" aria-labelledby="ev-rsvp-close">
|
|
131
|
+
<span id="ev-rsvp-close">RSVPs close</span>
|
|
132
|
+
<div className="pe-evr-when">
|
|
133
|
+
<input type="date" aria-label="RSVPs close on" max={datePart(meta.end || meta.start) || undefined} value={closesAt ? datePart(closesAt) : ''}
|
|
134
|
+
onChange={(e) => set({ closesAt: e.target.value ? `${e.target.value}T${(closesAt && timePart(closesAt)) || timePart(meta.start) || '12:00'}` : null })} />
|
|
135
|
+
<input type="time" aria-label="RSVPs close at" step={300} disabled={!closesAt} value={closesAt ? timePart(closesAt) : ''}
|
|
136
|
+
onChange={(e) => { if (closesAt && e.target.value) set({ closesAt: `${datePart(closesAt)}T${e.target.value}` }); }} />
|
|
137
|
+
{closesAt && remove({ closesAt: null })}
|
|
138
|
+
</div>
|
|
139
|
+
<small className={`pe-evs-hint ${late ? 'is-error' : ''}`}>
|
|
140
|
+
{!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.'}
|
|
141
|
+
</small>
|
|
142
|
+
{!closesAt && before > 0 && <small className="pe-evs-hint">It closes {ahead} before it starts. {remove({ closeBefore: 0 })}</small>}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
93
147
|
// The link only reaches the event (and autosave) once it's valid, so a
|
|
94
148
|
// half-typed link never blocks saving the rest. Blur fills in https://,
|
|
95
149
|
// 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; notifyStaff?: boolean };
|
|
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
|
+
}
|
|
@@ -13,6 +13,7 @@ import { isExtraLocale } from '../translations';
|
|
|
13
13
|
import { fill } from '../live/words';
|
|
14
14
|
import { bookingStrings, mailCopy, type BookingStrings } from '../email/booking-words';
|
|
15
15
|
import type { Mail } from '../email';
|
|
16
|
+
import type { RsvpStaffNotice } from '../email/types';
|
|
16
17
|
|
|
17
18
|
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
18
19
|
|
|
@@ -75,29 +76,36 @@ interface Ctx {
|
|
|
75
76
|
allDay?: boolean;
|
|
76
77
|
/** the words' language: the booking's for guest emails, the main one for staff */
|
|
77
78
|
lang?: string;
|
|
79
|
+
/** staff RSVP emails: the date's short form, seats taken right after, why they get it and where the person did it */
|
|
80
|
+
rsvpStaff?: { date: string; taken: number; capacity: number; why: RsvpStaffNotice['why']; via: Booking['source'] };
|
|
81
|
+
/** manageUrl is the recipient's My events page (a host without Manage bookings) */
|
|
82
|
+
mineLink?: boolean;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
// own: a staff email's own rows and footer instead of the details and reference
|
|
86
|
+
function layout(heading: string, intro: string, c: Ctx, w: BookingStrings, button?: { href: string; label: string }, own?: { rows: [string, string][]; footer: string }): { html: string; text: string } {
|
|
87
|
+
const rows: [string, string][] = own?.rows ?? [[w.what, c.title], [w.when, c.when], ...(c.location ? [[w.where, c.location] as [string, string]] : [])];
|
|
88
|
+
if (!own && c.booking.seats > 1) rows.push([w.seats, String(c.booking.seats)]);
|
|
89
|
+
const footer = own?.footer ?? fill(w.reference, { ref: c.booking.id.slice(0, 8).toUpperCase() });
|
|
84
90
|
const html = `<!doctype html><html lang="${esc(c.lang || 'en')}"><body style="margin:0;background:#f6f5f2;font-family:-apple-system,Segoe UI,Inter,Arial,sans-serif;color:#1e1f24">
|
|
85
91
|
<div style="max-width:520px;margin:0 auto;padding:28px 20px">
|
|
86
92
|
<p style="font-size:13px;color:#6b6e78;margin:0 0 18px">${esc(c.siteTitle)}</p>
|
|
87
|
-
<h1 style="font-size:22px;margin:0 0
|
|
88
|
-
|
|
93
|
+
<h1 style="font-size:22px;margin:0 0 ${intro ? 8 : 18}px">${esc(heading)}</h1>
|
|
94
|
+
${intro ? `<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>` : ''}
|
|
89
95
|
<table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #e5e3de;border-radius:10px">${rows
|
|
90
|
-
.map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${esc(k)}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v)}</td></tr>`)
|
|
96
|
+
.map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${esc(k)}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v).replace(/\n/g, '<br>')}</td></tr>`)
|
|
91
97
|
.join('')}</table>
|
|
92
98
|
${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
|
|
93
99
|
${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="display:inline-block;background:#1e1f24;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:10px 16px;border-radius:8px">${esc(button.label)}</a></p>` : ''}
|
|
94
|
-
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(
|
|
100
|
+
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(footer)}</p>
|
|
95
101
|
</div></body></html>`;
|
|
96
|
-
const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '',
|
|
102
|
+
const text = [heading, '', ...(intro ? [intro, ''] : []), ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', footer].join('\n');
|
|
97
103
|
return { html, text };
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
|
|
106
|
+
// link: the booking's own link again, after someone tried to make it twice.
|
|
107
|
+
// staff-rsvp(-cancelled): the hosts hear about a new RSVP, or one the person cancelled
|
|
108
|
+
export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'staff-new' | 'staff-rsvp' | 'staff-rsvp-cancelled';
|
|
101
109
|
|
|
102
110
|
export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
103
111
|
const w = bookingStrings(c.lang);
|
|
@@ -105,7 +113,7 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
105
113
|
const manage = { href: c.manageUrl, label: rsvp ? w.rsvp.manage : w.manage };
|
|
106
114
|
const ics = (method: 'REQUEST' | 'CANCEL' = 'REQUEST') => [{ filename: 'booking.ics', type: `text/calendar; charset=utf-8; method=${method === 'CANCEL' ? 'CANCEL' : c.organizer ? 'REQUEST' : 'PUBLISH'}`, content: bookingIcs(c.booking, c.title, c.location, c.manageUrl, c.host, method, { organizer: c.organizer, allDay: c.allDay, lang: c.lang }) }];
|
|
107
115
|
// null: no button (nothing left to change)
|
|
108
|
-
const guest = (k: Exclude<BookingEmail,
|
|
116
|
+
const guest = (k: Exclude<BookingEmail, `staff-${string}`>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
|
|
109
117
|
const copy = mailCopy(w, k, rsvp);
|
|
110
118
|
const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when };
|
|
111
119
|
return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(copy.heading, fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
|
|
@@ -118,6 +126,7 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
118
126
|
case 'updated': return { ...guest('updated'), attachments: ics() };
|
|
119
127
|
case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
|
|
120
128
|
case 'reminder': return guest('reminder');
|
|
129
|
+
case 'link': return guest('link');
|
|
121
130
|
case 'staff-new': {
|
|
122
131
|
const pending = c.booking.status === 'pending';
|
|
123
132
|
const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
|
|
@@ -126,5 +135,22 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
126
135
|
const m = layout(pending ? w.staff.pending : w.staff.booked, `${who}${answers ? `\n${answers}` : ''}`, c, w, { href: c.manageUrl, label: pending ? w.staff.review : w.staff.open });
|
|
127
136
|
return { to: [], subject: fill(pending ? w.staff.pendingSubject : w.staff.bookedSubject, vars), ...m, replyTo: c.booking.email || undefined };
|
|
128
137
|
}
|
|
138
|
+
case 'staff-rsvp':
|
|
139
|
+
case 'staff-rsvp-cancelled': {
|
|
140
|
+
const r = w.rsvpStaff, s = c.rsvpStaff!, b = c.booking;
|
|
141
|
+
const cancelled = kind === 'staff-rsvp-cancelled', pending = !cancelled && b.status === 'pending';
|
|
142
|
+
const seats = String(b.seats), count = String(s.taken);
|
|
143
|
+
const answers = Object.entries(b.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
144
|
+
const rows: [string, string][] = [
|
|
145
|
+
[r.name, b.name], [r.email, b.email], ...(b.phone ? [[r.phone, b.phone] as [string, string]] : []), [w.when, c.when],
|
|
146
|
+
[w.seats, fill(cancelled ? (b.seats === 1 ? r.seatFreed : r.seatsFreed) : pending ? r.pending : r.confirmed, { seats })],
|
|
147
|
+
...(answers ? [[r.answers, answers] as [string, string]] : []),
|
|
148
|
+
[r.now, s.capacity ? fill(r.seatsTakenOf, { count, capacity: String(s.capacity) }) : fill(s.taken === 1 ? r.seatTaken : r.seatsTaken, { count })],
|
|
149
|
+
];
|
|
150
|
+
const vars = { title: c.title, date: s.date };
|
|
151
|
+
const footer = `${r.via[s.via === 'web' || s.via === 'app' ? s.via : 'staff']} · ${s.why === 'hosts' ? r.hosts : r.noHosts}`;
|
|
152
|
+
const m = layout(fill(cancelled ? r.cancelledHeading : r.heading, vars), '', c, w, { href: c.manageUrl, label: c.mineLink ? r.openMine : pending ? w.staff.review : w.staff.open }, { rows, footer });
|
|
153
|
+
return { to: [], subject: fill(cancelled ? r.cancelledSubject : r.subject, vars), ...m, replyTo: b.email || undefined };
|
|
154
|
+
}
|
|
129
155
|
}
|
|
130
156
|
}
|