@acms-subir/core 0.3.37 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.38
4
+
5
+ ### Patch Changes
6
+
7
+ - 8d85520: `GET /api/v1/site?locale=es` returns the menu the way the `/es` header shows it: links to the Spanish pages, and the automatic menu's own words translated ("Inicio", "Enlaces"). Labels you wrote in Navigation stay as written. Events in the app API carry `notify.changes`, the push topics that hear when the event's date, time or place changes (Admin → Mobile app → When an event changes), or `[]`, so an app only promises "we'll let you know if it changes" when it's true. The RSVP panel recipe takes `children`, shown under the person's RSVP.
8
+ - 7b71457: Event pages now follow the page's language, in English, Spanish, French, German and Portuguese (other languages stay in English). On `/es` event pages, and on a site whose main language is Spanish, a cancelled date said "Cancelled on … Other dates go ahead as usual." in English; now it says "Cancelado el domingo, 25 de octubre de 2026. Las demás fechas siguen como siempre.", so the "Cancelado el …" push opens a page that matches it. The same goes for a cancelled event, the "Cancelado" flag on event cards and in the calendar's pop-up, "Todo el día", the repeat line ("Todos los domingos", "El primer domingo de cada mes", "Cada 2 semanas, los lunes, 6 veces"), Agregar al calendario, Descargar .ics, Compartir and Enlace copiado, Más eventos, Ver el calendario completo, the map's labels and "Mostrando el …". Links on a `/es` event page (more events, the calendar, sharing) stay in Spanish. Theme event templates can use the same words with `eventStrings` and `describeRepeat` from `@acms-subir/core/theme`.
9
+
10
+ Booking and RSVP emails now speak the language the person used: the page's on the website (RSVPs from `/es` event pages, and bookings from `/es/book/…`, whose pages stay in English), the app's `locale` on `POST /api/v1/bookings`, and the site's main language for bookings staff add. The subject, message, details, dates and button are in that language, RSVPs show the event's translated title and place, and every link (the booking's own page, the calendar file) keeps it, such as `/es/book/manage/…`. Spanish emails say _usted_, like the privacy notice. In the app API, `manageUrl` (and the event's `url` from `GET /api/v1/bookings/manage`) points to the page in that language too. Staff notifications stay in the main language. Older bookings, and backups made before this release, use the main language. On a site whose main language isn't English, booking emails without a language (and staff notifications) now come in that language instead of English. Migration 60 adds `bookings.locale` (default `''`, the main language).
11
+
12
+ A room booking's own page (`/book/manage/…`, the page booking emails link to) now follows the page's language too, like an RSVP's: "Tu reservación está confirmada", Cambiar horario, Cancelar reservación and so on. Opened without `/es`, it shows the main language.
13
+
3
14
  ## 0.3.37
4
15
 
5
16
  ### Patch Changes
@@ -1111,6 +1111,11 @@ var MIGRATIONS = [
1111
1111
  { version: 59, statements: [
1112
1112
  `ALTER TABLE push_queue ADD COLUMN payload TEXT`,
1113
1113
  `ALTER TABLE push_queue ADD COLUMN due_at INTEGER`
1114
+ ] },
1115
+ // the language a booking or RSVP was made in (Booking.locale), so its
1116
+ // emails and links follow it; '' = the main language, so older rows need no backfill
1117
+ { version: 60, statements: [
1118
+ `ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`
1114
1119
  ] }
1115
1120
  ];
1116
1121
  var CURRENT_CONTENT_VERSION_COLUMNS = {
@@ -32,7 +32,7 @@ const { page, posts } = Astro.props;
32
32
  {posts.map(post => <PostCard href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} />)}
33
33
  ```
34
34
 
35
- Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
35
+ Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. For an event template, `eventStrings(lang)` gives the built-in event page's words (Cancelled, Add to calendar, Share, …) and `describeRepeat(event.rrule, event.start, formatDate?, lang)` its repeat line ("Weekly on Sunday", "Todos los domingos"), in English, Spanish, French, German or Portuguese, otherwise English. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
36
36
 
37
37
  ### Upcoming events in coded templates
38
38
 
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useState } from 'react';
1
+ import { Fragment, useEffect, useState, type ReactNode } from 'react';
2
2
  import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
3
3
  import * as Crypto from 'expo-crypto';
4
4
  import * as SecureStore from 'expo-secure-store';
@@ -37,7 +37,9 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
37
37
  // a count in bold inside its words ("{count} going")
38
38
  const bold = (template: string, n: string) => template.split('{count}').map((part, i) => (i ? <Fragment key={i}><Text style={{ fontWeight: '700' }}>{n}</Text>{part}</Fragment> : part));
39
39
 
40
- export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
40
+ // children: shown under the person's RSVP (not a declined one), e.g. an offer
41
+ // to get notified if the date, time or place changes
42
+ export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
41
43
  const t = useTheme();
42
44
  const words = useStrings();
43
45
  const { locale } = useLocale();
@@ -113,7 +115,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
113
115
  const who = mine?.booking.email ?? made!.email;
114
116
  const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
115
117
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
116
- return (
118
+ return (<>
117
119
  <View style={[t.styles.card, { gap: 8 }]}>
118
120
  <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
119
121
  <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
@@ -136,7 +138,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
136
138
  {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
137
139
  {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
138
140
  </View>
139
- );
141
+ {state !== 'declined' && children}
142
+ </>);
140
143
  }
141
144
 
142
145
  if (s.reason === 'disabled') return null;
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useState } from 'react';
1
+ import { Fragment, useEffect, useState, type ReactNode } from 'react';
2
2
  import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
3
3
  import * as Crypto from 'expo-crypto';
4
4
  import * as SecureStore from 'expo-secure-store';
@@ -37,7 +37,9 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
37
37
  // a count in bold inside its words ("{count} going")
38
38
  const bold = (template: string, n: string) => template.split('{count}').map((part, i) => (i ? <Fragment key={i}><Text style={{ fontWeight: '700' }}>{n}</Text>{part}</Fragment> : part));
39
39
 
40
- export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
40
+ // children: shown under the person's RSVP (not a declined one), e.g. an offer
41
+ // to get notified if the date, time or place changes
42
+ export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
41
43
  const t = useTheme();
42
44
  const words = useStrings();
43
45
  const { locale } = useLocale();
@@ -113,7 +115,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
113
115
  const who = mine?.booking.email ?? made!.email;
114
116
  const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
115
117
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
116
- return (
118
+ return (<>
117
119
  <View style={[t.styles.card, { gap: 8 }]}>
118
120
  <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
119
121
  <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
@@ -136,7 +138,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
136
138
  {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
137
139
  {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
138
140
  </View>
139
- );
141
+ {state !== 'declined' && children}
142
+ </>);
140
143
  }
141
144
 
142
145
  if (s.reason === 'disabled') return null;
@@ -10,6 +10,7 @@
10
10
  "next": [
11
11
  "Mount it on your event screen where the date is known: <RsvpPanel slug={occurrence.event.slug} start={occurrence.start} /> from '../../components/RsvpPanel', with start exactly as useEvents returns it (the UTC ISO string, e.g. pass o.start through router params), and only when occurrence.event.rsvp?.enabled",
12
12
  "The screen should scroll and avoid the keyboard: wrap it in <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}> with <ScrollView keyboardShouldPersistTaps=\"handled\">",
13
- "RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too"
13
+ "RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too",
14
+ "Anything you pass as children shows under the person's RSVP (not a declined one), e.g. an offer to get notified if the date, time or place changes: <RsvpPanel slug={…} start={…}><MyOffer /></RsvpPanel>. With push on, occurrence.event.notify.changes lists the topics that hear about changes ([] = none)"
14
15
  ]
15
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.37",
3
+ "version": "0.3.38",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -2,15 +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
12
  import { siteLang, siteLocale } from '../lib/dates';
12
- import { I18N, requestLocale } from '../lib/i18n';
13
+ import { I18N, localePath, requestLocale } from '../lib/i18n';
13
14
  import { aroundCount, rsvpStrings } from '../lib/calendar/rsvp-words';
15
+ import { eventStrings } from '../lib/calendar/event-words';
16
+ import { fill } from '../lib/live/words';
14
17
  import { eventImage } from '../lib/calendar/images';
15
18
  import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
16
19
 
@@ -33,12 +36,18 @@ const image = eventImage(e, settings);
33
36
  const tz = eventTimeZone(e, settings);
34
37
  const locale = siteLocale(settings);
35
38
  const f = formatOccurrence(o, tz, locale);
36
- 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));
37
46
  const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
38
47
  const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
39
48
  const [y, m, d] = ymd.split('-').map(Number);
40
49
  return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
41
- });
50
+ }, lang);
42
51
  const ctaHref = safeHref(e.cta.href);
43
52
  const mapHref = e.location.url
44
53
  ? safeHref(e.location.url)
@@ -50,7 +59,9 @@ const loc = e.location;
50
59
  const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
51
60
  const directions = showMap ? safeHref(directionsUrl(loc)) : '';
52
61
  const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
53
- 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;
54
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 }));
55
66
  const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
56
67
  const I = {
@@ -63,10 +74,13 @@ const I = {
63
74
  video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
64
75
  };
65
76
  const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
66
- // the RSVP block speaks the page's language, as the layout does
67
- const w = rsvpStrings((I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '') || siteLang(settings));
77
+ // the RSVP block speaks the page's language too
78
+ const w = rsvpStrings(lang);
68
79
  const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
69
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('');
70
84
  ---
71
85
 
72
86
  <article class={`pc-ev-page${cancelled ? ' is-cancelled' : ''}`}>
@@ -79,19 +93,19 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
79
93
  {(calendars.length > 0 || visibleTags.length > 0) && (
80
94
  <div class="pc-ev-chips">
81
95
  {calendars.map((c) => (
82
- <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>
83
97
  ))}
84
98
  {visibleTags.map((t) => <span class="pc-ev-chip is-tag">{t}</span>)}
85
99
  </div>
86
100
  )}
87
101
  <h1 class="pc-ev-title">{e.title}</h1>
88
- {e.cancelled && <p class="pc-ev-cancelled"><b>This event is cancelled.</b> It stays listed so nobody is left wondering.</p>}
89
- {!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>}
90
104
  {e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
91
105
  <div class="pc-ev-body post-content" set:html={html} />
92
106
  </div>
93
107
 
94
- <aside class="pc-ev-side" aria-label="Event details">
108
+ <aside class="pc-ev-side" aria-label={ew.details}>
95
109
  <div class="pc-ev-date">
96
110
  <span class="pc-ev-tile" aria-hidden="true"><b>{f.month}</b><span>{f.day}</span></span>
97
111
  <div>
@@ -107,7 +121,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
107
121
  {(e.location.address || mapHref) && (
108
122
  <span>
109
123
  {e.location.address}
110
- {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></>}
111
125
  </span>
112
126
  )}
113
127
  </div>
@@ -115,9 +129,9 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
115
129
  )}
116
130
  {showMap && (
117
131
  <figure class="pc-ev-map">
118
- <a href={directions || undefined} target="_blank" rel="noopener" aria-label={`Directions to ${mapLabel}`}>
119
- <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" />
120
- {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" />}
121
135
  </a>
122
136
  {mapCredit && <figcaption class="pc-ev-map-credit">{mapCredit}</figcaption>}
123
137
  </figure>
@@ -125,7 +139,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
125
139
  {repeats && (
126
140
  <div class="pc-ev-fact">
127
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>
128
- <div><b>{repeats}</b><span>Showing {f.date}</span></div>
142
+ <div><b>{repeats}</b><span>{showing}</span></div>
129
143
  </div>
130
144
  )}
131
145
  {fieldRows.map((r) => (
@@ -164,33 +178,33 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
164
178
  <details class="pc-ev-add">
165
179
  <summary>
166
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>
167
- Add to calendar
181
+ {ew.addToCalendar}
168
182
  </summary>
169
183
  <div class="pc-ev-add-menu">
170
184
  <a href={links.google} target="_blank" rel="noopener">Google Calendar</a>
171
185
  <a href={links.ics}>Apple Calendar</a>
172
186
  <a href={links.outlook} target="_blank" rel="noopener">Outlook</a>
173
- <a href={links.ics} download={`${e.slug}.ics`}>Download .ics</a>
187
+ <a href={links.ics} download={`${e.slug}.ics`}>{ew.downloadIcs}</a>
174
188
  </div>
175
189
  </details>
176
- <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}>
177
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>
178
- <span>Share</span>
192
+ <span>{ew.share}</span>
179
193
  </button>
180
194
  </aside>
181
195
  </div>
182
196
 
183
197
  {upcoming.length > 0 && (
184
198
  <section class="pc-ev-more">
185
- <h2>More events</h2>
186
- <div class="pc-ev-cards" data-layout="grid" set:html={upcoming.map((u) => eventCardHtml(u, settings, { showImage: true })).join('')} />
187
- <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>
188
202
  </section>
189
203
  )}
190
204
  </article>
191
205
 
192
206
  <script>
193
- // share sheet where available, else copy the link
207
+ // share sheet where available, else copy the link (words from the page's language)
194
208
  document.querySelectorAll<HTMLButtonElement>('.pc-ev-share').forEach((b) => b.addEventListener('click', async () => {
195
209
  const url = b.dataset.url ?? location.href;
196
210
  if (navigator.share) {
@@ -198,7 +212,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
198
212
  }
199
213
  await navigator.clipboard.writeText(url);
200
214
  const label = b.querySelector('span');
201
- 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); }
202
216
  }));
203
217
  // one open menu; outside click closes
204
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>` : '') +