@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.
- package/CHANGELOG.md +26 -0
- package/cli/migrations.generated.mjs +8 -0
- package/docs/calendar.md +57 -1
- package/docs/mobile.md +7 -1
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
- package/mobile-template/recipes/booking/files/app/book/details.tsx +34 -25
- package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
- package/mobile-template/recipes/booking/files/app/book/manage.tsx +82 -0
- package/mobile-template/recipes/booking/recipe.json +6 -3
- package/package.json +1 -1
- package/src/components/MobileApp.tsx +11 -6
- package/src/components/booking/BookingFlow.astro +16 -8
- package/src/lib/booking/public.ts +40 -0
- package/src/lib/booking/service.ts +7 -3
- package/src/lib/booking/view.ts +6 -0
- package/src/lib/calendar/data.ts +25 -31
- package/src/lib/calendar/events.ts +12 -2
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/display/board.ts +5 -3
- package/src/lib/editor/schedule.ts +2 -0
- package/src/lib/mobile/calendar.ts +41 -14
- package/src/lib/mobile/guest.ts +4 -2
- package/src/lib/mobile/manage.ts +8 -4
- package/src/lib/mobile/openapi.ts +4 -4
- package/src/lib/mobile/schemas.ts +6 -1
- package/src/lib/mobile/search.ts +7 -6
- package/src/lib/push/api.ts +4 -2
- package/src/lib/push/service.ts +26 -15
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +10 -5
- package/src/lib/schema.ts +21 -6
- package/src/lib/sections.ts +4 -2
- package/src/pages/admin/calendar/resources/link.astro +14 -1
- package/src/pages/api/search.ts +3 -3
- package/src/pages/book/[resource]/[link].astro +10 -5
- package/src/pages/book/[resource]/index.astro +14 -8
- package/src/pages/book/index.astro +8 -3
- package/src/pages/book/manage/[token].astro +5 -3
- package/src/pages/embed/book/[resource]/[link].astro +11 -3
- package/src/pages/search.astro +2 -2
package/src/lib/push/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = {
|
package/src/lib/push/service.ts
CHANGED
|
@@ -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
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 ?
|
|
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 &&
|
|
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
|
|
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 =
|
|
551
|
-
const chosen = events.filter((e) => !e.cancelled &&
|
|
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
|
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
package/src/lib/sections.ts
CHANGED
|
@@ -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
|
-
|
|
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">
|
package/src/pages/api/search.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
46
|
-
events: (CALENDAR.enabled ? results.events.filter(({ doc }) =>
|
|
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
|
|
14
|
-
//
|
|
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={`${
|
|
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 {
|
|
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={
|
|
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">{
|
|
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 {
|
|
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
|
|
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 :
|
|
47
|
-
const location = event ? locationLine(event.location) : resource
|
|
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
|
|
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
|
-
|
|
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 }, '*'); };
|
package/src/pages/search.astro
CHANGED
|
@@ -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 {
|
|
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 }) =>
|
|
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
|
|