@acms-subir/core 0.3.37 → 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 +22 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +18 -11
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +18 -11
- package/mobile-template/recipes/rsvp/recipe.json +2 -1
- package/package.json +1 -1
- package/src/actions/index.ts +2 -1
- package/src/components/EventPage.astro +53 -30
- 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 +62 -45
- package/src/lib/booking/service.ts +57 -19
- package/src/lib/calendar/cards.ts +5 -3
- package/src/lib/calendar/event-words.ts +171 -0
- package/src/lib/calendar/events.ts +16 -2
- package/src/lib/calendar/render.ts +3 -1
- package/src/lib/calendar/rrule.ts +14 -18
- 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 +212 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/mobile/bookings.ts +10 -3
- package/src/lib/mobile/calendar.ts +6 -4
- package/src/lib/mobile/guest.ts +3 -2
- package/src/lib/mobile/http.ts +1 -1
- package/src/lib/mobile/manage.ts +7 -5
- package/src/lib/mobile/openapi.ts +1 -1
- package/src/lib/mobile/rsvp.ts +14 -8
- package/src/lib/mobile/schemas.ts +7 -2
- package/src/lib/mobile/search.ts +1 -1
- package/src/lib/mobile/site.ts +7 -3
- package/src/lib/push/api.ts +7 -8
- package/src/lib/push/service.ts +26 -11
- package/src/lib/push/types.ts +18 -8
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +7 -4
- package/src/lib/repo/types.ts +5 -3
- package/src/lib/schema.ts +8 -1
- package/src/pages/book/[resource]/[link].astro +8 -4
- package/src/pages/book/manage/[token].astro +22 -14
- package/src/pages/events/[slug].astro +12 -5
- 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 +4 -1
|
@@ -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
|
+
}
|
|
@@ -1,20 +1,43 @@
|
|
|
1
1
|
// Booking emails: plain, readable HTML (inline styles, no images) plus a
|
|
2
2
|
// text part. Confirmed bookings carry an .ics so one tap adds them to the
|
|
3
|
-
// booker's calendar.
|
|
3
|
+
// booker's calendar. Guest emails speak the language the booking was made
|
|
4
|
+
// in (Booking.locale, words in email/booking-words.ts) and link to its pages
|
|
5
|
+
// in it; staff emails speak the site's main language.
|
|
4
6
|
import type { Booking, BookingLink, Resource, Settings } from '../schema';
|
|
5
|
-
import { siteLocale } from '../dates';
|
|
7
|
+
import { siteLang, siteLocale } from '../dates';
|
|
6
8
|
import { escapeText, foldLine } from '../calendar/ics-write';
|
|
9
|
+
import { eventStrings } from '../calendar/event-words';
|
|
10
|
+
import { manageUrl } from '../calendar/config';
|
|
11
|
+
import { I18N, localePath } from '../i18n';
|
|
12
|
+
import { isExtraLocale } from '../translations';
|
|
13
|
+
import { fill } from '../live/words';
|
|
14
|
+
import { bookingStrings, mailCopy, type BookingStrings } from '../email/booking-words';
|
|
7
15
|
import type { Mail } from '../email';
|
|
8
16
|
|
|
9
17
|
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
10
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The language a booking speaks: its own (an extra site locale it was made
|
|
21
|
+
* in, while the site still has it), else the main language. `settings`
|
|
22
|
+
* carries it for dates, keeping the site's region when it's the same language.
|
|
23
|
+
*/
|
|
24
|
+
export function bookingLanguage(b: Pick<Booking, 'locale'>, settings: Settings): { locale: string; lang: string; settings: Settings } {
|
|
25
|
+
const locale = isExtraLocale(b.locale) ? b.locale : '';
|
|
26
|
+
const lang = locale || (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
|
|
27
|
+
return { locale, lang, settings: locale && locale.split('-')[0] !== siteLang(settings) ? { ...settings, locale } : settings };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** the booking's own page, in its language: /es/book/manage/<token> */
|
|
31
|
+
export const bookingManagePath = (b: Pick<Booking, 'locale' | 'manageToken'>) =>
|
|
32
|
+
localePath(manageUrl(b.manageToken), isExtraLocale(b.locale) ? b.locale : '');
|
|
33
|
+
|
|
11
34
|
export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings: Settings, allDayish = false): string {
|
|
12
35
|
const locale = siteLocale(settings);
|
|
13
36
|
const f = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: tz });
|
|
14
37
|
if (allDayish) {
|
|
15
38
|
const last = b.end - 1;
|
|
16
39
|
const d = f({ weekday: 'short', month: 'short', day: 'numeric' });
|
|
17
|
-
return d.format(b.start) === d.format(last) ? `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} ·
|
|
40
|
+
return d.format(b.start) === d.format(last) ? `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · ${eventStrings(locale).allDay.toLowerCase()}` : `${d.format(b.start)} – ${d.format(last)}`;
|
|
18
41
|
}
|
|
19
42
|
const zone = f({ hour: 'numeric', timeZoneName: 'short' }).formatToParts(b.start).find((p) => p.type === 'timeZoneName')?.value ?? '';
|
|
20
43
|
return `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · ${f({ hour: 'numeric', minute: '2-digit' }).format(b.start)} – ${f({ hour: 'numeric', minute: '2-digit' }).format(b.end)} ${zone}`.trim();
|
|
@@ -22,14 +45,15 @@ export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings
|
|
|
22
45
|
|
|
23
46
|
const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
24
47
|
|
|
25
|
-
|
|
48
|
+
// lang: the description's language ("Manage: <url>")
|
|
49
|
+
export function bookingIcs(b: Booking, title: string, location: string, url: string, host: string, method: 'REQUEST' | 'CANCEL' = 'REQUEST', options: { organizer?: string; allDay?: boolean; lang?: string } = {}): string {
|
|
26
50
|
const day = (ms: number) => new Intl.DateTimeFormat('en-CA', { timeZone: b.timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(ms).replaceAll('-', '');
|
|
27
51
|
const actualMethod = method === 'CANCEL' ? 'CANCEL' : options.organizer ? 'REQUEST' : 'PUBLISH';
|
|
28
52
|
const lines = [
|
|
29
53
|
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//acms-subir//Bookings//EN', `METHOD:${actualMethod}`,
|
|
30
54
|
'BEGIN:VEVENT', `UID:booking-${b.id}@acms.invalid`, `DTSTAMP:${utc(Date.now())}`, ...(options.allDay ? [`DTSTART;VALUE=DATE:${day(b.start)}`, `DTEND;VALUE=DATE:${day(b.end)}`] : [`DTSTART:${utc(b.start)}`, `DTEND:${utc(b.end)}`]),
|
|
31
55
|
...(options.organizer ? [`ORGANIZER:mailto:${options.organizer}`, `ATTENDEE;RSVP=FALSE:mailto:${b.email}`] : []),
|
|
32
|
-
`SUMMARY:${escapeText(title)}`, ...(location ? [`LOCATION:${escapeText(location)}`] : []), `DESCRIPTION:${escapeText(
|
|
56
|
+
`SUMMARY:${escapeText(title)}`, ...(location ? [`LOCATION:${escapeText(location)}`] : []), `DESCRIPTION:${escapeText(fill(bookingStrings(options.lang).icsManage, { url }))}`,
|
|
33
57
|
`URL:${url}`, `STATUS:${method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`, `SEQUENCE:${b.notificationVersion ?? 0}`,
|
|
34
58
|
'END:VEVENT', 'END:VCALENDAR',
|
|
35
59
|
];
|
|
@@ -49,67 +73,60 @@ interface Ctx {
|
|
|
49
73
|
message?: string;
|
|
50
74
|
organizer?: string;
|
|
51
75
|
allDay?: boolean;
|
|
76
|
+
/** the words' language: the booking's for guest emails, the main one for staff */
|
|
77
|
+
lang?: string;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
|
-
function layout(heading: string, intro: string, c: Ctx, button?: { href: string; label: string }): { html: string; text: string } {
|
|
55
|
-
const rows: [string, string][] = [[
|
|
56
|
-
if (c.booking.seats > 1) rows.push([
|
|
57
|
-
const
|
|
80
|
+
function layout(heading: string, intro: string, c: Ctx, w: BookingStrings, button?: { href: string; label: string }): { html: string; text: string } {
|
|
81
|
+
const rows: [string, string][] = [[w.what, c.title], [w.when, c.when], ...(c.location ? [[w.where, c.location] as [string, string]] : [])];
|
|
82
|
+
if (c.booking.seats > 1) rows.push([w.seats, String(c.booking.seats)]);
|
|
83
|
+
const reference = fill(w.reference, { ref: c.booking.id.slice(0, 8).toUpperCase() });
|
|
84
|
+
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">
|
|
58
85
|
<div style="max-width:520px;margin:0 auto;padding:28px 20px">
|
|
59
86
|
<p style="font-size:13px;color:#6b6e78;margin:0 0 18px">${esc(c.siteTitle)}</p>
|
|
60
87
|
<h1 style="font-size:22px;margin:0 0 8px">${esc(heading)}</h1>
|
|
61
88
|
<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>
|
|
62
89
|
<table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #e5e3de;border-radius:10px">${rows
|
|
63
|
-
.map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${k}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v)}</td></tr>`)
|
|
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>`)
|
|
64
91
|
.join('')}</table>
|
|
65
92
|
${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
|
|
66
93
|
${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>` : ''}
|
|
67
|
-
<p style="font-size:12px;color:#8e919a;margin:26px 0 0"
|
|
94
|
+
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(reference)}</p>
|
|
68
95
|
</div></body></html>`;
|
|
69
|
-
const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '',
|
|
96
|
+
const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', reference].join('\n');
|
|
70
97
|
return { html, text };
|
|
71
98
|
}
|
|
72
99
|
|
|
73
|
-
|
|
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';
|
|
74
102
|
|
|
75
103
|
export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
104
|
+
const w = bookingStrings(c.lang);
|
|
105
|
+
const rsvp = c.booking.kind === 'rsvp';
|
|
106
|
+
const manage = { href: c.manageUrl, label: rsvp ? w.rsvp.manage : w.manage };
|
|
107
|
+
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 }) }];
|
|
108
|
+
// null: no button (nothing left to change)
|
|
109
|
+
const guest = (k: Exclude<BookingEmail, 'staff-new'>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
|
|
110
|
+
const copy = mailCopy(w, k, rsvp);
|
|
111
|
+
const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when };
|
|
112
|
+
return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(copy.heading, fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
|
|
113
|
+
};
|
|
78
114
|
switch (kind) {
|
|
79
|
-
case 'received':
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
case '
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
case 'declined': {
|
|
88
|
-
const m = layout('We couldn’t confirm your request', 'Sorry — this time isn’t available. You’re welcome to pick another.', c);
|
|
89
|
-
return { to: c.booking.email, subject: `Not available: ${c.title}`, ...m };
|
|
90
|
-
}
|
|
91
|
-
case 'cancelled': {
|
|
92
|
-
const m = layout('Booking cancelled', 'This booking has been cancelled.', c);
|
|
93
|
-
return { to: c.booking.email, subject: `Cancelled: ${c.title}`, ...m, attachments: ics('CANCEL') };
|
|
94
|
-
}
|
|
95
|
-
case 'updated': {
|
|
96
|
-
const m = layout('Booking details updated', 'Please review the latest details. Use the attached calendar file to update your calendar.', c, manage);
|
|
97
|
-
return { to: c.booking.email, subject: `Updated: ${c.title}`, ...m, attachments: ics() };
|
|
98
|
-
}
|
|
99
|
-
case 'rescheduled': {
|
|
100
|
-
const m = layout('Booking moved', 'Your booking has a new time. Import the attached calendar file to update your calendar.', c, manage);
|
|
101
|
-
return { to: c.booking.email, subject: `New time: ${c.title} — ${c.when}`, ...m, attachments: ics() };
|
|
102
|
-
}
|
|
103
|
-
case 'reminder': {
|
|
104
|
-
const m = layout('Coming up soon', 'A quick reminder about your booking.', c, manage);
|
|
105
|
-
return { to: c.booking.email, subject: `Reminder: ${c.title} — ${c.when}`, ...m };
|
|
106
|
-
}
|
|
115
|
+
case 'received': return guest('received');
|
|
116
|
+
case 'confirmed': return { ...guest('confirmed', { message: c.message || c.link?.confirmMessage || '' }), attachments: ics() };
|
|
117
|
+
case 'declined': return guest('declined', {}, null);
|
|
118
|
+
case 'cancelled': return { ...guest('cancelled', {}, null), attachments: ics('CANCEL') };
|
|
119
|
+
case 'updated': return { ...guest('updated'), attachments: ics() };
|
|
120
|
+
case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
|
|
121
|
+
case 'reminder': return guest('reminder');
|
|
122
|
+
case 'link': return guest('link');
|
|
107
123
|
case 'staff-new': {
|
|
108
124
|
const pending = c.booking.status === 'pending';
|
|
109
125
|
const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
|
|
110
126
|
const answers = Object.entries(c.booking.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
111
|
-
const
|
|
112
|
-
|
|
127
|
+
const vars = { title: c.title, when: c.when };
|
|
128
|
+
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 });
|
|
129
|
+
return { to: [], subject: fill(pending ? w.staff.pendingSubject : w.staff.bookedSubject, vars), ...m, replyTo: c.booking.email || undefined };
|
|
113
130
|
}
|
|
114
131
|
}
|
|
115
132
|
}
|
|
@@ -9,9 +9,12 @@ import { loadFeed, expandVEvents } from '../calendar/ics';
|
|
|
9
9
|
import { emitWebhook } from '../webhooks';
|
|
10
10
|
import { parseFrom } from '../email/config';
|
|
11
11
|
import { kickEmail } from '../email/service';
|
|
12
|
-
import {
|
|
13
|
-
import { eventTimeZone, locationLine, occurrenceKey } from '../calendar/events';
|
|
14
|
-
import {
|
|
12
|
+
import { eventUrl } from '../calendar/config';
|
|
13
|
+
import { eventTimeZone, locationLine, occurrenceKey, rsvpDeadline } from '../calendar/events';
|
|
14
|
+
import { normalizePageLocale } from '../i18n';
|
|
15
|
+
import { localizeDoc } from '../translations';
|
|
16
|
+
import { bookingStrings } from '../email/booking-words';
|
|
17
|
+
import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type BookingEmail } from './emails';
|
|
15
18
|
import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
|
|
16
19
|
import { decideStatus, linkApproval } from './approval';
|
|
17
20
|
import { eventHolds } from './holds';
|
|
@@ -25,7 +28,8 @@ export interface FlowOptions {
|
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export class BookingError extends Error {
|
|
28
|
-
|
|
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); }
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
const token = () => {
|
|
@@ -76,9 +80,12 @@ export async function availabilityContext(
|
|
|
76
80
|
|
|
77
81
|
// ---- notifications ---------------------------------------------------------
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
// locale: an extra language to word it in (the event's translated title and
|
|
84
|
+
// place, dates in it); settings then carry that language
|
|
85
|
+
async function describe(repo: ContentRepository, b: Booking, settings: Settings, locale = '') {
|
|
80
86
|
if (b.kind === 'rsvp') {
|
|
81
|
-
const
|
|
87
|
+
const raw = b.eventId ? await repo.getEvent(b.eventId) : null;
|
|
88
|
+
const ev = raw && locale ? localizeDoc(raw, locale) : raw;
|
|
82
89
|
const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
|
|
83
90
|
return {
|
|
84
91
|
resource: null, link: null, event: ev,
|
|
@@ -107,18 +114,22 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
|
|
|
107
114
|
};
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
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 }) {
|
|
119
|
+
const site = await repo.getSettings();
|
|
120
|
+
// the guest hears in the language they booked in; staff in the main one
|
|
121
|
+
const language = bookingLanguage(kind === 'staff-new' ? { locale: '' } : typeof snapshot?.locale === 'string' ? { locale: snapshot.locale } : b, site);
|
|
122
|
+
const settings = language.settings;
|
|
123
|
+
const d = await describe(repo,b,settings,language.locale);
|
|
113
124
|
if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
|
|
114
125
|
d.title = snapshot.eventTitle;
|
|
115
126
|
try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
|
|
116
127
|
d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
|
|
117
128
|
}
|
|
118
129
|
return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
|
|
119
|
-
manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin +
|
|
130
|
+
manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
|
|
120
131
|
siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
|
|
121
|
-
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay) });
|
|
132
|
+
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang });
|
|
122
133
|
}
|
|
123
134
|
async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
|
|
124
135
|
// Repository mutations already committed their outbox jobs atomically.
|
|
@@ -142,6 +153,8 @@ export interface BookerInput {
|
|
|
142
153
|
phone?: string;
|
|
143
154
|
answers?: Record<string, string>;
|
|
144
155
|
formVersion?: string;
|
|
156
|
+
/** the language they booked in (the page's or the app's); left out = the main language */
|
|
157
|
+
locale?: string;
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'> | null, input: BookerInput): { name: string; email: string; phone: string; answers: Record<string, string> } {
|
|
@@ -236,7 +249,7 @@ export async function createResourceBooking(
|
|
|
236
249
|
kind: 'booking', resourceId: resource.id, linkId: link.id, eventId: '', status,
|
|
237
250
|
start: args.start, end: args.end, ...holdOf(link, args.start, args.end),
|
|
238
251
|
timezone: resourceZone(resource, settings.timezone), ...booker, seats: 1, notes: '',
|
|
239
|
-
manageToken: token(), source: args.source ?? 'web',
|
|
252
|
+
manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
240
253
|
}, { idempotency: opts.idempotency });
|
|
241
254
|
if (!b) throw new BookingError(409, 'That time was just taken. Please pick another.');
|
|
242
255
|
await notify(repo, b, [status === 'confirmed' ? 'confirmed' : 'received', 'staff-new'], opts);
|
|
@@ -264,29 +277,54 @@ export async function createRsvp(
|
|
|
264
277
|
opts: FlowOptions
|
|
265
278
|
): Promise<Booking> {
|
|
266
279
|
const e = args.event;
|
|
280
|
+
const now = opts.now ?? Date.now();
|
|
267
281
|
if (!e.rsvp.enabled) throw new BookingError(400, 'This event doesn’t take RSVPs.');
|
|
268
282
|
if (e.cancelled) throw new BookingError(410, 'This event is cancelled.');
|
|
269
|
-
if (args.end <=
|
|
270
|
-
|
|
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))) {
|
|
271
286
|
throw new BookingError(410, 'This date is cancelled.');
|
|
272
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.');
|
|
273
290
|
const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
|
|
274
291
|
const booker = validateBooker(null, args.booker);
|
|
275
|
-
await checkEmailCap(repo, booker.email, args.source,
|
|
292
|
+
await checkEmailCap(repo, booker.email, args.source, settings, opts.now);
|
|
276
293
|
const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
|
|
277
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';
|
|
278
297
|
const b = await repo.createBooking({
|
|
279
298
|
kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
|
|
280
299
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
281
|
-
formResponse, notes: '', manageToken: token(), source: args.source ?? 'web',
|
|
282
|
-
}, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
|
|
283
|
-
if (!b)
|
|
300
|
+
formResponse, notes: '', manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
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
|
+
}
|
|
284
313
|
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
|
|
285
314
|
await hook(repo, 'booking.created', b, opts);
|
|
286
315
|
if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
|
|
287
316
|
return b;
|
|
288
317
|
}
|
|
289
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
|
+
|
|
290
328
|
const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
|
291
329
|
pending: ['confirmed', 'declined', 'cancelled'],
|
|
292
330
|
confirmed: ['cancelled'],
|
|
@@ -377,7 +415,7 @@ export async function decideOverdue(repo: ContentRepository, opts: FlowOptions):
|
|
|
377
415
|
if (!rules.deadlineHours || b.createdAt + rules.deadlineHours * 3_600_000 > now) continue;
|
|
378
416
|
try {
|
|
379
417
|
await setBookingStatus(repo, b, rules.deadlineAction === 'decline' ? 'declined' : 'confirmed', opts,
|
|
380
|
-
rules.deadlineAction === 'decline' ?
|
|
418
|
+
rules.deadlineAction === 'decline' ? bookingStrings(bookingLanguage(b, settings).lang).lateDecline : undefined);
|
|
381
419
|
decided++;
|
|
382
420
|
} catch { /* changed meanwhile — next sweep sees the new state */ }
|
|
383
421
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// .pc-ev-card* classes are the styling API, like .pc-cal-* for the grid.
|
|
4
4
|
import type { ContentRepository } from '../repo/types';
|
|
5
5
|
import { tagFilter, type CalEvent, type Settings } from '../schema';
|
|
6
|
-
import { siteLocale } from '../dates';
|
|
6
|
+
import { siteLang, siteLocale } from '../dates';
|
|
7
|
+
import { eventStrings } from './event-words';
|
|
7
8
|
import { eventImage } from './images';
|
|
8
9
|
import { eventUrl } from './config';
|
|
9
10
|
import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
|
|
@@ -37,7 +38,8 @@ export function eventCardHtml(
|
|
|
37
38
|
const f = formatOccurrence(o, tz, siteLocale(settings));
|
|
38
39
|
const href = opts.href ?? eventUrl(e.slug);
|
|
39
40
|
const h = opts.heading ?? 'h3';
|
|
40
|
-
// "Sat, Oct 3 · 11:00 AM", "Sat, Oct 3 · All day", "Sat, Oct 24 – Sun, Oct 25"
|
|
41
|
+
// "Sat, Oct 3 · 11:00 AM", "Sat, Oct 3 · All day", "Sat, Oct 24 – Sun, Oct 25";
|
|
42
|
+
// the Cancelled flag speaks the dates' language
|
|
41
43
|
const when = f.range;
|
|
42
44
|
const image = eventImage(e, settings);
|
|
43
45
|
const media = opts.showImage
|
|
@@ -53,7 +55,7 @@ export function eventCardHtml(
|
|
|
53
55
|
: '';
|
|
54
56
|
return (
|
|
55
57
|
`<article class="pc-ev-card${cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
|
|
56
|
-
`<div class="pc-ev-card-when">${cancelled ?
|
|
58
|
+
`<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : ''}${esc(when)}</div>` +
|
|
57
59
|
`<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || 'Untitled event')}</a></${h}>` +
|
|
58
60
|
(e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
|
|
59
61
|
(loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
|