@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.
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import type { Booking, BookingLink, CalEvent, Resource, Settings } from '../schema';
3
3
  import { ApiError, readJson } from '../api';
4
- import { bookingsEnabled, eventUrl, manageUrl } from '../calendar/config';
4
+ import { bookingsEnabled, eventUrl } from '../calendar/config';
5
5
  import { eventTimeZone, locationLine } from '../calendar/events';
6
6
  import { resourceZone, linkLengths } from '../booking/availability';
7
- import { bookingIcs } from '../booking/emails';
7
+ import { bookingIcs, bookingLanguage, bookingManagePath } from '../booking/emails';
8
+ import { localePath } from '../i18n';
8
9
  import { guestCancel, guestChanges, guestMoveLink, guestReschedule } from '../booking/service';
9
10
  import { siteOrigin } from '../seo';
10
11
  import { waitUntilFrom } from '../webhooks';
@@ -59,9 +60,10 @@ function view(m: Managed, booking = m.booking) {
59
60
  location: event ? locationLine(event.location) : resource?.name ?? '',
60
61
  resource: resource ? { slug: resource.slug, name: resource.name } : null,
61
62
  link: link ? { slug: link.slug, name: link.name, mode: link.mode, lengths: linkLengths(link) } : null,
62
- event: event ? { slug: event.slug, title: event.title, url: origin + eventUrl(event.slug) } : null,
63
+ // the web pages in the language it was booked in, as the emails link them
64
+ event: event ? { slug: event.slug, title: event.title, url: origin + localePath(eventUrl(event.slug), bookingLanguage(booking, settings).locale) } : null,
63
65
  // the web page for this booking (the same private link the emails carry)
64
- manageUrl: origin + manageUrl(booking.manageToken),
66
+ manageUrl: origin + bookingManagePath(booking),
65
67
  can,
66
68
  // the RSVP form as it was when they sent it (like the web page), else the booking type's message
67
69
  confirmation: booking.formResponse?.definition.confirmation || link?.confirmMessage || null,
@@ -106,7 +108,7 @@ export async function managedIcs(c: PublicContext) {
106
108
  z.object({}).strict().parse(queryParams(c.url));
107
109
  const m = await managed(c);
108
110
  const v = view(m);
109
- const body = bookingIcs(m.booking, v.title, v.location, v.manageUrl, new URL(m.origin).host, m.booking.status === 'cancelled' ? 'CANCEL' : 'REQUEST', { allDay: v.booking.allDay });
111
+ const body = bookingIcs(m.booking, v.title, v.location, v.manageUrl, new URL(m.origin).host, m.booking.status === 'cancelled' ? 'CANCEL' : 'REQUEST', { allDay: v.booking.allDay, lang: bookingLanguage(m.booking, m.settings).lang });
110
112
  return new Response(body, { headers: {
111
113
  'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'attachment; filename="booking.ics"',
112
114
  'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff',
@@ -32,6 +32,8 @@ export const Event = CalEvent.pick({
32
32
  allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
33
33
  }).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
34
34
  calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
35
+ // push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
36
+ notify: z.object({ changes: strings }),
35
37
  });
36
38
  export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
37
39
  occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, event: Event })),
@@ -87,7 +87,7 @@ export async function search(repo: ContentRepository, url: URL) {
87
87
  snippet: plainSnippet(snippet),
88
88
  // the next occurrence, or the latest one when the event is over
89
89
  occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled } : null,
90
- event: publicEventDto(e, origin, listed, settings.timezone, locale),
90
+ event: publicEventDto(e, origin, listed, settings, locale),
91
91
  };
92
92
  }),
93
93
  more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
@@ -13,6 +13,7 @@ import { queryParams } from './http';
13
13
  import { localeOnlyQuery, localizedUrl, mobileLocale } from './locale';
14
14
  import { guestSignInReady } from './guest';
15
15
  import { LEGAL } from '../legal/config';
16
+ import { siteStrings } from '../site-words';
16
17
 
17
18
  export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
18
19
  // locale: the legal links point at that language's pages (/es/privacy)
@@ -23,16 +24,19 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
23
24
  const origin = siteOrigin(settings, url);
24
25
  const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
25
26
  const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
27
+ // the same list the website's header shows in that language: the owner's
28
+ // menu as written, or the automatic one with its words translated
29
+ const words = siteStrings(locale || settings.locale || 'en');
26
30
  const menu = header.length ? header : [
27
- { label: 'Home', url: '/' },
31
+ { label: words.home, url: '/' },
28
32
  ...pages.filter(p => p.status === 'published' && !p.isHome).map(p => ({ label: p.title, url: `/${p.slug}` })),
29
33
  ...Object.values(CONTENT.collections).map(c => ({ label: c.plural, url: c.base })),
30
- { label: 'Links', url: '/links' },
34
+ { label: words.links, url: '/links' },
31
35
  ];
32
36
  return {
33
37
  name: settings.siteTitle, description: settings.tagline,
34
38
  logo: safeUrl(settings.siteLogo, origin), logoDark: safeUrl(settings.siteLogoDark, origin), icon: safeUrl(settings.siteFavicon, origin),
35
- menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
39
+ menu: menu.map(n => ({ label: n.label, href: localizedUrl(safeUrl(n.url, origin, true), origin, locale) })),
36
40
  languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
37
41
  features: {
38
42
  // redirectFrom: old addresses the website 301s to url, so apps can follow them too,
@@ -229,6 +229,16 @@ function inChangeRule(rule: PushAuto['eventChanges'], e: CalEvent) {
229
229
  const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
230
230
  return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
231
231
  }
232
+ /**
233
+ * The topics this event's change notices go to (Admin → Mobile app → When an
234
+ * event changes), or [] while that rule doesn't cover it. Apps show it to
235
+ * promise "we'll let you know if it changes" only when it's true.
236
+ */
237
+ export function changeTopics(e: CalEvent, settings: Settings, calendars: Map<string, Calendar>): string[] {
238
+ const rule = settings.push.auto.eventChanges;
239
+ if (!PUSH.enabled || !CALENDAR.enabled || !inChangeRule(rule, e)) return [];
240
+ return rule.topics.length ? [...rule.topics] : eventTopics(e, calendars);
241
+ }
232
242
  function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
233
243
  return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
234
244
  }
@@ -685,6 +685,11 @@ export const MIGRATIONS: Migration[] = [
685
685
  `ALTER TABLE push_queue ADD COLUMN payload TEXT`,
686
686
  `ALTER TABLE push_queue ADD COLUMN due_at INTEGER`,
687
687
  ] },
688
+ // the language a booking or RSVP was made in (Booking.locale), so its
689
+ // emails and links follow it; '' = the main language, so older rows need no backfill
690
+ { version: 60, statements: [
691
+ `ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`,
692
+ ] },
688
693
  ];
689
694
 
690
695
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -210,7 +210,7 @@ function rowToBooking(r: Row): Booking {
210
210
  status: r.status, start: Number(r.start_at), end: Number(r.end_at), holdStart: Number(r.hold_start), holdEnd: Number(r.hold_end),
211
211
  timezone: r.timezone ?? '', name: r.name ?? '', email: r.email ?? '', phone: r.phone ?? '', seats: Number(r.seats ?? 1),
212
212
  formResponse: r.form_response ? json(r.form_response, undefined) : undefined,
213
- answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web',
213
+ answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web', locale: r.locale ?? '',
214
214
  notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
215
215
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
216
216
  });
@@ -446,7 +446,7 @@ export class SqlRepository implements ContentRepository {
446
446
  await this.ready;
447
447
  // One SELECT snapshots bookings and their retry ledger together. No UI
448
448
  // list limit; include tombstones for deleted bookings as well.
449
- const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','reminder_sent_at','notification_version','created_at','updated_at'];
449
+ const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','locale','reminder_sent_at','notification_version','created_at','updated_at'];
450
450
  const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
451
451
  UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
452
452
  return {
@@ -1078,9 +1078,9 @@ export class SqlRepository implements ContentRepository {
1078
1078
  private bookingInsert(b: Booking): SqlStatement {
1079
1079
  return {
1080
1080
  sql: `INSERT INTO bookings (id, kind, resource_id, link_id, event_id, status, start_at, end_at, hold_start, hold_end, timezone, name, email, phone,
1081
- seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1081
+ seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response, locale) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1082
1082
  args: [b.id, b.kind, b.resourceId, b.linkId, b.eventId, b.status, b.start, b.end, b.holdStart, b.holdEnd, b.timezone, b.name, b.email, b.phone,
1083
- b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null],
1083
+ b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null, b.locale],
1084
1084
  };
1085
1085
  }
1086
1086
  async listResources() {
@@ -195,8 +195,9 @@ export interface ContentRepository {
195
195
  getBookingByToken(token: string): Promise<Booking | null>;
196
196
  // bookings + RSVPs (never staff blocks) this address made since `since`
197
197
  countBookingsByEmail(email: string, since: number): Promise<number>;
198
+ // locale: the booker's language; '' (the main language) when left out
198
199
  createBooking(
199
- data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'>,
200
+ data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
200
201
  guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
201
202
  ): Promise<Booking | null>;
202
203
  // moves a booking only if the new held range is free (reschedule)
package/src/lib/schema.ts CHANGED
@@ -674,6 +674,9 @@ export const Booking = z.object({
674
674
  manageToken: z.string().default(''),
675
675
  // web: the website; app: the public app API (/api/v1/bookings)
676
676
  source: z.enum(['web', 'app', 'admin', 'api', 'agent']).default('web'),
677
+ // the language the booker used (an extra site locale, e.g. 'es'): their
678
+ // emails and links speak it. '' = the site's main language, as on page rows
679
+ locale: z.string().default(''),
677
680
  reminderSentAt: z.number().nullable().default(null),
678
681
  notificationVersion: z.number().default(0),
679
682
  createdAt: z.number(),
@@ -6,9 +6,12 @@ import { bookingsEnabled, manageUrl, resourceUrl } from '../../../lib/calendar/c
6
6
  import { availabilityContext, createResourceBooking, guestMoveLink, guestReschedule, BookingError } from '../../../lib/booking/service';
7
7
  import { siteOrigin } from '../../../lib/seo';
8
8
  import { waitUntilFrom } from '../../../lib/webhooks';
9
+ import { localePath, requestLocale } from '../../../lib/i18n';
9
10
 
10
11
  // One booking link: pick → answer → done. POSTs create the booking (or move
11
- // one when ?reschedule=<token>) and land on the manage page.
12
+ // one when ?reschedule=<token>) and land on the manage page. These pages are
13
+ // in English, but a booking made from /es/… keeps the visitor's language for
14
+ // its emails and its own page.
12
15
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
13
16
  const repo = await getRepo(Astro.locals);
14
17
  const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
@@ -17,6 +20,7 @@ if (!resource || !link || !link.active) return new Response(null, { status: 404,
17
20
  const settings = await repo.getSettings();
18
21
  const origin = siteOrigin(settings, Astro.url);
19
22
  const flow = { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) };
23
+ const locale = requestLocale(Astro.url, Astro.locals);
20
24
  const rescheduleToken = Astro.url.searchParams.get('reschedule') ?? '';
21
25
  const reschedule = rescheduleToken ? await repo.getBookingByToken(rescheduleToken) : null;
22
26
  if (reschedule && reschedule.linkId !== link.id) return Astro.redirect(manageUrl(reschedule.manageToken));
@@ -37,16 +41,16 @@ if (Astro.request.method === 'POST') {
37
41
  const b = await repo.getBookingByToken(token);
38
42
  if (!b || b.linkId !== link.id) throw new BookingError(404, 'That booking can’t be found.');
39
43
  await guestReschedule(repo, b, start, end, flow);
40
- return Astro.redirect(`${manageUrl(b.manageToken)}?moved=1`);
44
+ return Astro.redirect(`${localePath(manageUrl(b.manageToken), locale)}?moved=1`);
41
45
  }
42
46
  const answers: Record<string, string> = {};
43
47
  for (const qn of link.questions) answers[qn.id] = String(form.get(`q_${qn.id}`) ?? '');
44
48
  const b = await createResourceBooking(repo, {
45
49
  resource, link, start, end,
46
- booker: { name: String(form.get('name') ?? ''), email: String(form.get('email') ?? ''), phone: String(form.get('phone') ?? ''), answers },
50
+ booker: { name: String(form.get('name') ?? ''), email: String(form.get('email') ?? ''), phone: String(form.get('phone') ?? ''), answers, locale },
47
51
  }, flow);
48
52
  if (link.redirectUrl && /^https?:\/\//.test(link.redirectUrl)) return Astro.redirect(link.redirectUrl);
49
- return Astro.redirect(`${manageUrl(b.manageToken)}?new=1`);
53
+ return Astro.redirect(`${localePath(manageUrl(b.manageToken), locale)}?new=1`);
50
54
  } catch (err) {
51
55
  error = err instanceof BookingError ? err.message : 'Something went wrong. Please try again.';
52
56
  if (Number.isFinite(start)) {
@@ -12,13 +12,16 @@ import { I18N, localePath, requestLocale } from '../../../lib/i18n';
12
12
  import { siteLang } from '../../../lib/dates';
13
13
  import { isExtraLocale, localizeDoc } from '../../../lib/translations';
14
14
  import { rsvpStrings } from '../../../lib/calendar/rsvp-words';
15
+ import { bookingStrings } from '../../../lib/email/booking-words';
15
16
  import { fill } from '../../../lib/live/words';
16
17
 
17
18
  // The booker's own page, reached from the confirmation and every email: see
18
19
  // the status, add it to a calendar, move it or cancel it. The token in the
19
- // URL is the only credential — it's long and random, and never listed. An
20
- // RSVP's page speaks the page's language (/es/book/manage/… in Spanish);
21
- // room bookings stay in English like the rest of their pages.
20
+ // URL is the only credential — it's long and random, and never listed. It
21
+ // speaks the request's language, for RSVPs and room bookings alike
22
+ // (/es/book/manage/… in Spanish, as the emails of a booking made in Spanish
23
+ // link it); opened without the prefix it shows the main language, with no
24
+ // redirect. The room booking flow it links back to stays in English.
22
25
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
23
26
  const repo = await getRepo(Astro.locals);
24
27
  const booking = await repo.getBookingByToken(Astro.params.token ?? '');
@@ -32,14 +35,18 @@ const [resource, link, rawEvent] = await Promise.all([
32
35
  ]);
33
36
  const isRsvp = booking.kind === 'rsvp';
34
37
  const locale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
35
- const w = rsvpStrings(isRsvp ? locale || siteLang(settings) : 'en');
38
+ const lang = locale || siteLang(settings);
39
+ // a room booking words a few things its own way ("Your booking", not "Your RSVP")
40
+ const bw = bookingStrings(lang).page;
41
+ const w = isRsvp ? rsvpStrings(lang) : { ...rsvpStrings(lang), ...bw };
36
42
  const event = rawEvent && localizeDoc(rawEvent, locale || undefined);
37
- const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking';
43
+ const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || bw.untitled;
38
44
  const location = event ? locationLine(event.location) : resource?.name ?? '';
39
- const url = origin + Astro.url.pathname;
45
+ // the address the visitor sees (/es/… is rewritten to this route)
46
+ const url = origin + localePath(Astro.url.pathname, locale);
40
47
 
41
48
  if (Astro.url.searchParams.get('ics')) {
42
- return new Response(bookingIcs(booking, title, location, url, new URL(origin).host), {
49
+ return new Response(bookingIcs(booking, title, location, url, new URL(origin).host, 'REQUEST', { lang }), {
43
50
  headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'inline; filename="booking.ics"', 'Cache-Control': 'private, no-store' },
44
51
  });
45
52
  }
@@ -56,7 +63,7 @@ if (Astro.request.method === 'POST') {
56
63
  }
57
64
  }
58
65
  const tz = event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
59
- const when = whenText(current, tz, isRsvp && isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
66
+ const when = whenText(current, tz, isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
60
67
  const active = current.status === 'pending' || current.status === 'confirmed';
61
68
  const upcoming = current.end > Date.now();
62
69
  const can = guestChanges(current, link);
@@ -65,11 +72,12 @@ const moved = Astro.url.searchParams.has('moved');
65
72
  const heading = current.status === 'cancelled' ? w.cancelledTitle
66
73
  : current.status === 'declined' ? w.notAvailable
67
74
  : current.status === 'pending' ? (isNew ? w.requestSent : w.awaitingApproval)
68
- : moved ? 'Booking moved' : isNew ? w.booked : w.yours;
75
+ : moved ? bw.moved : isNew ? w.booked : w.yours;
69
76
  const lede = current.status === 'pending' ? fill(w.emailWhenApproved, { email: current.email })
70
77
  : current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || fill(w.confirmationOnItsWay, { email: current.email }))
71
- : current.status === 'declined' ? (isRsvp ? w.declined : 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.')
78
+ : current.status === 'declined' ? w.declined
72
79
  : w.cancelledText;
80
+ const cutoff = fill(bw.cutoff, { hours: String(can.cutoffHours) });
73
81
  const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchParams({ action: 'TEMPLATE', text: title, dates: `${new Date(current.start).toISOString().replace(/[-:]|\.\d{3}/g, '')}/${new Date(current.end).toISOString().replace(/[-:]|\.\d{3}/g, '')}`, details: url, location })}`;
74
82
  ---
75
83
 
@@ -104,17 +112,17 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
104
112
  </details>
105
113
  )}
106
114
  {resource && link && can.reschedule && (
107
- <a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>Reschedule</a>
115
+ <a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>{bw.reschedule}</a>
108
116
  )}
109
117
  {can.cancel && (
110
- <form method="post" onsubmit={`return confirm(${JSON.stringify(isRsvp ? w.cancelConfirm : 'Cancel this booking?')})`}>
118
+ <form method="post" onsubmit={`return confirm(${JSON.stringify(w.cancelConfirm)})`}>
111
119
  <input type="hidden" name="op" value="cancel" />
112
- <button class="pc-manage-btn is-danger" type="submit">{isRsvp ? w.cancel : 'Cancel booking'}</button>
120
+ <button class="pc-manage-btn is-danger" type="submit">{w.cancel}</button>
113
121
  </form>
114
122
  )}
115
123
  </div>
116
124
  )}
117
- {can.closed && <p class="pc-manage-note">Changes close {can.cutoffHours} hours before the start. Please contact us to change or cancel.</p>}
125
+ {can.closed && <p class="pc-manage-note">{cutoff}</p>}
118
126
  <p class="pc-bk-foot">
119
127
  {event ? <a href={localePath(eventUrl(event.slug), locale)}>{fill(w.backTo, { title: event.title })}</a> : resource ? <a href={resourceUrl(resource.slug)}>{fill(w.backTo, { title: resource.name })}</a> : null}
120
128
  </p>
@@ -48,7 +48,7 @@ const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-
48
48
  if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found' });
49
49
  const origin = siteOrigin(settings, Astro.url);
50
50
  // RSVP: "Save my seat" posts back here and lands on the manage page, in the
51
- // visitor's language
51
+ // visitor's language (its emails speak it too)
52
52
  let rsvpError = '';
53
53
  let formValues: Record<string,string> = {};
54
54
  let coreValues = { name: '', email: '', seats: '1' };
@@ -63,7 +63,7 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
63
63
  requestId = await submissionGuard(repo, form, Astro.clientAddress, `event:${event.id}`);
64
64
  formValues = readAnswers(form, definition);
65
65
  coreValues = { name: String(form.get('name') || '').slice(0,120), email: String(form.get('email') || '').slice(0,200), seats: String(form.get('seats') || '1') };
66
- const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
66
+ const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) }, locale);
67
67
  return Astro.redirect(`${localePath(manageUrl(booking.manageToken), locale)}?new=1`, 303);
68
68
  } catch (err) {
69
69
  rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings)).saveError;
@@ -24,6 +24,9 @@ export type { LinkValue, EventItem, PostItem, PostAuthor, BookingItem } from '..
24
24
  export type { SiteChrome, ChromeLink } from '../lib/site-chrome';
25
25
  // core's chrome labels for any language (a template already gets them as site.words)
26
26
  export { siteStrings, type SiteStrings } from '../lib/site-words';
27
+ // the built-in event page's words and repeat line ("Weekly on Sunday"), for templates/event.astro
28
+ export { eventStrings, type EventStrings } from '../lib/calendar/event-words';
29
+ export { describeRepeat } from '../lib/calendar/rrule';
27
30
  import type { SiteChrome } from '../lib/site-chrome';
28
31
  export interface ChromeProps { site: SiteChrome }
29
32