@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/mobile.md +1 -1
  4. package/docs/theme-authoring.md +1 -1
  5. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +18 -11
  6. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +18 -11
  7. package/mobile-template/recipes/rsvp/recipe.json +2 -1
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +2 -1
  10. package/src/components/EventPage.astro +53 -30
  11. package/src/components/MobileApp.tsx +44 -23
  12. package/src/components/editor/EventPanel.tsx +2 -1
  13. package/src/components/editor/PostEditor.tsx +1 -1
  14. package/src/components/editor/event/RegistrationCard.tsx +51 -1
  15. package/src/components/editor/event/registration.ts +18 -1
  16. package/src/lib/booking/emails.ts +62 -45
  17. package/src/lib/booking/service.ts +57 -19
  18. package/src/lib/calendar/cards.ts +5 -3
  19. package/src/lib/calendar/event-words.ts +171 -0
  20. package/src/lib/calendar/events.ts +16 -2
  21. package/src/lib/calendar/render.ts +3 -1
  22. package/src/lib/calendar/rrule.ts +14 -18
  23. package/src/lib/calendar/rsvp-words.ts +31 -2
  24. package/src/lib/content-service.ts +26 -14
  25. package/src/lib/drafts.ts +2 -0
  26. package/src/lib/editor/documents.ts +3 -1
  27. package/src/lib/editor/schemas.ts +1 -1
  28. package/src/lib/email/booking-words.ts +212 -0
  29. package/src/lib/forms/events.ts +4 -2
  30. package/src/lib/mobile/bookings.ts +10 -3
  31. package/src/lib/mobile/calendar.ts +6 -4
  32. package/src/lib/mobile/guest.ts +3 -2
  33. package/src/lib/mobile/http.ts +1 -1
  34. package/src/lib/mobile/manage.ts +7 -5
  35. package/src/lib/mobile/openapi.ts +1 -1
  36. package/src/lib/mobile/rsvp.ts +14 -8
  37. package/src/lib/mobile/schemas.ts +7 -2
  38. package/src/lib/mobile/search.ts +1 -1
  39. package/src/lib/mobile/site.ts +7 -3
  40. package/src/lib/push/api.ts +7 -8
  41. package/src/lib/push/service.ts +26 -11
  42. package/src/lib/push/types.ts +18 -8
  43. package/src/lib/repo/migrations.ts +5 -0
  44. package/src/lib/repo/sql.ts +7 -4
  45. package/src/lib/repo/types.ts +5 -3
  46. package/src/lib/schema.ts +8 -1
  47. package/src/pages/book/[resource]/[link].astro +8 -4
  48. package/src/pages/book/manage/[token].astro +22 -14
  49. package/src/pages/events/[slug].astro +12 -5
  50. package/src/styles/editor.css +4 -0
  51. package/src/styles/mobile-app.css +7 -0
  52. package/src/styles/site.css +3 -0
  53. 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 calendarChoices = (key: 'newEvent' | 'startingSoon' | 'eventChanges') => {
266
- const list = auto[key].calendars;
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('newEvent')}
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('startingSoon')}
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
- {calendarChoices('eventChanges')}
314
- <div className="ma-fields">
315
- <div className="field ma-lead">
316
- <span>Only events tagged</span>
317
- <TagChips tags={auto.eventChanges.tags} suggestions={state.tags}
318
- onChange={(tags) => setAuto({ ...auto, eventChanges: { ...auto.eventChanges, tags } })} />
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
- <label className="field ma-lead">
321
- <span>Send to</span>
322
- <select value={auto.eventChanges.topics.join(',')}
323
- onChange={(e) => setAuto({ ...auto, eventChanges: { ...auto.eventChanges, topics: e.target.value ? e.target.value.split(',') : [] } })}>
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
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; form?: FormDefinition };
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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)} · all day` : `${d.format(b.start)} – ${d.format(last)}`;
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
- export function bookingIcs(b: Booking, title: string, location: string, url: string, host: string, method: 'REQUEST' | 'CANCEL' = 'REQUEST', options: { organizer?: string; allDay?: boolean } = {}): string {
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(`Manage: ${url}`)}`,
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][] = [['What', c.title], ['When', c.when], ...(c.location ? [['Where', c.location] as [string, string]] : [])];
56
- if (c.booking.seats > 1) rows.push(['Seats', String(c.booking.seats)]);
57
- const html = `<!doctype html><html><body style="margin:0;background:#f6f5f2;font-family:-apple-system,Segoe UI,Inter,Arial,sans-serif;color:#1e1f24">
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">Reference ${esc(c.booking.id.slice(0, 8).toUpperCase())}</p>
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}`] : []), '', `Reference ${c.booking.id.slice(0, 8).toUpperCase()}`].join('\n');
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
- export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'staff-new';
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 manage = { href: c.manageUrl, label: 'View or change your booking' };
77
- 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 }) }];
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
- const m = layout('Request received', `Thanks, ${c.booking.name || 'there'}. We'll confirm by email soon.`, c, manage);
81
- return { to: c.booking.email, subject: `Request received: ${c.title}`, ...m };
82
- }
83
- case 'confirmed': {
84
- const m = layout('You’re booked', `It’s confirmed. Import the attached file to add it to your calendar.`, { ...c, message: c.message || c.link?.confirmMessage || '' }, manage);
85
- return { to: c.booking.email, subject: `Confirmed: ${c.title} — ${c.when}`, ...m, attachments: ics() };
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 m = layout(pending ? 'New request to approve' : 'New booking', `${who}${answers ? `\n${answers}` : ''}`, c, { href: c.manageUrl, label: pending ? 'Review in the admin' : 'Open in the admin' });
112
- return { to: [], subject: `${pending ? 'Approve' : 'New booking'}: ${c.title} — ${c.when}`, ...m, replyTo: c.booking.email || undefined };
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 { manageUrl as manageUrlFor, eventUrl } from '../calendar/config';
13
- import { eventTimeZone, locationLine, occurrenceKey } from '../calendar/events';
14
- import { bookingEmail, whenText, type BookingEmail } from './emails';
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
- constructor(public status: number, message: string) { super(message); }
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
- async function describe(repo: ContentRepository, b: Booking, settings: Settings) {
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 ev = b.eventId ? await repo.getEvent(b.eventId) : null;
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
- export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number }) {
111
- const settings = await repo.getSettings();
112
- const d = await describe(repo,b,settings);
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 + manageUrlFor(b.manageToken),
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 <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
270
- if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, args.start, await repo.getSettings()))) {
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, await repo.getSettings(), opts.now);
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) throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
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' ? 'We weren’t able to confirm this booking in time. Please pick another time.' : undefined);
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 ? '<span class="pc-ev-flag">Cancelled</span> ' : ''}${esc(when)}</div>` +
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>` : '') +