@acms-subir/core 0.3.36 → 0.3.38

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 (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/theme-authoring.md +1 -1
  4. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +54 -47
  5. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +54 -47
  6. package/mobile-template/recipes/rsvp/recipe.json +2 -1
  7. package/package.json +1 -1
  8. package/src/components/EventPage.astro +50 -30
  9. package/src/lib/booking/emails.ts +59 -44
  10. package/src/lib/booking/service.ts +22 -11
  11. package/src/lib/calendar/cards.ts +5 -3
  12. package/src/lib/calendar/event-words.ts +171 -0
  13. package/src/lib/calendar/events.ts +5 -2
  14. package/src/lib/calendar/render.ts +3 -1
  15. package/src/lib/calendar/rrule.ts +14 -18
  16. package/src/lib/calendar/rsvp-words.ts +121 -0
  17. package/src/lib/email/booking-words.ts +203 -0
  18. package/src/lib/forms/events.ts +4 -2
  19. package/src/lib/mobile/bookings.ts +9 -2
  20. package/src/lib/mobile/calendar.ts +6 -4
  21. package/src/lib/mobile/guest.ts +3 -2
  22. package/src/lib/mobile/manage.ts +7 -5
  23. package/src/lib/mobile/rsvp.ts +9 -1
  24. package/src/lib/mobile/schemas.ts +2 -0
  25. package/src/lib/mobile/search.ts +1 -1
  26. package/src/lib/mobile/site.ts +7 -3
  27. package/src/lib/push/service.ts +10 -0
  28. package/src/lib/repo/migrations.ts +5 -0
  29. package/src/lib/repo/sql.ts +4 -4
  30. package/src/lib/repo/types.ts +2 -1
  31. package/src/lib/schema.ts +3 -0
  32. package/src/pages/book/[resource]/[link].astro +8 -4
  33. package/src/pages/book/manage/[token].astro +48 -29
  34. package/src/pages/events/[slug].astro +8 -5
  35. package/src/theme-sdk/index.ts +3 -0
@@ -2,13 +2,18 @@
2
2
  // The built-in event page (feature image, date block, place, button, add to
3
3
  // calendar, share, related events). Themes replace it with
4
4
  // templates/event.astro, which receives the same props. Every element
5
- // carries a stable .pc-ev-* class for CSS overrides.
5
+ // carries a stable .pc-ev-* class for CSS overrides. Built-in words follow
6
+ // the page's language (lib/calendar/event-words.ts, also in the theme SDK).
6
7
  import type { CalEvent, Calendar, Settings } from '../lib/schema';
7
8
  import { CALENDAR, eventIcsUrl, eventUrl, calendarPageUrl } from '../lib/calendar/config';
8
9
  import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
9
10
  import { describeRepeat } from '../lib/calendar/rrule';
10
11
  import { eventCardHtml, safeHref } from '../lib/calendar/cards';
11
- import { siteLocale } from '../lib/dates';
12
+ import { siteLang, siteLocale } from '../lib/dates';
13
+ import { I18N, localePath, requestLocale } from '../lib/i18n';
14
+ import { aroundCount, rsvpStrings } from '../lib/calendar/rsvp-words';
15
+ import { eventStrings } from '../lib/calendar/event-words';
16
+ import { fill } from '../lib/live/words';
12
17
  import { eventImage } from '../lib/calendar/images';
13
18
  import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
14
19
 
@@ -31,12 +36,18 @@ const image = eventImage(e, settings);
31
36
  const tz = eventTimeZone(e, settings);
32
37
  const locale = siteLocale(settings);
33
38
  const f = formatOccurrence(o, tz, locale);
34
- const pageUrl = origin + eventUrl(e.slug);
39
+ // the page's language, as the layout's: its built-in words, and links that
40
+ // keep the visitor in it (/es/events/…)
41
+ const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
42
+ const lang = reqLocale || siteLang(settings);
43
+ const ew = eventStrings(lang);
44
+ const here = (path: string) => localePath(path, reqLocale);
45
+ const pageUrl = origin + here(eventUrl(e.slug));
35
46
  const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
36
47
  const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
37
48
  const [y, m, d] = ymd.split('-').map(Number);
38
49
  return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
39
- });
50
+ }, lang);
40
51
  const ctaHref = safeHref(e.cta.href);
41
52
  const mapHref = e.location.url
42
53
  ? safeHref(e.location.url)
@@ -48,7 +59,9 @@ const loc = e.location;
48
59
  const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
49
60
  const directions = showMap ? safeHref(directionsUrl(loc)) : '';
50
61
  const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
51
- const mapLabel = loc.name || loc.address || 'the location';
62
+ const place = loc.name || loc.address;
63
+ const directionsLabel = place ? fill(ew.directionsTo, { place }) : ew.directionsHere;
64
+ const mapAlt = place ? fill(ew.mapOf, { place }) : ew.mapHere;
52
65
  const fieldRows = CALENDAR.eventFields.filter((fd) => !fd.private && e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
53
66
  const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
54
67
  const I = {
@@ -61,6 +74,13 @@ const I = {
61
74
  video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
62
75
  };
63
76
  const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
77
+ // the RSVP block speaks the page's language too
78
+ const w = rsvpStrings(lang);
79
+ const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
80
+ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.spotsLeft) : ['', ''];
81
+ const dateCancelled = fill(ew.dateCancelled, { date: f.date });
82
+ const showing = fill(ew.showing, { date: f.date });
83
+ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: true, href: here(eventUrl(u.event.slug)) })).join('');
64
84
  ---
65
85
 
66
86
  <article class={`pc-ev-page${cancelled ? ' is-cancelled' : ''}`}>
@@ -73,19 +93,19 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
73
93
  {(calendars.length > 0 || visibleTags.length > 0) && (
74
94
  <div class="pc-ev-chips">
75
95
  {calendars.map((c) => (
76
- <a class="pc-ev-chip" href={calendarPageUrl(c.slug)}><i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}</a>
96
+ <a class="pc-ev-chip" href={here(calendarPageUrl(c.slug))}><i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}</a>
77
97
  ))}
78
98
  {visibleTags.map((t) => <span class="pc-ev-chip is-tag">{t}</span>)}
79
99
  </div>
80
100
  )}
81
101
  <h1 class="pc-ev-title">{e.title}</h1>
82
- {e.cancelled && <p class="pc-ev-cancelled"><b>This event is cancelled.</b> It stays listed so nobody is left wondering.</p>}
83
- {!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>Cancelled on {f.date}.</b> Other dates go ahead as usual.</p>}
102
+ {e.cancelled && <p class="pc-ev-cancelled"><b>{ew.eventCancelled}</b> {ew.eventCancelledNote}</p>}
103
+ {!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>{dateCancelled}</b> {ew.dateCancelledNote}</p>}
84
104
  {e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
85
105
  <div class="pc-ev-body post-content" set:html={html} />
86
106
  </div>
87
107
 
88
- <aside class="pc-ev-side" aria-label="Event details">
108
+ <aside class="pc-ev-side" aria-label={ew.details}>
89
109
  <div class="pc-ev-date">
90
110
  <span class="pc-ev-tile" aria-hidden="true"><b>{f.month}</b><span>{f.day}</span></span>
91
111
  <div>
@@ -101,7 +121,7 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
101
121
  {(e.location.address || mapHref) && (
102
122
  <span>
103
123
  {e.location.address}
104
- {mapHref && <>{e.location.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? 'Join online' : 'Map'}</a></>}
124
+ {mapHref && <>{e.location.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? ew.joinOnline : ew.map}</a></>}
105
125
  </span>
106
126
  )}
107
127
  </div>
@@ -109,9 +129,9 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
109
129
  )}
110
130
  {showMap && (
111
131
  <figure class="pc-ev-map">
112
- <a href={directions || undefined} target="_blank" rel="noopener" aria-label={`Directions to ${mapLabel}`}>
113
- <img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={`Map of ${mapLabel}`} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />
114
- {loc.mapImageDark && <img class="pc-ev-map-img is-dark" src={loc.mapImageDark} alt={`Map of ${mapLabel}`} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />}
132
+ <a href={directions || undefined} target="_blank" rel="noopener" aria-label={directionsLabel}>
133
+ <img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={mapAlt} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />
134
+ {loc.mapImageDark && <img class="pc-ev-map-img is-dark" src={loc.mapImageDark} alt={mapAlt} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />}
115
135
  </a>
116
136
  {mapCredit && <figcaption class="pc-ev-map-credit">{mapCredit}</figcaption>}
117
137
  </figure>
@@ -119,7 +139,7 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
119
139
  {repeats && (
120
140
  <div class="pc-ev-fact">
121
141
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.repeat} /></svg>
122
- <div><b>{repeats}</b><span>Showing {f.date}</span></div>
142
+ <div><b>{repeats}</b><span>{showing}</span></div>
123
143
  </div>
124
144
  )}
125
145
  {fieldRows.map((r) => (
@@ -130,27 +150,27 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
130
150
  ))}
131
151
  {rsvp ? (
132
152
  <div class="pc-ev-rsvp">
133
- <p class="pc-ev-going"><b>{rsvp.going}</b> going{rsvp.left !== null && <> · <b>{rsvp.left}</b> {rsvp.left === 1 ? 'spot' : 'spots'} left</>}</p>
153
+ <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>
134
154
  {rsvp.open && (rsvp.left === null || rsvp.left > 0) ? (
135
155
  <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
136
- <summary class="pc-ev-cta">Save my seat</summary>
156
+ <summary class="pc-ev-cta">{w.saveSeat}</summary>
137
157
  <form method="post">
138
158
  <Fragment set:html={rsvp.formHtml ?? ''}/>
139
159
  {e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
140
160
  {e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
141
161
  {rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
142
- <label class="pc-bk-field"><span>Your name</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
143
- <label class="pc-bk-field"><span>Email</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
162
+ <label class="pc-bk-field"><span>{w.nameField}</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
163
+ <label class="pc-bk-field"><span>{w.emailField}</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
144
164
  {rsvp.maxSeats > 1 && (
145
- <label class="pc-bk-field"><span>Seats</span>
165
+ <label class="pc-bk-field"><span>{w.seatsField}</span>
146
166
  <select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
147
167
  </label>
148
168
  )}
149
169
  <Fragment set:html={rsvp.fieldsHtml ?? ''}/>
150
- <button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat')}</button>
170
+ <button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? w.confirmSeat : w.requestSeat)}</button>
151
171
  </form>
152
172
  </details>
153
- ) : <p class="pc-ev-full">{rsvp.open ? 'This event is full.' : 'RSVPs are closed.'}</p>}
173
+ ) : <p class="pc-ev-full">{rsvp.open ? w.full : w.closed}</p>}
154
174
  </div>
155
175
  ) : e.cta.label && ctaHref && !cancelled && (
156
176
  <a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
@@ -158,33 +178,33 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
158
178
  <details class="pc-ev-add">
159
179
  <summary>
160
180
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.cal} /></svg>
161
- Add to calendar
181
+ {ew.addToCalendar}
162
182
  </summary>
163
183
  <div class="pc-ev-add-menu">
164
184
  <a href={links.google} target="_blank" rel="noopener">Google Calendar</a>
165
185
  <a href={links.ics}>Apple Calendar</a>
166
186
  <a href={links.outlook} target="_blank" rel="noopener">Outlook</a>
167
- <a href={links.ics} download={`${e.slug}.ics`}>Download .ics</a>
187
+ <a href={links.ics} download={`${e.slug}.ics`}>{ew.downloadIcs}</a>
168
188
  </div>
169
189
  </details>
170
- <button type="button" class="pc-ev-share" data-url={pageUrl} data-title={e.title}>
190
+ <button type="button" class="pc-ev-share" data-url={pageUrl} data-title={e.title} data-share={ew.share} data-copied={ew.linkCopied}>
171
191
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.share} /></svg>
172
- <span>Share</span>
192
+ <span>{ew.share}</span>
173
193
  </button>
174
194
  </aside>
175
195
  </div>
176
196
 
177
197
  {upcoming.length > 0 && (
178
198
  <section class="pc-ev-more">
179
- <h2>More events</h2>
180
- <div class="pc-ev-cards" data-layout="grid" set:html={upcoming.map((u) => eventCardHtml(u, settings, { showImage: true })).join('')} />
181
- <a class="pc-ev-all" href={CALENDAR.base}>See the full calendar →</a>
199
+ <h2>{ew.moreEvents}</h2>
200
+ <div class="pc-ev-cards" data-layout="grid" set:html={moreCards} />
201
+ <a class="pc-ev-all" href={here(CALENDAR.base)}>{ew.fullCalendar}</a>
182
202
  </section>
183
203
  )}
184
204
  </article>
185
205
 
186
206
  <script>
187
- // share sheet where available, else copy the link
207
+ // share sheet where available, else copy the link (words from the page's language)
188
208
  document.querySelectorAll<HTMLButtonElement>('.pc-ev-share').forEach((b) => b.addEventListener('click', async () => {
189
209
  const url = b.dataset.url ?? location.href;
190
210
  if (navigator.share) {
@@ -192,7 +212,7 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
192
212
  }
193
213
  await navigator.clipboard.writeText(url);
194
214
  const label = b.querySelector('span');
195
- if (label) { label.textContent = 'Link copied'; setTimeout(() => (label.textContent = 'Share'), 1400); }
215
+ if (label) { label.textContent = b.dataset.copied ?? 'Link copied'; setTimeout(() => (label.textContent = b.dataset.share ?? 'Share'), 1400); }
196
216
  }));
197
217
  // one open menu; outside click closes
198
218
  document.addEventListener('click', (ev) => {
@@ -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,58 @@ 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
100
  export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'staff-new';
74
101
 
75
102
  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 }) }];
103
+ const w = bookingStrings(c.lang);
104
+ const rsvp = c.booking.kind === 'rsvp';
105
+ const manage = { href: c.manageUrl, label: rsvp ? w.rsvp.manage : w.manage };
106
+ 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
+ // null: no button (nothing left to change)
108
+ const guest = (k: Exclude<BookingEmail, 'staff-new'>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
109
+ const copy = mailCopy(w, k, rsvp);
110
+ const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when };
111
+ return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(copy.heading, fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
112
+ };
78
113
  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
- }
114
+ case 'received': return guest('received');
115
+ case 'confirmed': return { ...guest('confirmed', { message: c.message || c.link?.confirmMessage || '' }), attachments: ics() };
116
+ case 'declined': return guest('declined', {}, null);
117
+ case 'cancelled': return { ...guest('cancelled', {}, null), attachments: ics('CANCEL') };
118
+ case 'updated': return { ...guest('updated'), attachments: ics() };
119
+ case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
120
+ case 'reminder': return guest('reminder');
107
121
  case 'staff-new': {
108
122
  const pending = c.booking.status === 'pending';
109
123
  const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
110
124
  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 };
125
+ const vars = { title: c.title, when: c.when };
126
+ 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
+ return { to: [], subject: fill(pending ? w.staff.pendingSubject : w.staff.bookedSubject, vars), ...m, replyTo: c.booking.email || undefined };
113
128
  }
114
129
  }
115
130
  }
@@ -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';
12
+ import { eventUrl } from '../calendar/config';
13
13
  import { eventTimeZone, locationLine, occurrenceKey } from '../calendar/events';
14
- import { bookingEmail, whenText, type BookingEmail } from './emails';
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';
@@ -76,9 +79,12 @@ export async function availabilityContext(
76
79
 
77
80
  // ---- notifications ---------------------------------------------------------
78
81
 
79
- async function describe(repo: ContentRepository, b: Booking, settings: Settings) {
82
+ // locale: an extra language to word it in (the event's translated title and
83
+ // place, dates in it); settings then carry that language
84
+ async function describe(repo: ContentRepository, b: Booking, settings: Settings, locale = '') {
80
85
  if (b.kind === 'rsvp') {
81
- const ev = b.eventId ? await repo.getEvent(b.eventId) : null;
86
+ const raw = b.eventId ? await repo.getEvent(b.eventId) : null;
87
+ const ev = raw && locale ? localizeDoc(raw, locale) : raw;
82
88
  const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
83
89
  return {
84
90
  resource: null, link: null, event: ev,
@@ -108,17 +114,20 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
108
114
  }
109
115
 
110
116
  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
+ const site = await repo.getSettings();
118
+ // the guest hears in the language they booked in; staff in the main one
119
+ const language = bookingLanguage(kind === 'staff-new' ? { locale: '' } : b, site);
120
+ const settings = language.settings;
121
+ const d = await describe(repo,b,settings,language.locale);
113
122
  if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
114
123
  d.title = snapshot.eventTitle;
115
124
  try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
116
125
  d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
117
126
  }
118
127
  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),
128
+ manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + bookingManagePath(b),
120
129
  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) });
130
+ allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang });
122
131
  }
123
132
  async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
124
133
  // Repository mutations already committed their outbox jobs atomically.
@@ -142,6 +151,8 @@ export interface BookerInput {
142
151
  phone?: string;
143
152
  answers?: Record<string, string>;
144
153
  formVersion?: string;
154
+ /** the language they booked in (the page's or the app's); left out = the main language */
155
+ locale?: string;
145
156
  }
146
157
 
147
158
  export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'> | null, input: BookerInput): { name: string; email: string; phone: string; answers: Record<string, string> } {
@@ -236,7 +247,7 @@ export async function createResourceBooking(
236
247
  kind: 'booking', resourceId: resource.id, linkId: link.id, eventId: '', status,
237
248
  start: args.start, end: args.end, ...holdOf(link, args.start, args.end),
238
249
  timezone: resourceZone(resource, settings.timezone), ...booker, seats: 1, notes: '',
239
- manageToken: token(), source: args.source ?? 'web',
250
+ manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
240
251
  }, { idempotency: opts.idempotency });
241
252
  if (!b) throw new BookingError(409, 'That time was just taken. Please pick another.');
242
253
  await notify(repo, b, [status === 'confirmed' ? 'confirmed' : 'received', 'staff-new'], opts);
@@ -278,7 +289,7 @@ export async function createRsvp(
278
289
  const b = await repo.createBooking({
279
290
  kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
280
291
  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',
292
+ formResponse, notes: '', manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
282
293
  }, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
283
294
  if (!b) throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
284
295
  await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
@@ -377,7 +388,7 @@ export async function decideOverdue(repo: ContentRepository, opts: FlowOptions):
377
388
  if (!rules.deadlineHours || b.createdAt + rules.deadlineHours * 3_600_000 > now) continue;
378
389
  try {
379
390
  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);
391
+ rules.deadlineAction === 'decline' ? bookingStrings(bookingLanguage(b, settings).lang).lateDecline : undefined);
381
392
  decided++;
382
393
  } catch { /* changed meanwhile — next sweep sees the new state */ }
383
394
  }
@@ -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>` : '') +