@acms-subir/core 0.3.35 → 0.3.37

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 (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/docs/mobile.md +7 -4
  3. package/docs/theme-authoring.md +5 -4
  4. package/integration.mjs +3 -1
  5. package/mobile-template/client/AGENTS.md +2 -1
  6. package/mobile-template/client/README.md +1 -1
  7. package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
  8. package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
  9. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +274 -0
  10. package/mobile-template/recipes/events/recipe.json +6 -3
  11. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +274 -0
  12. package/mobile-template/recipes/rsvp/recipe.json +15 -0
  13. package/package.json +1 -1
  14. package/src/components/EventPage.astro +14 -8
  15. package/src/layouts/Site.astro +1 -0
  16. package/src/lib/cache/policy.ts +2 -2
  17. package/src/lib/calendar/rsvp-words.ts +121 -0
  18. package/src/lib/legal/app-privacy.ts +5 -3
  19. package/src/lib/legal/text-en.ts +1 -0
  20. package/src/lib/legal/text-es.ts +1 -0
  21. package/src/lib/mobile/bookings.ts +17 -11
  22. package/src/lib/mobile/forms.ts +21 -11
  23. package/src/lib/mobile/manage.ts +2 -0
  24. package/src/lib/mobile/openapi.ts +8 -3
  25. package/src/lib/mobile/rsvp.ts +73 -0
  26. package/src/lib/mobile/schemas.ts +9 -1
  27. package/src/lib/mobile/site.ts +3 -1
  28. package/src/lib/schema.ts +2 -1
  29. package/src/lib/site-chrome.ts +8 -0
  30. package/src/pages/admin/calendar/bookings.astro +3 -1
  31. package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
  32. package/src/pages/book/manage/[token].astro +35 -24
  33. package/src/pages/events/[slug].astro +7 -4
  34. package/src/theme-sdk/index.ts +2 -0
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import type { SavedForm } from '../forms/schema';
3
+ import type { FormDefinition, SavedForm } from '../forms/schema';
4
4
  import { collectResponse, formVersion } from '../forms/service';
5
5
  import { ApiError, readJson } from '../api';
6
6
  import { sha256Hex } from '../api-keys';
@@ -24,14 +24,11 @@ async function publishedForm(repo: ContentRepository, id: string): Promise<Saved
24
24
  return form;
25
25
  }
26
26
 
27
- export async function getForm(repo: ContentRepository, url: URL, id: string) {
28
- z.object({}).strict().parse(queryParams(url));
29
- await requireAccess(repo, 'forms');
30
- const form = await publishedForm(repo, id);
31
- const d = form.definition;
27
+ /** A form's questions for apps; `version` is the one collectResponse accepts (also for event RSVPs). */
28
+ export async function formJson(d: FormDefinition) {
32
29
  return {
33
- id: form.id, version: await formVersion(d),
34
- title: d.title || form.name, intro: d.intro, submitLabel: d.submitLabel || 'Submit', confirmation: d.confirmation,
30
+ version: await formVersion(d),
31
+ title: d.title, intro: d.intro, submitLabel: d.submitLabel, confirmation: d.confirmation,
35
32
  fields: d.fields.map((f) => ({
36
33
  id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
37
34
  placeholder: f.placeholder ?? '', width: f.width ?? 'full', allowOther: f.allowOther ?? false,
@@ -39,9 +36,22 @@ export async function getForm(repo: ContentRepository, url: URL, id: string) {
39
36
  };
40
37
  }
41
38
 
39
+ export async function getForm(repo: ContentRepository, url: URL, id: string) {
40
+ z.object({}).strict().parse(queryParams(url));
41
+ await requireAccess(repo, 'forms');
42
+ const form = await publishedForm(repo, id);
43
+ const d = form.definition;
44
+ return { id: form.id, ...await formJson(d), title: d.title || form.name, submitLabel: d.submitLabel || 'Submit' };
45
+ }
46
+
47
+ // field id → answer; checkbox groups send a list. "Other" answers send the typed text.
48
+ export const appAnswers = z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)]));
49
+ /** lists become the stored newline-joined form */
50
+ export const joinAnswers = (answers: z.infer<typeof appAnswers>): Record<string, string> =>
51
+ Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => x.trim()).filter(Boolean).join('\n') : v]));
52
+
42
53
  export const formResponseInput = z.object({
43
- // field id → answer; checkbox groups send a list. "Other" answers send the typed text.
44
- answers: z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)])).refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
54
+ answers: appAnswers.refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
45
55
  // the version from GET /forms/{id}; a changed form answers 409 instead of saving
46
56
  version: z.string().max(100).optional(),
47
57
  // spam trap: apps leave it out or empty
@@ -57,7 +67,7 @@ export async function submitFormResponse(c: PublicContext) {
57
67
  if (body.website) throw new ApiError(400, 'Could not submit this form.');
58
68
  const form = await publishedForm(c.repo, id);
59
69
  if (!await c.repo.email.rateLimit(`forms:app:${await sha256Hex(`${form.id}:${c.address}`)}`, 10, 60_000)) throw new ApiError(429, 'Too many responses. Please try again in a minute.');
60
- const answers = Object.fromEntries(Object.entries(body.answers).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => x.trim()).filter(Boolean).join('\n') : v]));
70
+ const answers = joinAnswers(body.answers);
61
71
  // the key is the stored response id: a retry of a saved response answers
62
72
  // as before and costs nothing (also after the form changed); the same key
63
73
  // with different answers is a 409
@@ -63,6 +63,8 @@ function view(m: Managed, booking = m.booking) {
63
63
  // the web page for this booking (the same private link the emails carry)
64
64
  manageUrl: origin + manageUrl(booking.manageToken),
65
65
  can,
66
+ // the RSVP form as it was when they sent it (like the web page), else the booking type's message
67
+ confirmation: booking.formResponse?.definition.confirmation || link?.confirmMessage || null,
66
68
  };
67
69
  }
68
70
  const flow = (m: Managed, c: PublicContext) => ({ origin: m.origin, locals: c.locals, waitUntil: waitUntilFrom(c.locals) });
@@ -4,6 +4,7 @@ import type { ContentRepository } from '../repo/types';
4
4
  import { contentQuery, contentDetailQuery } from './content';
5
5
  import { eventsQuery, availabilityQuery } from './calendar';
6
6
  import { bookingInput } from './bookings';
7
+ import { rsvpQuery } from './rsvp';
7
8
  import { pagesQuery } from './pages';
8
9
  import { searchQuery } from './search';
9
10
  import { manageAvailabilityQuery, rescheduleInput } from './manage';
@@ -33,7 +34,10 @@ const errors = Object.fromEntries([400, 404, 405, 429, 500].map(status => [statu
33
34
  ...(status === 429 ? { headers: { 'Retry-After': { description: 'Seconds to wait: 60 for per-minute limits, longer for hourly, daily and lockout limits.', schema: { type: 'string' } } } } : {}),
34
35
  }]));
35
36
  function parameters(query: z.AnyZodObject) {
36
- return Object.entries(query.shape).map(([name, schema]) => ({ name, in: 'query', required: !(schema as z.ZodTypeAny).isOptional(), schema: jsonSchema(schema as z.ZodTypeAny) }));
37
+ return Object.entries(query.shape).map(([name, schema]) => {
38
+ const s = schema as z.ZodTypeAny;
39
+ return { name, in: 'query', required: !s.isOptional(), ...(s.description ? { description: s.description } : {}), schema: jsonSchema(s) };
40
+ });
37
41
  }
38
42
  export async function getOpenApi(_repo: ContentRepository, url: URL) {
39
43
  empty.parse(queryParams(url));
@@ -48,6 +52,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
48
52
  ['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
49
53
  ['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
50
54
  ['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
55
+ ['/events/{slug}/rsvp', 'RSVP status of one date (start = the occurrence start exactly): open or why not, live seats, the questions and their version; never cached; requires calendar', rsvpQuery, ref('RsvpStatus'), false],
51
56
  ['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
52
57
  ['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
53
58
  ['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
@@ -73,8 +78,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
73
78
  }
74
79
  paths['/api/v1/bookings'] = {
75
80
  post: {
76
- summary: 'Create a guest booking or replay an existing request; requires bookings',
77
- description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent.',
81
+ summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
82
+ description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409.',
78
83
  parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
79
84
  requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
80
85
  responses: { ...errors,
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { CalEvent } from '../schema';
4
+ import { ApiError } from '../api';
5
+ import { answerFields } from '../forms/schema';
6
+ import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
7
+ import { seatsTaken } from '../booking/service';
8
+ import { localizeDoc } from '../translations';
9
+ import { siteLang } from '../dates';
10
+ import { rsvpStrings } from '../calendar/rsvp-words';
11
+ import { queryParams } from './http';
12
+ import { requireCalendar } from './calendar';
13
+ import { formJson } from './forms';
14
+ import { localeQuery, mobileLocale } from './locale';
15
+
16
+ // RSVP status of one date of an event, for apps: whether it takes RSVPs now,
17
+ // live seats and the questions with their version. The same rules as the
18
+ // event's web page: open until the date ends, no deadline or waitlist.
19
+ // Never cached; seats change with every RSVP. POST /bookings saves one.
20
+
21
+ /** a public event (slug or id) and its occurrence starting exactly at `start` */
22
+ export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
23
+ const event = await repo.getEventBySlug(ref) ?? await repo.getEvent(ref);
24
+ const calendars = new Map((await repo.listCalendars()).map(c => [c.id, c]));
25
+ if (!event || !eventIsPublic(event, calendars)) throw new ApiError(404, 'Event not found.');
26
+ const settings = await repo.getSettings();
27
+ const at = Date.parse(start);
28
+ const occurrence = expandEvents([event], settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: at, windowEndMs: at + 1 }).find(o => o.start === at);
29
+ if (!occurrence) throw new ApiError(400, 'Start must match an event occurrence exactly.');
30
+ return occurrence;
31
+ }
32
+ /** the RSVP form when it asks something; apps must then send its version */
33
+ export const rsvpQuestions = (e: CalEvent) => e.rsvp.form && answerFields(e.rsvp.form).length ? e.rsvp.form : null;
34
+ /** why this date takes no RSVPs (seats aside), or null */
35
+ export function rsvpClosed(o: Occurrence, now = Date.now()) {
36
+ if (!o.event.rsvp.enabled) return 'disabled' as const;
37
+ if (o.cancelled) return 'cancelled' as const;
38
+ if (o.end <= now) return 'ended' as const;
39
+ return null;
40
+ }
41
+
42
+ export const rsvpQuery = z.object({
43
+ start: z.string().datetime({ offset: true }).describe('The occurrence’s start exactly as GET /events returns it, a UTC ISO date-time such as 2026-10-05T14:00:00.000Z. A start that isn’t one of the event’s dates answers 400.'),
44
+ locale: localeQuery,
45
+ }).strict();
46
+ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: string) {
47
+ requireCalendar();
48
+ const q = rsvpQuery.parse(queryParams(url));
49
+ const locale = mobileLocale(q.locale);
50
+ const o = await rsvpOccurrence(repo, slug, q.start);
51
+ const { rsvp } = o.event;
52
+ // pending and confirmed RSVPs hold seats, as in the capacity check
53
+ const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
54
+ const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
55
+ const closed = rsvpClosed(o);
56
+ const reason = closed ?? (left === 0 ? 'full' as const : null);
57
+ const form = rsvpQuestions(o.event);
58
+ const title = localizeDoc(o.event, locale ?? undefined).title;
59
+ // the built-in button speaks the app's language (the site's own without languages set up)
60
+ let submitLabel = form?.submitLabel ?? '';
61
+ if (form && !submitLabel) {
62
+ const words = rsvpStrings(locale ?? siteLang(await repo.getSettings()));
63
+ submitLabel = rsvp.autoConfirm ? words.confirmSeat : words.requestSeat;
64
+ }
65
+ return {
66
+ event: { id: o.event.id, slug: o.event.slug, title },
67
+ start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
68
+ open: !reason, reason, going, left, capacity: rsvp.capacity,
69
+ // seats one RSVP may take right now
70
+ maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
71
+ form: form ? { ...await formJson(form), submitLabel } : null,
72
+ };
73
+ }
@@ -8,7 +8,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
8
8
  export const Site = z.object({
9
9
  name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
10
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
- features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
11
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
12
12
  legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
13
13
  });
14
14
  export const Theme = z.object({
@@ -68,6 +68,12 @@ export const Form = z.object({
68
68
  })),
69
69
  });
70
70
  export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
71
+ export const RsvpStatus = z.object({
72
+ event: z.object({ id: str, slug: str, title: str }), start: date, end: date,
73
+ open: bool, reason: z.enum(['disabled', 'ended', 'cancelled', 'full']).nullable(),
74
+ going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
75
+ form: Form.omit({ id: true }).nullable(),
76
+ });
71
77
  const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
72
78
  export const ManagedBooking = z.object({
73
79
  booking: z.object({
@@ -79,6 +85,8 @@ export const ManagedBooking = z.object({
79
85
  link: z.object({ slug: str, name: str, mode: z.enum(['slots', 'range', 'day']), lengths: z.array(z.number()) }).nullable(),
80
86
  event: z.object({ slug: str, title: str, url: str }).nullable(),
81
87
  manageUrl: str, can: GuestChanges,
88
+ // what the web page says once it's confirmed: the RSVP form's or booking type's message
89
+ confirmation: str.nullable(),
82
90
  });
83
91
  export const GuestCodeSent = z.object({ sent: z.literal(true), expiresInMinutes: z.number().int() });
84
92
  export const GuestToken = z.object({ accessToken: str, tokenType: z.literal('Bearer'), email: str, idleDays: z.number().int() });
@@ -37,7 +37,9 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
37
37
  features: {
38
38
  // redirectFrom: old addresses the website 301s to url, so apps can follow them too,
39
39
  // minus any another collection now lives at (the website serves that one first)
40
- blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin), redirectFrom: legacyBases(key, c).filter(from => !bases.has(from)).map(from => safeUrl(from, origin)) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled,
40
+ blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin), redirectFrom: legacyBases(key, c).filter(from => !bases.has(from)).map(from => safeUrl(from, origin)) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(),
41
+ // RSVPs from apps (GET /events/{slug}/rsvp, POST /bookings) come with the calendar
42
+ rsvp: CALENDAR.enabled, search: SEARCH.enabled,
41
43
  // Admin → Mobile app → App access
42
44
  bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
43
45
  forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
package/src/lib/schema.ts CHANGED
@@ -672,7 +672,8 @@ export const Booking = z.object({
672
672
  formResponse: FormResponse.optional(),
673
673
  notes: z.string().default(''), // staff-only
674
674
  manageToken: z.string().default(''),
675
- source: z.enum(['web', 'admin', 'api', 'agent']).default('web'),
675
+ // web: the website; app: the public app API (/api/v1/bookings)
676
+ source: z.enum(['web', 'app', 'admin', 'api', 'agent']).default('web'),
676
677
  reminderSentAt: z.number().nullable().default(null),
677
678
  notificationVersion: z.number().default(0),
678
679
  createdAt: z.number(),
@@ -8,6 +8,12 @@
8
8
  // [data-scheme-toggle] flips light/dark (when site.schemeToggle is true)
9
9
  // [data-menu-open] opens the phone menu sheet (built from site.links)
10
10
  // [data-live-header] (hidden) becomes the "Live" link during a stream
11
+ //
12
+ // `words` are core's own labels in the page's language (skip link, menu and
13
+ // dark-mode buttons, landmark and footer labels), so a custom header labels
14
+ // its controls the same way the built-in one does.
15
+ import type { SiteStrings } from './site-words';
16
+
11
17
  export interface ChromeLink {
12
18
  label: string;
13
19
  url: string;
@@ -32,4 +38,6 @@ export interface SiteChrome {
32
38
  liveHeader: boolean;
33
39
  headerStyle: string; // theme token `header`
34
40
  footerStyle: string; // theme token `footer`
41
+ lang: string; // the page's language, as on <html lang>
42
+ words: SiteStrings;
35
43
  }
@@ -185,6 +185,8 @@ const emailNotice = !emailConfig.config.enabled || emailConfig.config.provider =
185
185
  : emailStatus.linkError;
186
186
  const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
187
187
  const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
188
+ // where it was made: the website, an app (/api/v1/bookings), staff, an API key or the booking agent
189
+ const SOURCES: Record<Booking['source'], string> = { web: 'Website', app: 'App', admin: 'Staff', api: 'API', agent: 'Agent' };
188
190
  const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
189
191
  ? `${f.day.format(b.start)} – ${f.day.format(b.end - 1)}`
190
192
  : b.end - b.start >= 86_400_000 - 3_600_000 ? 'All day' : `${f.time.format(b.start)} – ${f.time.format(b.end)}`);
@@ -327,7 +329,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
327
329
  <div><dt>{ev ? 'Event' : 'Where'}</dt><dd>{ev ? <a href={eventUrl(ev.slug)} target="_blank" rel="noopener">{ev.title}</a> : r?.name ?? '—'}{l ? ` · ${l.name}` : ''}</dd></div>
328
330
  {b.seats > 1 && <div><dt>Seats</dt><dd>{b.seats}</dd></div>}
329
331
  {Object.entries(b.answers).map(([k, v]) => <div><dt>{k}</dt><dd>{v}</dd></div>)}
330
- <div><dt>Booked</dt><dd>{f.day.format(b.createdAt)} · {b.source}</dd></div>
332
+ <div><dt>Booked</dt><dd>{f.day.format(b.createdAt)} · {SOURCES[b.source] ?? b.source}</dd></div>
331
333
  </dl>
332
334
  <form method="post" class="bk-notes">
333
335
  <input type="hidden" name="op" value="notes" /><input type="hidden" name="id" value={b.id} />
@@ -0,0 +1,8 @@
1
+ import { getRsvpStatus } from '../../../../../lib/mobile/rsvp';
2
+ import { readRoute } from '../../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
5
+ // no-store: live seats; a cached "3 left" would invite RSVPs that fail
6
+ export const GET = readRoute(getRsvpStatus, false);
7
+ export const HEAD = GET;
8
+ export const ALL = () => methodNotAllowed();
@@ -8,21 +8,32 @@ import { resourceZone } from '../../../lib/booking/availability';
8
8
  import { eventTimeZone, locationLine } from '../../../lib/calendar/events';
9
9
  import { siteOrigin } from '../../../lib/seo';
10
10
  import { waitUntilFrom } from '../../../lib/webhooks';
11
+ import { I18N, localePath, requestLocale } from '../../../lib/i18n';
12
+ import { siteLang } from '../../../lib/dates';
13
+ import { isExtraLocale, localizeDoc } from '../../../lib/translations';
14
+ import { rsvpStrings } from '../../../lib/calendar/rsvp-words';
15
+ import { fill } from '../../../lib/live/words';
11
16
 
12
17
  // The booker's own page, reached from the confirmation and every email: see
13
18
  // the status, add it to a calendar, move it or cancel it. The token in the
14
- // URL is the only credential — it's long and random, and never listed.
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.
15
22
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
16
23
  const repo = await getRepo(Astro.locals);
17
24
  const booking = await repo.getBookingByToken(Astro.params.token ?? '');
18
25
  if (!booking || (!bookingsEnabled() && booking.kind !== 'rsvp')) return new Response(null, { status: 404, statusText: 'Not found' });
19
26
  const settings = await repo.getSettings();
20
27
  const origin = siteOrigin(settings, Astro.url);
21
- const [resource, link, event] = await Promise.all([
28
+ const [resource, link, rawEvent] = await Promise.all([
22
29
  booking.resourceId ? repo.getResource(booking.resourceId) : Promise.resolve(null),
23
30
  booking.linkId ? repo.getBookingLink(booking.linkId) : Promise.resolve(null),
24
31
  booking.eventId ? repo.getEvent(booking.eventId) : Promise.resolve(null),
25
32
  ]);
33
+ const isRsvp = booking.kind === 'rsvp';
34
+ const locale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
35
+ const w = rsvpStrings(isRsvp ? locale || siteLang(settings) : 'en');
36
+ const event = rawEvent && localizeDoc(rawEvent, locale || undefined);
26
37
  const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking';
27
38
  const location = event ? locationLine(event.location) : resource?.name ?? '';
28
39
  const url = origin + Astro.url.pathname;
@@ -40,25 +51,25 @@ if (Astro.request.method === 'POST') {
40
51
  try {
41
52
  current = await guestCancel(repo, booking, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
42
53
  } catch (err) {
43
- error = err instanceof BookingError ? err.message : 'Couldn’t cancel — please try again.';
54
+ error = err instanceof BookingError ? err.message : w.cancelError;
44
55
  }
45
56
  }
46
57
  }
47
58
  const tz = event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
48
- const when = whenText(current, tz, settings, link?.mode === 'day' || event?.allDay);
59
+ const when = whenText(current, tz, isRsvp && isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
49
60
  const active = current.status === 'pending' || current.status === 'confirmed';
50
61
  const upcoming = current.end > Date.now();
51
62
  const can = guestChanges(current, link);
52
63
  const isNew = Astro.url.searchParams.has('new');
53
64
  const moved = Astro.url.searchParams.has('moved');
54
- const heading = current.status === 'cancelled' ? 'Booking cancelled'
55
- : current.status === 'declined' ? 'Not available'
56
- : current.status === 'pending' ? (isNew ? 'Request sent' : 'Waiting for approval')
57
- : moved ? 'Booking moved' : isNew ? 'You’re booked' : 'Your booking';
58
- const lede = current.status === 'pending' ? `We’ll email ${current.email} once it’s approved.`
59
- : current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || `A confirmation is on its way to ${current.email}.`)
60
- : current.status === 'declined' ? 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.'
61
- : 'This booking is cancelled.';
65
+ const heading = current.status === 'cancelled' ? w.cancelledTitle
66
+ : current.status === 'declined' ? w.notAvailable
67
+ : current.status === 'pending' ? (isNew ? w.requestSent : w.awaitingApproval)
68
+ : moved ? 'Booking moved' : isNew ? w.booked : w.yours;
69
+ const lede = current.status === 'pending' ? fill(w.emailWhenApproved, { email: current.email })
70
+ : 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.')
72
+ : w.cancelledText;
62
73
  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 })}`;
63
74
  ---
64
75
 
@@ -73,22 +84,22 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
73
84
  <p class="pc-manage-lede">{lede}</p>
74
85
  {error && <p class="pc-bk-error">{error}</p>}
75
86
  <dl class="pc-manage-card">
76
- <div><dt>What</dt><dd>{title}</dd></div>
77
- <div><dt>When</dt><dd>{when}</dd></div>
78
- {location && <div><dt>Where</dt><dd>{location}</dd></div>}
79
- {current.seats > 1 && <div><dt>Seats</dt><dd>{current.seats}</dd></div>}
80
- <div><dt>Status</dt><dd class={`pc-manage-status is-${current.status}`}>{current.status[0].toUpperCase() + current.status.slice(1)}</dd></div>
81
- <div><dt>Reference</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
87
+ <div><dt>{w.what}</dt><dd>{title}</dd></div>
88
+ <div><dt>{w.when}</dt><dd>{when}</dd></div>
89
+ {location && <div><dt>{w.where}</dt><dd>{location}</dd></div>}
90
+ {current.seats > 1 && <div><dt>{w.seatsField}</dt><dd>{current.seats}</dd></div>}
91
+ <div><dt>{w.status}</dt><dd class={`pc-manage-status is-${current.status}`}>{w.statuses[current.status]}</dd></div>
92
+ <div><dt>{w.reference}</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
82
93
  </dl>
83
94
  {active && upcoming && (
84
95
  <div class="pc-manage-actions">
85
96
  {current.status === 'confirmed' && (
86
97
  <details class="pc-ev-add">
87
- <summary>Add to calendar</summary>
98
+ <summary>{w.addToCalendar}</summary>
88
99
  <div class="pc-ev-add-menu">
89
100
  <a href={googleUrl} target="_blank" rel="noopener">Google Calendar</a>
90
101
  <a href="?ics=1">Apple Calendar</a>
91
- <a href="?ics=1" download="booking.ics">Download .ics</a>
102
+ <a href="?ics=1" download="booking.ics">{w.downloadIcs}</a>
92
103
  </div>
93
104
  </details>
94
105
  )}
@@ -96,17 +107,17 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
96
107
  <a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>Reschedule</a>
97
108
  )}
98
109
  {can.cancel && (
99
- <form method="post" onsubmit="return confirm('Cancel this booking?')">
110
+ <form method="post" onsubmit={`return confirm(${JSON.stringify(isRsvp ? w.cancelConfirm : 'Cancel this booking?')})`}>
100
111
  <input type="hidden" name="op" value="cancel" />
101
- <button class="pc-manage-btn is-danger" type="submit">Cancel {current.kind === 'rsvp' ? 'RSVP' : 'booking'}</button>
112
+ <button class="pc-manage-btn is-danger" type="submit">{isRsvp ? w.cancel : 'Cancel booking'}</button>
102
113
  </form>
103
114
  )}
104
115
  </div>
105
116
  )}
106
117
  {can.closed && <p class="pc-manage-note">Changes close {can.cutoffHours} hours before the start. Please contact us to change or cancel.</p>}
107
118
  <p class="pc-bk-foot">
108
- {event ? <a href={eventUrl(event.slug)}>Back to {event.title}</a> : resource ? <a href={resourceUrl(resource.slug)}>Back to {resource.name}</a> : null}
119
+ {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}
109
120
  </p>
110
- <p class="pc-manage-note">Keep this link — anyone with it can change this booking.</p>
121
+ <p class="pc-manage-note">{w.keepLink}</p>
111
122
  </div>
112
123
  </Site>
@@ -5,7 +5,9 @@ import { formVersion } from '../../lib/forms/service';
5
5
  import { renderFields, hiddenFields } from '../../lib/forms/render';
6
6
  import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
7
7
  import { submitEventForm, prepareLegacyEventForm } from '../../lib/forms/events';
8
- import { requestLocale } from '../../lib/i18n';
8
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
9
+ import { siteLang } from '../../lib/dates';
10
+ import { rsvpStrings } from '../../lib/calendar/rsvp-words';
9
11
  import { localizeDoc, isExtraLocale } from '../../lib/translations';
10
12
  import Site from '../../layouts/Site.astro';
11
13
  import EventPage from '../../components/EventPage.astro';
@@ -45,7 +47,8 @@ const on = Astro.url.searchParams.get('on') ?? undefined;
45
47
  const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-\d{2}$/.test(on) ? on : undefined);
46
48
  if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found' });
47
49
  const origin = siteOrigin(settings, Astro.url);
48
- // RSVP: "Save my seat" posts back here and lands on the manage page
50
+ // RSVP: "Save my seat" posts back here and lands on the manage page, in the
51
+ // visitor's language
49
52
  let rsvpError = '';
50
53
  let formValues: Record<string,string> = {};
51
54
  let coreValues = { name: '', email: '', seats: '1' };
@@ -61,9 +64,9 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
61
64
  formValues = readAnswers(form, definition);
62
65
  coreValues = { name: String(form.get('name') || '').slice(0,120), email: String(form.get('email') || '').slice(0,200), seats: String(form.get('seats') || '1') };
63
66
  const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
64
- return Astro.redirect(`${manageUrl(booking.manageToken)}?new=1`, 303);
67
+ return Astro.redirect(`${localePath(manageUrl(booking.manageToken), locale)}?new=1`, 303);
65
68
  } catch (err) {
66
- rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : 'Couldn’t save your RSVP. Please try again.';
69
+ rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings)).saveError;
67
70
  }
68
71
  }
69
72
  const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
@@ -22,6 +22,8 @@ export interface SectionProps<D = Record<string, unknown>> { data: D; variant: s
22
22
  export type { LinkValue, EventItem, PostItem, PostAuthor, BookingItem } from '../lib/sections';
23
23
  /** Props for templates/header.astro and templates/footer.astro. */
24
24
  export type { SiteChrome, ChromeLink } from '../lib/site-chrome';
25
+ // core's chrome labels for any language (a template already gets them as site.words)
26
+ export { siteStrings, type SiteStrings } from '../lib/site-words';
25
27
  import type { SiteChrome } from '../lib/site-chrome';
26
28
  export interface ChromeProps { site: SiteChrome }
27
29