@acms-subir/core 0.3.61 → 0.3.63

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 (40) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/cli/migrations.generated.mjs +8 -0
  3. package/docs/calendar.md +57 -1
  4. package/docs/mobile.md +7 -1
  5. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
  6. package/mobile-template/recipes/booking/files/app/book/details.tsx +34 -25
  7. package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
  8. package/mobile-template/recipes/booking/files/app/book/manage.tsx +82 -0
  9. package/mobile-template/recipes/booking/recipe.json +6 -3
  10. package/package.json +1 -1
  11. package/src/components/MobileApp.tsx +11 -6
  12. package/src/components/booking/BookingFlow.astro +16 -8
  13. package/src/lib/booking/public.ts +40 -0
  14. package/src/lib/booking/service.ts +7 -3
  15. package/src/lib/booking/view.ts +6 -0
  16. package/src/lib/calendar/data.ts +25 -31
  17. package/src/lib/calendar/events.ts +12 -2
  18. package/src/lib/calendar/google/map.ts +1 -1
  19. package/src/lib/display/board.ts +5 -3
  20. package/src/lib/editor/schedule.ts +2 -0
  21. package/src/lib/mobile/calendar.ts +41 -14
  22. package/src/lib/mobile/guest.ts +4 -2
  23. package/src/lib/mobile/manage.ts +8 -4
  24. package/src/lib/mobile/openapi.ts +4 -4
  25. package/src/lib/mobile/schemas.ts +6 -1
  26. package/src/lib/mobile/search.ts +7 -6
  27. package/src/lib/push/api.ts +4 -2
  28. package/src/lib/push/service.ts +26 -15
  29. package/src/lib/repo/migrations.ts +8 -0
  30. package/src/lib/repo/sql.ts +10 -5
  31. package/src/lib/schema.ts +21 -6
  32. package/src/lib/sections.ts +4 -2
  33. package/src/pages/admin/calendar/resources/link.astro +14 -1
  34. package/src/pages/api/search.ts +3 -3
  35. package/src/pages/book/[resource]/[link].astro +10 -5
  36. package/src/pages/book/[resource]/index.astro +14 -8
  37. package/src/pages/book/index.astro +8 -3
  38. package/src/pages/book/manage/[token].astro +5 -3
  39. package/src/pages/embed/book/[resource]/[link].astro +11 -3
  40. package/src/pages/search.astro +2 -2
@@ -136,7 +136,8 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
136
136
  calendarEnabled: CALENDAR.enabled,
137
137
  // "we're live" needs live video on (Integrations → Live video)
138
138
  liveMode: settings.live.mode,
139
- calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
139
+ // a rule can name an unlisted calendar too (a catch-all covers only public ones)
140
+ calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color, visibility: c.visibility })),
140
141
  // suggestions for the event-change rule's "Only events tagged"
141
142
  tags: tags.map((t) => t.name),
142
143
  expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
@@ -182,7 +183,8 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
182
183
  }
183
184
  case 'saveAuto': {
184
185
  const collections = new Set(Object.keys(CONTENT.collections));
185
- const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
186
+ // private calendars never notify: a rule can't name one
187
+ const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility !== 'private').map((c) => c.id));
186
188
  const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
187
189
  const known = await knownTopics(repo);
188
190
  const auto = {
@@ -5,7 +5,7 @@ import type { ContentRepository } from '../repo/types';
5
5
  import type { CalEvent, Calendar, Post, Settings } from '../schema';
6
6
  import { CONTENT, collectionUrl } from '../content';
7
7
  import { CALENDAR, eventUrl } from '../calendar/config';
8
- import { cutsAt, eventIsListed, eventTimeZone, expandEvents, findOccurrence, partOf, type Occurrence } from '../calendar/events';
8
+ import { cutsAt, eventIsListed, eventIsPublic, eventTimeZone, expandEvents, findOccurrence, isGoogleBooking, partOf, type Occurrence } from '../calendar/events';
9
9
  import { describeRepeat } from '../calendar/rrule';
10
10
  import { tzParts } from '../calendar/ics';
11
11
  import { I18N, stripLocale } from '../i18n';
@@ -236,22 +236,32 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
236
236
 
237
237
  // ---- automatic notices ---------------------------------------------------------
238
238
 
239
- const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
240
- const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
241
- // a change rule: its calendars, and one of its tags when it lists some (any case)
242
- function inChangeRule(rule: ChangeRule, e: CalEvent) {
243
- const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
244
- return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
239
+ const calendarMap = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
240
+ // Whether a rule (new event, starting soon, or a change rule) covers a
241
+ // published event: one in its calendars ('*' = any) with, for a change rule
242
+ // that lists tags, one of them (any case). A rule that names the event (one of
243
+ // its calendars, or tags it has) covers it in any calendar that isn't private,
244
+ // so a "Class" rule reaches classes in an unlisted rooms calendar; a catch-all
245
+ // ('*' and no tags) stays with public calendars. Private calendars and Google
246
+ // booking-page appointments never notify.
247
+ function covers(rule: { calendars: string[]; tags?: string[] }, e: CalEvent, calendars: Map<string, Calendar>) {
248
+ const tags = new Set((rule.tags ?? []).map((t) => t.toLowerCase()));
249
+ const named = e.calendarIds.some((id) => rule.calendars.includes(id));
250
+ if (!named && !rule.calendars.includes('*')) return false;
251
+ if (tags.size && !e.tags.some((t) => tags.has(t.toLowerCase()))) return false;
252
+ if (isGoogleBooking(e)) return false;
253
+ return named || tags.size ? eventIsPublic(e, calendars) : eventIsListed(e, calendars);
245
254
  }
246
255
  // every rule that covers the event adds its topics, or the event's own when it
247
256
  // names none: in rule order, once each ([] = no rule covers it)
248
257
  function changeAudience(rules: ChangeRule[], e: CalEvent, calendars: Map<string, Calendar>) {
249
- return [...new Set(rules.filter((r) => inChangeRule(r, e)).flatMap((r) => (r.topics.length ? r.topics : eventTopics(e, calendars))))];
258
+ return [...new Set(rules.filter((r) => covers(r, e, calendars)).flatMap((r) => (r.topics.length ? r.topics : eventTopics(e, calendars))))];
250
259
  }
251
260
  /**
252
261
  * The topics this event's change notices go to (Admin → Mobile app → When an
253
262
  * event changes), or [] while no rule covers it. Apps show it to promise
254
- * "we'll let you know if it changes" only when it's true.
263
+ * "we'll let you know if it changes" only when it's true. `calendars` holds
264
+ * every calendar (their visibility decides what notifies), not only public ones.
255
265
  */
256
266
  export function changeTopics(e: CalEvent, settings: Settings, calendars: Map<string, Calendar>): string[] {
257
267
  if (!PUSH.enabled || !CALENDAR.enabled) return [];
@@ -409,7 +419,8 @@ export async function queueEventChange(repo: ContentRepository, before: CalEvent
409
419
  const was = eventSnapshot(before);
410
420
  if (JSON.stringify(was) === JSON.stringify(eventSnapshot(after))) return false;
411
421
  const [settings, all] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
412
- if (!settings.push.auto.eventChanges.some((r) => inChangeRule(r, after)) || !eventIsListed(after, listedCalendars(all))) return false;
422
+ const calendars = calendarMap(all);
423
+ if (!settings.push.auto.eventChanges.some((r) => covers(r, after, calendars))) return false;
413
424
  const key = `change:${after.id}`;
414
425
  // a change to a date that starts soon waits only a minute (judged from what people last knew)
415
426
  const row = await repo.push.queuedChange(key);
@@ -506,7 +517,7 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
506
517
  if (!rows.length) return 0;
507
518
  const settings = await repo.getSettings();
508
519
  const auto = settings.push.auto;
509
- const calendars = CALENDAR.enabled ? listedCalendars(await repo.listCalendars()) : new Map<string, Calendar>();
520
+ const calendars = CALENDAR.enabled ? calendarMap(await repo.listCalendars()) : new Map<string, Calendar>();
510
521
  const run = { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) };
511
522
  let sent = 0;
512
523
  for (const row of rows) {
@@ -517,14 +528,14 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
517
528
  if (post && post.status === 'published' && CONTENT.collections[post.collection] && auto.newPost.collections.includes(post.collection)) notice = postNotice(post, settings);
518
529
  } else if (row.kind === 'event' && CALENDAR.enabled) {
519
530
  const e = await repo.getEvent(row.docId);
520
- if (e && !e.cancelled && eventIsListed(e, calendars) && inRule(auto.newEvent.calendars, e)) notice = eventNotice(e, settings, calendars, now);
531
+ if (e && !e.cancelled && covers(auto.newEvent, e, calendars)) notice = eventNotice(e, settings, calendars, now);
521
532
  } else if (row.kind === 'change' && CALENDAR.enabled) {
522
533
  // compared at send time: edits that cancel out, or come too late, send nothing
523
534
  const e = await repo.getEvent(row.docId);
524
535
  const was = readSnapshot(row.payload);
525
536
  // one notice, to everyone the rules that cover it send to
526
537
  const topics = e ? changeAudience(auto.eventChanges, e, calendars) : [];
527
- const change = e && was && topics.length && eventIsListed(e, calendars) ? eventChange(was, e, settings, now) : null;
538
+ const change = e && was && topics.length ? eventChange(was, e, settings, now) : null;
528
539
  if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, topics);
529
540
  }
530
541
  if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
@@ -547,8 +558,8 @@ export async function sendStartingSoon(repo: ContentRepository, opts: PushRunOpt
547
558
  const rule = settings.push.auto.startingSoon;
548
559
  if (!rule.calendars.length) return 0;
549
560
  const [all, events] = await Promise.all([repo.listCalendars(), repo.listPublishedEvents()]);
550
- const calendars = listedCalendars(all);
551
- const chosen = events.filter((e) => !e.cancelled && eventIsListed(e, calendars) && inRule(rule.calendars, e));
561
+ const calendars = calendarMap(all);
562
+ const chosen = events.filter((e) => !e.cancelled && covers(rule, e, calendars));
552
563
  if (!chosen.length) return 0;
553
564
  const lead = rule.minutes * MINUTE;
554
565
  const occurrences = expandEvents(chosen, settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + lead + 1 })
@@ -748,6 +748,13 @@ export const MIGRATIONS: Migration[] = [
748
748
  `DROP TRIGGER content_version_resources_update`,
749
749
  contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'])[2],
750
750
  ] },
751
+ // a booking link's name and description in other languages
752
+ // (BookingLink.translations): public, so a change refreshes cached pages and API answers
753
+ { version: 72, statements: [
754
+ `ALTER TABLE booking_links ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
755
+ `DROP TRIGGER content_version_booking_links_update`,
756
+ contentVersionTriggers('booking_links', [...CONTENT_VERSION_COLUMNS.booking_links, 'translations'])[2],
757
+ ] },
751
758
  ];
752
759
 
753
760
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -756,6 +763,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
756
763
  settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
757
764
  events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'],
758
765
  resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'],
766
+ booking_links: [...CONTENT_VERSION_COLUMNS.booking_links, 'translations'],
759
767
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
760
768
  };
761
769
 
@@ -230,10 +230,15 @@ function rowToLink2(r: Row): BookingLink {
230
230
  hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
231
231
  approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
232
232
  askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
233
- redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
233
+ redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0), translations: linkTranslations(r.translations),
234
234
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
235
235
  });
236
236
  }
237
+ // a booking link's words in other languages; a bad entry (or a broken column) reads as untranslated
238
+ function linkTranslations(raw: unknown): BookingLink['translations'] {
239
+ const parsed = BookingLink.shape.translations.safeParse(json(raw, {}));
240
+ return parsed.success ? parsed.data : {};
241
+ }
237
242
  function rowToBooking(r: Row): Booking {
238
243
  return Booking.parse({
239
244
  id: r.id, kind: r.kind ?? 'booking', resourceId: r.resource_id ?? '', linkId: r.link_id ?? '', eventId: r.event_id ?? '',
@@ -1175,11 +1180,11 @@ export class SqlRepository implements ContentRepository {
1175
1180
  return {
1176
1181
  sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
1177
1182
  buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
1178
- active, ord, created_at, updated_at, approval, approval_rules, change_cutoff) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1183
+ active, ord, created_at, updated_at, approval, approval_rules, change_cutoff, translations) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1179
1184
  args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
1180
1185
  l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
1181
1186
  l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
1182
- l.approval, JSON.stringify(l.approvalRules), l.changeCutoff],
1187
+ l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, JSON.stringify(l.translations)],
1183
1188
  };
1184
1189
  }
1185
1190
  private bookingInsert(b: Booking): SqlStatement {
@@ -1271,10 +1276,10 @@ export class SqlRepository implements ContentRepository {
1271
1276
  await this.driver.run(
1272
1277
  `UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
1273
1278
  buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
1274
- ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ? WHERE id = ?`,
1279
+ ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ?, translations = ? WHERE id = ?`,
1275
1280
  [l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
1276
1281
  l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
1277
- JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, id]
1282
+ JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, JSON.stringify(l.translations), id]
1278
1283
  );
1279
1284
  }
1280
1285
  async deleteBookingLink(id: string) {
package/src/lib/schema.ts CHANGED
@@ -288,7 +288,8 @@ export type Redirect = z.infer<typeof Redirect>;
288
288
  // admin; `feed` calendars mirror an external ICS feed (Google, Apple,
289
289
  // Outlook, any .ics) live, read-only. Visibility decides where a calendar's
290
290
  // events surface: `public` = listed on the calendar page + the all-calendars
291
- // feed; `unlisted` = event pages and its own feed work, but it's not listed;
291
+ // feed; `unlisted` = event pages, its own feed and search work, but it's not
292
+ // listed (push rules cover its events only when they name them);
292
293
  // `private` = admin-only, plus a secret-token feed for staff subscriptions.
293
294
  export const CalendarKind = z.enum(['native', 'feed']);
294
295
  export type CalendarKind = z.infer<typeof CalendarKind>;
@@ -583,6 +584,15 @@ export const DateOverride = z.object({
583
584
  });
584
585
  export type DateOverride = z.infer<typeof DateOverride>;
585
586
 
587
+ // a language a room or booking link has its own words in ('es', 'es-MX')
588
+ const LocaleKey = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/);
589
+ // an entry for `locale` ('es-MX' → 'es' when it has no entry of its own)
590
+ const forLocale = <T>(all: Record<string, T> | undefined, locale?: string | null): T | undefined => {
591
+ if (!locale || !all) return undefined;
592
+ const want = locale.toLowerCase();
593
+ return all[want] ?? all[want.split('-')[0]];
594
+ };
595
+
586
596
  export const Resource = z.object({
587
597
  id: z.string(),
588
598
  slug: z.string(),
@@ -613,7 +623,7 @@ export const Resource = z.object({
613
623
  requestable: z.boolean().default(false),
614
624
  // its name in the site's other languages ({ es: { name: 'Salón de jóvenes' } });
615
625
  // roomName() picks one, else `name`. The slug stays the same in every language.
616
- translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), z.object({ name: z.string().max(120).default('') })).default({}),
626
+ translations: z.record(LocaleKey, z.object({ name: z.string().max(120).default('') })).default({}),
617
627
  // secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
618
628
  feedToken: z.string().default(''),
619
629
  // the saved place this room is in ('' = none)
@@ -627,10 +637,7 @@ export type Resource = z.infer<typeof Resource>;
627
637
  export const roomColor = (r: Pick<Resource, 'color'>) => (/^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '');
628
638
  /** A room's name in `locale` ('es', 'es-MX' → its 'es' name), else its main name. */
629
639
  export function roomName(r: Pick<Resource, 'name'> & { translations?: Resource['translations'] }, locale?: string | null): string {
630
- if (!locale || !r.translations) return r.name;
631
- const want = locale.toLowerCase();
632
- const t = r.translations[want] ?? r.translations[want.split('-')[0]];
633
- return t?.name.trim() || r.name;
640
+ return forLocale(r.translations, locale)?.name.trim() || r.name;
634
641
  }
635
642
 
636
643
  // A saved place (HQ, the park pavilion…): picked in an event's Where field,
@@ -803,11 +810,19 @@ export const BookingLink = z.object({
803
810
  confirmMessage: z.string().default(''),
804
811
  redirectUrl: z.string().default(''),
805
812
  active: z.boolean().default(true),
813
+ // its name and description in the site's other languages ({ es: { name: 'Ensayo' } });
814
+ // linkWords() picks them, each falling back to the main one. The slug stays the same.
815
+ translations: z.record(LocaleKey, z.object({ name: z.string().max(120).default(''), description: z.string().max(2000).default('') })).default({}),
806
816
  order: z.number().default(0),
807
817
  createdAt: z.number(),
808
818
  updatedAt: z.number(),
809
819
  });
810
820
  export type BookingLink = z.infer<typeof BookingLink>;
821
+ /** A booking link's name and description in `locale` ('es-MX' → its 'es' words), each falling back to the main one. */
822
+ export function linkWords(l: Pick<BookingLink, 'name' | 'description'> & { translations?: BookingLink['translations'] }, locale?: string | null): { name: string; description: string } {
823
+ const t = forLocale(l.translations, locale);
824
+ return { name: t?.name.trim() || l.name, description: t?.description.trim() || l.description };
825
+ }
811
826
 
812
827
  // RSVPs only: waitlisted = in line for a full date (takes no seats); offered =
813
828
  // a freed spot held for them until holdUntil (takes its seats); expired = they
@@ -1,7 +1,7 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
2
  import type { z } from 'zod';
3
3
  import type { ContentRepository } from './repo/types';
4
- import { authorSlug, tagFilter, type Settings, type User } from './schema';
4
+ import { authorSlug, linkWords, roomName, tagFilter, type Settings, type User } from './schema';
5
5
  import { htmlToPlaintext } from './plaintext';
6
6
  import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
7
7
  import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
@@ -165,7 +165,9 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
165
165
  const links = (await repo.listBookingLinks()).filter((l) => l.active && l.visibility === 'public' && byId.has(l.resourceId));
166
166
  return links.slice(0, limit).map((l): BookingItem => {
167
167
  const r = byId.get(l.resourceId)!;
168
- return { title: l.name, url: bookingLinkUrl(r.slug, l.slug), description: l.description, resource: r.name, minutes: l.duration };
168
+ // the link and room in the page's language (their main words without one)
169
+ const words = linkWords(l, lang);
170
+ return { title: words.name, url: lp(bookingLinkUrl(r.slug, l.slug)), description: words.description, resource: roomName(r, lang), minutes: l.duration };
169
171
  });
170
172
  }
171
173
 
@@ -11,6 +11,7 @@ import { siteOrigin } from '../../../../lib/seo';
11
11
  import { APPROVAL_LABEL, linkApproval, siteApproval } from '../../../../lib/booking/approval';
12
12
  import { ApprovalRules } from '../../../../lib/schema';
13
13
  import { forbiddenUnless } from '../../../../lib/permissions/access';
14
+ import { extraLocales, localeName } from '../../../../lib/i18n';
14
15
 
15
16
  // One booking link: how people book, how bookings get approved (follow the
16
17
  // site setting, or its own mode + rules), when it's open (within the room or
@@ -69,8 +70,14 @@ if (Astro.request.method === 'POST') {
69
70
  deadlineHours: form.has('deadline') ? int(form.get('deadlineHours'), 1, 24 * 60, 48) : 0,
70
71
  deadlineAction: form.get('deadlineAction') === 'decline' ? 'decline' : 'approve',
71
72
  });
73
+ // its name and description in each of the site's other languages ('' = the main one); languages no longer configured keep theirs
74
+ const translations = { ...link.translations };
75
+ for (const l of extraLocales()) {
76
+ const words = { name: String(form.get('name_' + l) || '').trim().slice(0, 120), description: String(form.get('description_' + l) || '').trim().slice(0, 1000) };
77
+ if (words.name || words.description) translations[l] = words; else delete translations[l];
78
+ }
72
79
  const patch: Partial<BookingLink> = {
73
- name, slug, mode: mode as BookingMode,
80
+ name, slug, mode: mode as BookingMode, translations,
74
81
  description: String(form.get('description') || '').slice(0, 1000),
75
82
  duration,
76
83
  lengths,
@@ -221,8 +228,14 @@ const effective = linkApproval(view, settings).mode;
221
228
  <section class="card card-pad">
222
229
  <div class="card-head"><b>Link</b></div>
223
230
  <label class="field"><span>Name</span><input type="text" name="name" value={view.name} required /></label>
231
+ {extraLocales().map((l) => (
232
+ <label class="field"><span>Name in {localeName(l)} <em class="hint">optional; shown on {localeName(l)} pages, in the app and in emails</em></span><input type="text" name={'name_' + l} lang={l} maxlength="120" value={view.translations[l]?.name ?? ''} placeholder={view.name} /></label>
233
+ ))}
224
234
  <label class="field"><span>URL name</span><input type="text" class="mono" name="slug" value={view.slug} /></label>
225
235
  <label class="field"><span>Description</span><textarea name="description" rows="3" placeholder="What this booking is for">{view.description}</textarea></label>
236
+ {extraLocales().map((l) => (
237
+ <label class="field"><span>Description in {localeName(l)} <em class="hint">optional</em></span><textarea name={'description_' + l} lang={l} rows="3" maxlength="1000" placeholder={view.description}>{view.translations[l]?.description ?? ''}</textarea></label>
238
+ ))}
226
239
  <label class="field">
227
240
  <span>Who can find it</span>
228
241
  <select name="visibility">
@@ -6,7 +6,7 @@ import { collectionUrl, tagUrl } from '../../lib/content';
6
6
  import { localeHomeUrl, normalizePageLocale } from '../../lib/i18n';
7
7
  import { formatDate, siteLocale } from '../../lib/dates';
8
8
  import { CALENDAR, eventUrl } from '../../lib/calendar/config';
9
- import { eventIsPublic, featuredOccurrence, formatOccurrence } from '../../lib/calendar/events';
9
+ import { eventIsSearchable, featuredOccurrence, formatOccurrence } from '../../lib/calendar/events';
10
10
 
11
11
  // Public search API behind the site's search modal. Published-only by
12
12
  // construction — the same contract as rss/sitemap — so it needs no auth and
@@ -42,8 +42,8 @@ export const GET: APIRoute = async ({ locals, url }) => {
42
42
  snippet,
43
43
  locale: p.locale,
44
44
  })),
45
- // private-calendar events never surface publicly
46
- events: (CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsPublic(doc, calMap)) : []).slice(0, LIMITS.events).map(({ doc: e, snippet }) => {
45
+ // the app's rule too: never private calendars or booking-page appointments
46
+ events: (CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsSearchable(doc, calMap)) : []).slice(0, LIMITS.events).map(({ doc: e, snippet }) => {
47
47
  const o = featuredOccurrence(e, settings, now);
48
48
  return {
49
49
  title: e.title,
@@ -7,11 +7,13 @@ import { availabilityContext, createResourceBooking, guestMoveLink, guestResched
7
7
  import { siteOrigin } from '../../../lib/seo';
8
8
  import { waitUntilFrom } from '../../../lib/webhooks';
9
9
  import { localePath, requestLocale } from '../../../lib/i18n';
10
+ import { linkWords, roomName } from '../../../lib/schema';
10
11
 
11
12
  // One booking link: pick → answer → done. POSTs create the booking (or move
12
13
  // 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.
14
+ // in English, but the link and room are named in the page's language (their
15
+ // translations), every step stays under /es/…, and a booking made there keeps
16
+ // the visitor's language for its emails and its own page.
15
17
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
16
18
  const repo = await getRepo(Astro.locals);
17
19
  const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
@@ -64,11 +66,14 @@ if (Astro.request.method === 'POST') {
64
66
  }
65
67
  }
66
68
  const ctx = await availabilityContext(repo, resource, reschedule ? guestMoveLink(link) : link, settings, Date.now(), reschedule?.id);
69
+ // the link and room named in the page's language
70
+ const words = linkWords(link, locale);
71
+ const room = roomName(resource, locale);
67
72
  ---
68
73
 
69
- <Site title={`${link.name} · ${resource.name}`} description={link.description || `Book ${resource.name}.`} noindex={link.visibility === 'unlisted' || resource.visibility === 'unlisted'}>
74
+ <Site title={`${words.name} · ${room}`} description={words.description || `Book ${room}.`} noindex={link.visibility === 'unlisted' || resource.visibility === 'unlisted'}>
70
75
  <div class="pc-bkpage">
71
- <BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={flowUrl} error={error} values={values} reschedule={reschedule} />
72
- <p class="pc-bk-foot"><a href={resourceUrl(resource.slug)}>Other ways to book {resource.name}</a></p>
76
+ <BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={flowUrl} error={error} values={values} reschedule={reschedule} locale={locale} />
77
+ <p class="pc-bk-foot"><a href={localePath(resourceUrl(resource.slug), locale)}>Other ways to book {room}</a></p>
73
78
  </div>
74
79
  </Site>
@@ -5,6 +5,8 @@ import { bookingLinkUrl, bookingsEnabled, CALENDAR } from '../../../lib/calendar
5
5
  import { availabilityContext } from '../../../lib/booking/service';
6
6
  import { describeLink, nextOpenings } from '../../../lib/booking/availability';
7
7
  import { fmt } from '../../../lib/booking/view';
8
+ import { linkWords, roomName } from '../../../lib/schema';
9
+ import { localePath, requestLocale } from '../../../lib/i18n';
8
10
 
9
11
  // A room (or person, or van) and its booking links (mockup C): each link is a
10
12
  // card with its rules, an Instant / Approval badge, and its next openings —
@@ -30,34 +32,38 @@ const chip = (start: number, link: (typeof links)[number]) => {
30
32
  return link.mode === 'slots' ? `${day} ${f.time.format(start)}` : day;
31
33
  };
32
34
  const facts = [resource.capacity ? `Up to ${resource.capacity}` : '', ...resource.amenities].filter(Boolean);
35
+ // the room and its links named in the page's language, and links that stay in it
36
+ const locale = requestLocale(Astro.url, Astro.locals);
37
+ const room = roomName(resource, locale);
38
+ const here = (path: string) => localePath(path, locale);
33
39
  ---
34
40
 
35
- <Site title={resource.name} description={resource.description || `Book ${resource.name}.`} image={resource.photo || undefined} noindex={resource.visibility === 'unlisted'}>
41
+ <Site title={room} description={resource.description || `Book ${room}.`} image={resource.photo || undefined} noindex={resource.visibility === 'unlisted'}>
36
42
  <div class="pc-res">
37
43
  <div class="pc-res-about">
38
44
  {resource.photo ? <img class="pc-res-photo" src={resource.photo} alt="" /> : null}
39
45
  <span class="pc-res-kind">{resource.kind}</span>
40
- <h1 class="pc-res-title">{resource.name}</h1>
46
+ <h1 class="pc-res-title">{room}</h1>
41
47
  {facts.length > 0 && <ul class="pc-res-facts">{facts.map((x) => <li>{x}</li>)}</ul>}
42
48
  {resource.description && <p class="pc-res-desc">{resource.description}</p>}
43
49
  </div>
44
50
  <div class="pc-res-links">
45
- {cards.length === 0 && <p class="pc-bk-empty">Online booking isn’t open for {resource.name} right now.</p>}
51
+ {cards.length === 0 && <p class="pc-bk-empty">Online booking isn’t open for {room} right now.</p>}
46
52
  {cards.map(({ link, next }) => (
47
53
  <article class="pc-res-link">
48
54
  <div class="pc-res-linkhead">
49
- <h2><a href={bookingLinkUrl(resource.slug, link.slug)}>{link.name}</a></h2>
55
+ <h2><a href={here(bookingLinkUrl(resource.slug, link.slug))}>{linkWords(link, locale).name}</a></h2>
50
56
  <span class={`pc-res-badge${link.autoConfirm ? ' is-instant' : ''}`}>{link.autoConfirm ? 'Instant' : 'Approval'}</span>
51
57
  </div>
52
- <p>{[describeLink(link), link.description].filter(Boolean).join(' · ')}</p>
58
+ <p>{[describeLink(link), linkWords(link, locale).description].filter(Boolean).join(' · ')}</p>
53
59
  <div class="pc-res-next">
54
- {next.map((s) => <a href={`${bookingLinkUrl(resource.slug, link.slug)}?date=${new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(s.start)}&start=${s.start}&len=${s.minutes}`}>{chip(s.start, link)}</a>)}
60
+ {next.map((s) => <a href={`${here(bookingLinkUrl(resource.slug, link.slug))}?date=${new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(s.start)}&start=${s.start}&len=${s.minutes}`}>{chip(s.start, link)}</a>)}
55
61
  {next.length === 0 && <span class="pc-res-none">No openings soon</span>}
56
- <a class="pc-res-more" href={bookingLinkUrl(resource.slug, link.slug)}>{next.length ? 'More times ›' : 'See calendar ›'}</a>
62
+ <a class="pc-res-more" href={here(bookingLinkUrl(resource.slug, link.slug))}>{next.length ? 'More times ›' : 'See calendar ›'}</a>
57
63
  </div>
58
64
  </article>
59
65
  ))}
60
- <p class="pc-bk-foot"><a href={CALENDAR.bookBase}>All rooms and people</a></p>
66
+ <p class="pc-bk-foot"><a href={here(CALENDAR.bookBase)}>All rooms and people</a></p>
61
67
  </div>
62
68
  </div>
63
69
  </Site>
@@ -2,6 +2,8 @@
2
2
  import Site from '../../layouts/Site.astro';
3
3
  import { getRepo } from '../../lib/repo';
4
4
  import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl } from '../../lib/calendar/config';
5
+ import { linkWords, roomName } from '../../lib/schema';
6
+ import { localePath, requestLocale } from '../../lib/i18n';
5
7
 
6
8
  // Directory of everything bookable: public resources grouped by kind, each
7
9
  // with its public booking links.
@@ -13,6 +15,9 @@ const byKind = new Map<string, typeof listed>();
13
15
  for (const r of listed) byKind.set(r.kind, [...(byKind.get(r.kind) ?? []), r]);
14
16
  const linksOf = (id: string) => links.filter((l) => l.resourceId === id && l.active && l.visibility === 'public');
15
17
  const plural = (k: string) => (k.endsWith('s') ? k : k.endsWith('y') ? `${k.slice(0, -1)}ies` : `${k}s`);
18
+ // rooms and links named in the page's language, and links that stay in it
19
+ const locale = requestLocale(Astro.url, Astro.locals);
20
+ const here = (path: string) => localePath(path, locale);
16
21
  ---
17
22
 
18
23
  <Site title="Book" description={`Book rooms and time at ${settings.siteTitle}.`}>
@@ -32,11 +37,11 @@ const plural = (k: string) => (k.endsWith('s') ? k : k.endsWith('y') ? `${k.slic
32
37
  <div class="pc-dir-grid">
33
38
  {list.map((r) => (
34
39
  <article class="pc-dir-card">
35
- <a class="pc-dir-media" href={resourceUrl(r.slug)} tabindex="-1" aria-hidden="true">{r.photo ? <img src={r.photo} alt="" loading="lazy" /> : <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V9l9-5 9 5v12M9 21v-6h6v6" /></svg>}</a>
40
+ <a class="pc-dir-media" href={here(resourceUrl(r.slug))} tabindex="-1" aria-hidden="true">{r.photo ? <img src={r.photo} alt="" loading="lazy" /> : <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V9l9-5 9 5v12M9 21v-6h6v6" /></svg>}</a>
36
41
  <div class="pc-dir-body">
37
- <h3><a href={resourceUrl(r.slug)}>{r.name}</a></h3>
42
+ <h3><a href={here(resourceUrl(r.slug))}>{roomName(r, locale)}</a></h3>
38
43
  <p>{[r.capacity ? `Up to ${r.capacity}` : '', ...r.amenities.slice(0, 3)].filter(Boolean).join(' · ')}</p>
39
- <div class="pc-dir-links">{linksOf(r.id).map((l) => <a href={bookingLinkUrl(r.slug, l.slug)}>{l.name}</a>)}</div>
44
+ <div class="pc-dir-links">{linksOf(r.id).map((l) => <a href={here(bookingLinkUrl(r.slug, l.slug))}>{linkWords(l, locale).name}</a>)}</div>
40
45
  </div>
41
46
  </article>
42
47
  ))}
@@ -5,6 +5,8 @@ import { CALENDAR, bookingLinkUrl, bookingsEnabled, eventUrl, resourceUrl } from
5
5
  import { confirmOffer, guestCancel, guestChanges, waitlistPosition, BookingError, WAITLIST_HOLD } from '../../../lib/booking/service';
6
6
  import { bookingIcs, whenText } from '../../../lib/booking/emails';
7
7
  import { resourceZone } from '../../../lib/booking/availability';
8
+ import { bookingTitle } from '../../../lib/booking/view';
9
+ import { roomName } from '../../../lib/schema';
8
10
  import { eventTimeZone, locationLine } from '../../../lib/calendar/events';
9
11
  import { siteOrigin } from '../../../lib/seo';
10
12
  import { waitUntilFrom } from '../../../lib/webhooks';
@@ -43,8 +45,8 @@ const lang = locale || siteLang(settings);
43
45
  const bw = bookingStrings(lang).page;
44
46
  const w = isRsvp ? rsvpStrings(lang) : { ...rsvpStrings(lang), ...bw };
45
47
  const event = rawEvent && localizeDoc(rawEvent, locale || undefined);
46
- const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || bw.untitled;
47
- const location = event ? locationLine(event.location) : resource?.name ?? '';
48
+ const title = event ? event.title : bookingTitle(resource, link, locale) || bw.untitled;
49
+ const location = event ? locationLine(event.location) : resource ? roomName(resource, locale) : '';
48
50
  // the address the visitor sees (/es/… is rewritten to this route)
49
51
  const url = origin + localePath(Astro.url.pathname, locale);
50
52
 
@@ -166,7 +168,7 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
166
168
  )}
167
169
  {can.closed && <p class="pc-manage-note">{cutoff}</p>}
168
170
  <p class="pc-bk-foot">
169
- {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}
171
+ {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: roomName(resource, locale) })}</a> : null}
170
172
  </p>
171
173
  <p class="pc-manage-note">{w.keepLink}</p>
172
174
  </div>
@@ -4,25 +4,33 @@ import BookingFlow from '../../../../components/booking/BookingFlow.astro';
4
4
  import { getRepo } from '../../../../lib/repo';
5
5
  import { bookingLinkUrl, bookingsEnabled } from '../../../../lib/calendar/config';
6
6
  import { availabilityContext } from '../../../../lib/booking/service';
7
+ import { I18N, localePath } from '../../../../lib/i18n';
8
+ import { linkWords } from '../../../../lib/schema';
7
9
 
8
10
  // Embeddable picker for other sites: day + time selection happens inside the
9
11
  // iframe; choosing a time opens the full booking page (top window) to finish.
12
+ // ?lang=es (one of the site's languages; embeds are never under /es) names the
13
+ // link and room in it and hands off to that language's page.
10
14
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
11
15
  const repo = await getRepo(Astro.locals);
12
16
  const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
13
17
  const link = resource ? await repo.getBookingLinkBySlug(resource.id, Astro.params.link ?? '') : null;
14
18
  if (!resource || !link || !link.active) return new Response(null, { status: 404 });
15
19
  const settings = await repo.getSettings();
20
+ const langParam = Astro.url.searchParams.get('lang') ?? '';
21
+ const locale = I18N.enabled && I18N.locales.includes(langParam) ? langParam : '';
16
22
  // a chosen time hands off to the real page, where the form lives
17
23
  if (Astro.url.searchParams.get('start')) {
18
- return Astro.redirect(`${bookingLinkUrl(resource.slug, link.slug)}${Astro.url.search}`);
24
+ const search = new URLSearchParams(Astro.url.search);
25
+ search.delete('lang');
26
+ return Astro.redirect(`${localePath(bookingLinkUrl(resource.slug, link.slug), locale)}?${search}`);
19
27
  }
20
28
  const ctx = await availabilityContext(repo, resource, link, settings);
21
29
  Astro.response.headers.set('Content-Security-Policy', 'frame-ancestors *');
22
30
  ---
23
31
 
24
- <Site title={link.name} bare noindex>
25
- <BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={Astro.url} embed />
32
+ <Site title={linkWords(link, locale).name} bare noindex>
33
+ <BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={Astro.url} embed locale={locale} />
26
34
  <script is:inline>
27
35
  (function () {
28
36
  var send = function () { parent.postMessage({ acmsBookingEmbed: true, height: document.documentElement.scrollHeight }, '*'); };
@@ -7,7 +7,7 @@ import { authorSlug } from '../lib/schema';
7
7
  import { I18N, localeFromPath, localeHomeUrl, normalizePageLocale } from '../lib/i18n';
8
8
  import { searchStrings } from '../lib/search-i18n';
9
9
  import { CALENDAR, eventUrl } from '../lib/calendar/config';
10
- import { eventIsPublic, featuredOccurrence, formatOccurrence } from '../lib/calendar/events';
10
+ import { eventIsSearchable, featuredOccurrence, formatOccurrence } from '../lib/calendar/events';
11
11
  import { siteLocale } from '../lib/dates';
12
12
 
13
13
  // The no-JS search surface (the modal's form falls back here). Serves both
@@ -25,7 +25,7 @@ const results = q
25
25
  ? await repo.searchPublished(q, { locale })
26
26
  : { posts: [], pages: [], events: [], tags: [], authors: [] };
27
27
  const calMap = new Map((await repo.listCalendars()).map((c) => [c.id, c]));
28
- const events = CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsPublic(doc, calMap)).slice(0, 10) : [];
28
+ const events = CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsSearchable(doc, calMap)).slice(0, 10) : [];
29
29
  const total = results.posts.length + results.pages.length + events.length + results.tags.length + results.authors.length;
30
30
  ---
31
31