@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
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// is a plain link or form (?date=…&len=…&start=…), so it works without
|
|
5
5
|
// JavaScript and each state is shareable. Used by the page and the embed.
|
|
6
6
|
import type { Booking, BookingLink, Resource, Settings } from '../../lib/schema';
|
|
7
|
+
import { linkWords, roomName } from '../../lib/schema';
|
|
7
8
|
import { bookingLinkUrl, resourceUrl } from '../../lib/calendar/config';
|
|
9
|
+
import { localePath } from '../../lib/i18n';
|
|
8
10
|
import { describeLength, describeLink, linkLengths, openDays, slotsForDay, ymd, type AvailabilityContext } from '../../lib/booking/availability';
|
|
9
11
|
import { addMonths, fmt, monthCells, slotLabel } from '../../lib/booking/view';
|
|
10
12
|
import { confirmsInstantly, linkApproval } from '../../lib/booking/approval';
|
|
@@ -19,8 +21,13 @@ interface Props {
|
|
|
19
21
|
values?: Record<string, string>;
|
|
20
22
|
embed?: boolean;
|
|
21
23
|
reschedule?: Booking | null;
|
|
24
|
+
/** the page's language: the link and room are named in it, and links stay in it ('' = the main one) */
|
|
25
|
+
locale?: string;
|
|
22
26
|
}
|
|
23
|
-
const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null } = Astro.props;
|
|
27
|
+
const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null, locale = '' } = Astro.props;
|
|
28
|
+
// the booking link and room in the page's language (the rest of the flow is in English)
|
|
29
|
+
const words = linkWords(link, locale);
|
|
30
|
+
const room = roomName(resource, locale);
|
|
24
31
|
const tz = ctx.resource.timezone || settings.timezone || 'UTC';
|
|
25
32
|
// "rules" links usually confirm at once, but may not — promise only what's certain
|
|
26
33
|
const instant = confirmsInstantly(linkApproval(link, settings));
|
|
@@ -42,10 +49,11 @@ const startParam = Number(url.searchParams.get('start'));
|
|
|
42
49
|
const chosen = slots.find((s) => s.start === startParam && (link.mode !== 'day' || s.minutes === Number(url.searchParams.get('len') || s.minutes)));
|
|
43
50
|
const minMonth = today.slice(0, 7);
|
|
44
51
|
const maxMonth = ymd(ctx.now + link.maxAdvance * 86_400_000, tz).slice(0, 7);
|
|
45
|
-
const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : bookingLinkUrl(resource.slug, link.slug);
|
|
52
|
+
const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : localePath(bookingLinkUrl(resource.slug, link.slug), locale);
|
|
46
53
|
const q = (patch: Record<string, string | number | undefined>) => {
|
|
47
54
|
const p = new URLSearchParams();
|
|
48
|
-
|
|
55
|
+
// an embed keeps its ?lang= (embeds are never under /es)
|
|
56
|
+
for (const k of ['len', 'm', 'date', 'start', 'reschedule', ...(embed ? ['lang'] : [])]) {
|
|
49
57
|
const v = k in patch ? patch[k] : url.searchParams.get(k) ?? undefined;
|
|
50
58
|
if (v !== undefined && v !== '' && v !== null) p.set(k, String(v));
|
|
51
59
|
}
|
|
@@ -62,10 +70,10 @@ const val = (k: string) => values[k] ?? '';
|
|
|
62
70
|
|
|
63
71
|
<div class={`pc-bk${embed ? ' is-embed' : ''}`}>
|
|
64
72
|
<aside class="pc-bk-info">
|
|
65
|
-
{!embed && <a class="pc-bk-back" href={resourceUrl(resource.slug)}>‹ {
|
|
66
|
-
{embed && <span class="pc-bk-kicker">{
|
|
67
|
-
<h1 class="pc-bk-title">{
|
|
68
|
-
{
|
|
73
|
+
{!embed && <a class="pc-bk-back" href={localePath(resourceUrl(resource.slug), locale)}>‹ {room}</a>}
|
|
74
|
+
{embed && <span class="pc-bk-kicker">{room}</span>}
|
|
75
|
+
<h1 class="pc-bk-title">{words.name}</h1>
|
|
76
|
+
{words.description && <p class="pc-bk-desc">{words.description}</p>}
|
|
69
77
|
<ul class="pc-bk-rules">{rules.map((r) => <li>{r}</li>)}</ul>
|
|
70
78
|
{link.mode === 'range' && lengths.length > 1 && (
|
|
71
79
|
<div class="pc-bk-lens" role="group" aria-label="How long">
|
|
@@ -111,7 +119,7 @@ const val = (k: string) => values[k] ?? '';
|
|
|
111
119
|
) : (
|
|
112
120
|
<section class="pc-bk-form">
|
|
113
121
|
<div class="pc-bk-summary">
|
|
114
|
-
<b>{
|
|
122
|
+
<b>{room} · {words.name}</b>
|
|
115
123
|
<span>{f.dayLong.format(chosen.start)} · {slotLabel(chosen, link, f)}</span>
|
|
116
124
|
<a href={q({ start: undefined })}>Change</a>
|
|
117
125
|
</div>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Confirmed room bookings as schedule items: /calendar, embeds and TV boards
|
|
2
|
+
// (lib/calendar/data.ts) and the app API's `bookings` (lib/mobile/calendar.ts).
|
|
3
|
+
// Publicly a booking is named by its booking link in the reader's language
|
|
4
|
+
// ("Rehearsal", "Ensayo"; the calendar's "Reserved" when the link is gone)
|
|
5
|
+
// and never says who booked: no name, email, phone, notes, answers or manage
|
|
6
|
+
// token, and not the booking's id (publicBookingKey stands in for it).
|
|
7
|
+
import type { ContentRepository } from '../repo/types';
|
|
8
|
+
import { linkWords, type Booking, type BookingLink, type Resource } from '../schema';
|
|
9
|
+
import { sha256Hex } from '../api-keys';
|
|
10
|
+
|
|
11
|
+
export interface ScheduleBooking { booking: Booking; resource: Resource; link: BookingLink | null }
|
|
12
|
+
|
|
13
|
+
/** Confirmed bookings of these rooms overlapping [from, to), with their room and booking link, by start. */
|
|
14
|
+
export async function scheduleBookings(repo: ContentRepository, rooms: Resource[], from: number, to: number, limit = 5000): Promise<ScheduleBooking[]> {
|
|
15
|
+
if (!rooms.length) return [];
|
|
16
|
+
const byId = new Map(rooms.map((r) => [r.id, r]));
|
|
17
|
+
const [booked, links] = await Promise.all([
|
|
18
|
+
repo.listBookings({ from, to, statuses: ['confirmed'], kinds: ['booking'] }),
|
|
19
|
+
repo.listBookingLinks(),
|
|
20
|
+
]);
|
|
21
|
+
const linkById = new Map(links.map((l) => [l.id, l]));
|
|
22
|
+
const out: ScheduleBooking[] = [];
|
|
23
|
+
for (const b of booked) {
|
|
24
|
+
const resource = byId.get(b.resourceId);
|
|
25
|
+
if (resource) out.push({ booking: b, resource, link: linkById.get(b.linkId) ?? null });
|
|
26
|
+
if (out.length >= limit) break;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether a booking reads as all day: its link books whole days (without its link: a day long, give or take a clock change). */
|
|
32
|
+
export const bookingAllDay = (b: Pick<Booking, 'start' | 'end'>, link: Pick<BookingLink, 'mode'> | null) =>
|
|
33
|
+
link ? link.mode === 'day' : b.end - b.start >= 86_400_000 - 3_600_000;
|
|
34
|
+
|
|
35
|
+
/** A booking's public title: its link's name in `locale`, else `reserved` (the calendar's word in that language). */
|
|
36
|
+
export const publicBookingTitle = (link: BookingLink | null, locale: string | null | undefined, reserved: string) =>
|
|
37
|
+
(link && linkWords(link, locale).name) || reserved;
|
|
38
|
+
|
|
39
|
+
/** A stable key for a booking in public data that doesn't reveal its id. */
|
|
40
|
+
export const publicBookingKey = async (b: Pick<Booking, 'id'>) => `b${(await sha256Hex(`public-booking:${b.id}`)).slice(0, 24)}`;
|
|
@@ -6,6 +6,7 @@ import { collectResponse, displayAnswers, staffAnswers } from '../forms/service'
|
|
|
6
6
|
import type { ContentRepository } from '../repo/types';
|
|
7
7
|
import type { Booking, BookingLink, BookingStatus, CalEvent, Resource, Settings } from '../schema';
|
|
8
8
|
import { roomName } from '../schema';
|
|
9
|
+
import { bookingTitle } from './view';
|
|
9
10
|
import { loadFeed, expandVEvents } from '../calendar/ics';
|
|
10
11
|
import { emitWebhook } from '../webhooks';
|
|
11
12
|
import { parseFrom } from '../email/config';
|
|
@@ -109,8 +110,8 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
|
|
|
109
110
|
const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
|
|
110
111
|
return {
|
|
111
112
|
resource, link, event: null as CalEvent | null, tz,
|
|
112
|
-
// the room in the booking's language (
|
|
113
|
-
title:
|
|
113
|
+
// the room and its booking link in the booking's language (their main names without one)
|
|
114
|
+
title: bookingTitle(resource, link, locale) || 'Booking',
|
|
114
115
|
location: resource ? roomName(resource, locale) : '',
|
|
115
116
|
when: whenText(b, tz, settings, link?.mode === 'day'),
|
|
116
117
|
};
|
|
@@ -304,8 +305,11 @@ export async function createResourceBooking(
|
|
|
304
305
|
const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
|
|
305
306
|
if (args.source !== 'admin' && !isBookable(ctx, args.start, args.end)) throw new BookingError(409, 'That time was just taken or isn’t available. Please pick another.');
|
|
306
307
|
const approval = linkApproval(link, settings);
|
|
308
|
+
// "the first booking from that email": only a confirmed booking (staff approved it, or it
|
|
309
|
+
// confirmed by itself) makes them returning; one still waiting, declined or cancelled doesn't.
|
|
310
|
+
// The address is all a booking without an account has to go on.
|
|
307
311
|
const returning = approval.mode === 'rules' && approval.rules.firstTime
|
|
308
|
-
? (await repo.listBookings({ email: booker.email, statuses: ['
|
|
312
|
+
? (await repo.listBookings({ email: booker.email, statuses: ['confirmed'], kinds: ['booking'], limit: 1 })).length > 0
|
|
309
313
|
: true;
|
|
310
314
|
const status: BookingStatus = args.status ?? decideStatus(approval, { start: args.start, end: args.end, seats: 1, returning });
|
|
311
315
|
const b = await repo.createBooking({
|
package/src/lib/booking/view.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Display helpers for the public booking pages.
|
|
2
2
|
import type { BookingLink, Resource, Settings } from '../schema';
|
|
3
|
+
import { linkWords, roomName } from '../schema';
|
|
3
4
|
import { siteLocale } from '../dates';
|
|
4
5
|
import { addDaysYmd, localMidnight, resourceZone, ymd, type Slot } from './availability';
|
|
5
6
|
|
|
@@ -52,3 +53,8 @@ export function todayIn(resource: Resource, settings: Settings, now: number) {
|
|
|
52
53
|
const tz = resourceZone(resource, settings.timezone);
|
|
53
54
|
return { tz, today: ymd(now, tz), midnight: localMidnight(ymd(now, tz), tz) };
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/** "Sanctuary · Rehearsal": the room and booking link in `locale` (each its main name without one); '' when both are gone. */
|
|
58
|
+
export function bookingTitle(resource: Resource | null | undefined, link: BookingLink | null | undefined, locale?: string | null): string {
|
|
59
|
+
return [resource && roomName(resource, locale), link && linkWords(link, locale).name].filter(Boolean).join(' · ');
|
|
60
|
+
}
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// details, lib/display: published events only, private calendars when it
|
|
8
8
|
// names them, bookings with the booker's name, never emails, phones or notes).
|
|
9
9
|
import type { ContentRepository } from '../repo/types';
|
|
10
|
-
import { roomColor, roomName, type
|
|
10
|
+
import { linkWords, roomColor, roomName, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
|
|
11
|
+
import { bookingAllDay, publicBookingTitle, scheduleBookings } from '../booking/public';
|
|
11
12
|
import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
|
|
12
13
|
import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
|
|
13
14
|
import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
|
|
@@ -48,13 +49,14 @@ export interface ViewScope {
|
|
|
48
49
|
includeUncategorized?: boolean;
|
|
49
50
|
// feeds are network calls — views that don't need them can skip
|
|
50
51
|
includeFeeds?: boolean;
|
|
51
|
-
//
|
|
52
|
+
// confirmed bookings of rooms set to show on the calendar (named by their
|
|
53
|
+
// booking link; with the booker's name for staff and private screens)
|
|
52
54
|
includeBookings?: boolean;
|
|
53
55
|
// the address's cal/tag/notag/room (filter.ts); its calendars narrow
|
|
54
56
|
// calendarIds when both are given
|
|
55
57
|
filter?: ResolvedEventFilter;
|
|
56
|
-
// the request's language (requestLocale): translated titles,
|
|
57
|
-
// under its /<locale> prefix
|
|
58
|
+
// the request's language (requestLocale): translated titles, room and
|
|
59
|
+
// booking link names, event links under its /<locale> prefix
|
|
58
60
|
locale?: string;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -158,36 +160,28 @@ export async function collectViewEvents(
|
|
|
158
160
|
events.push(...results.flat());
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
163
|
+
// rooms marked "show on calendar": their confirmed bookings appear named by
|
|
164
|
+
// their booking link in the view's language ("Rehearsal", "Ensayo"; "Reserved"
|
|
165
|
+
// when the link is gone), never who booked, on views that name no calendar
|
|
166
|
+
// (a booking belongs to none): unfiltered, or by room (each in its own room),
|
|
167
|
+
// never with a tag. A screen also shows the bookings of the rooms it names, as
|
|
168
|
+
// "<booking link> · <booker>" (the name only: never their email, phone or notes).
|
|
165
169
|
if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
|
|
166
170
|
const named = scope.audience === 'screen' ? f?.roomIds ?? [] : [];
|
|
167
171
|
const resources = (await repo.listResources()).filter((r) => (r.showOnCalendar || named.includes(r.id)) && (!f || untaggedMatches(f, { roomId: r.id })));
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
|
|
182
|
-
: scope.audience === 'screen' ? [linkName.get(b.linkId) || room, b.name].filter(Boolean).join(' · ')
|
|
183
|
-
: `${words.reserved} · ${room}`;
|
|
184
|
-
events.push({
|
|
185
|
-
start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
|
|
186
|
-
title, location: room, description: '', cal: inScope.length, color: '#8e919a', calendarName: room,
|
|
187
|
-
url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
|
|
188
|
-
rooms: [{ id: r.id, slug: r.slug, name: room, color: roomColor(r) }],
|
|
189
|
-
});
|
|
190
|
-
}
|
|
172
|
+
for (const { booking: b, resource: r, link } of await scheduleBookings(repo, resources, win.windowStartMs, win.windowEndMs)) {
|
|
173
|
+
const allDay = bookingAllDay(b, link);
|
|
174
|
+
// the room and link in the view's language (their main names without one)
|
|
175
|
+
const room = roomName(r, scope.locale);
|
|
176
|
+
const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
|
|
177
|
+
: scope.audience === 'screen' ? [link ? linkWords(link, scope.locale).name : room, b.name].filter(Boolean).join(' · ')
|
|
178
|
+
: publicBookingTitle(link, scope.locale, words.reserved);
|
|
179
|
+
events.push({
|
|
180
|
+
start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
|
|
181
|
+
title, location: room, description: '', cal: inScope.length, color: '#8e919a', calendarName: room,
|
|
182
|
+
url: scope.audience === 'public' ? localePath(resourceUrl(r.slug), scope.locale ?? '') : undefined,
|
|
183
|
+
rooms: [{ id: r.id, slug: r.slug, name: room, color: roomColor(r) }],
|
|
184
|
+
});
|
|
191
185
|
}
|
|
192
186
|
}
|
|
193
187
|
|
|
@@ -398,8 +398,8 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
|
|
|
398
398
|
|
|
399
399
|
// ---- visibility ----------------------------------------------------------
|
|
400
400
|
// An event with no calendars counts as public. Otherwise it follows its most
|
|
401
|
-
// open calendar: any public/unlisted calendar makes the event page public
|
|
402
|
-
// only public calendars put it on the main calendar page and site feed.
|
|
401
|
+
// open calendar: any public/unlisted calendar makes the event page public (and
|
|
402
|
+
// searchable); only public calendars put it on the main calendar page and site feed.
|
|
403
403
|
|
|
404
404
|
export function eventIsPublic(e: CalEvent, calendars: Map<string, Calendar>): boolean {
|
|
405
405
|
if (e.status !== 'published') return false;
|
|
@@ -416,6 +416,16 @@ export function eventIsListed(e: CalEvent, calendars: Map<string, Calendar>): bo
|
|
|
416
416
|
return e.calendarIds.some((id) => calendars.get(id)?.visibility === 'public');
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/** The internal tag on appointments booked through a Google booking page (they come in titled "Booked"). */
|
|
420
|
+
export const BOOKING_TAG = '#google-booking';
|
|
421
|
+
/** A Google booking-page appointment: search never finds it and push never announces it. */
|
|
422
|
+
export const isGoogleBooking = (e: Pick<CalEvent, 'tags'>) => e.tags.some((t) => t.trim().toLowerCase() === BOOKING_TAG);
|
|
423
|
+
|
|
424
|
+
/** What search finds, on the website and in the app: events with a public page (unlisted calendars too), but no booking-page appointments. */
|
|
425
|
+
export function eventIsSearchable(e: CalEvent, calendars: Map<string, Calendar>): boolean {
|
|
426
|
+
return eventIsPublic(e, calendars) && !isGoogleBooking(e);
|
|
427
|
+
}
|
|
428
|
+
|
|
419
429
|
// ---- formatting ----------------------------------------------------------
|
|
420
430
|
|
|
421
431
|
export function formatOccurrence(
|
|
@@ -27,7 +27,7 @@ const DAY = 86_400_000;
|
|
|
27
27
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
28
28
|
|
|
29
29
|
/** The internal tag on booked appointments from Google booking pages (Q16): never shown publicly. */
|
|
30
|
-
export
|
|
30
|
+
export { BOOKING_TAG } from '../events';
|
|
31
31
|
|
|
32
32
|
/** The few words the sync writes, in the site's main language (Q9). */
|
|
33
33
|
export interface SyncWords { cancelled: string; details: string; link: string; booked: string; untitled: string }
|
package/src/lib/display/board.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// One TV board, ready to render: which days it covers, which events it may
|
|
2
2
|
// show, and when it goes stale. Quick links (/display/calendar?…) and saved
|
|
3
3
|
// screens without private details show what calendar.ics shows: published
|
|
4
|
-
// events in public calendars (or an unlisted one named in cal=)
|
|
4
|
+
// events in public calendars (or an unlisted one named in cal=), plus, as
|
|
5
|
+
// /calendar shows them, the bookings of rooms set to show on the calendar,
|
|
6
|
+
// named by their booking link (never who booked). A saved
|
|
5
7
|
// screen with private details on also shows private calendars it names, the
|
|
6
8
|
// bookings of its rooms with the booker's name, the rooms and hosts of every
|
|
7
9
|
// event. Never emails, phone numbers, notes or RSVP lists.
|
|
@@ -108,9 +110,9 @@ export async function loadDisplayBoard(repo: ContentRepository, site: Settings,
|
|
|
108
110
|
const { events, calendars: shownCalendars } = await collectViewEvents(repo, site, win, {
|
|
109
111
|
audience: req.privateDetails ? 'screen' : 'public',
|
|
110
112
|
filter,
|
|
111
|
-
// quick links show what calendar.ics shows
|
|
113
|
+
// quick links show what calendar.ics shows (no subscribed feeds), and
|
|
114
|
+
// bookings as /calendar shows them: named by their link, never who booked
|
|
112
115
|
includeFeeds: req.privateDetails,
|
|
113
|
-
includeBookings: req.privateDetails,
|
|
114
116
|
// translated titles in the display's language
|
|
115
117
|
locale: lang,
|
|
116
118
|
});
|
|
@@ -263,6 +263,8 @@ export const LinkInput = z.object({
|
|
|
263
263
|
confirmMessage: z.string().max(1000).optional(), redirectUrl: z.string().max(1000).refine((v) => !v || /^https?:\/\//.test(v), 'Use an http(s) URL.').optional(),
|
|
264
264
|
active: z.boolean().optional(),
|
|
265
265
|
changeCutoff: z.number().int().min(0).max(8760).optional(),
|
|
266
|
+
// its name and description in the site's other languages: { es: { name: 'Ensayo' } } ('' = the main one)
|
|
267
|
+
translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), z.object({ name: z.string().trim().max(120).default(''), description: z.string().max(2000).default('') })).optional(),
|
|
266
268
|
}).strict();
|
|
267
269
|
export const LinkPatch = LinkInput.partial();
|
|
268
270
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import { type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
3
|
+
import { linkWords, roomName, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
|
|
6
6
|
import { calendarToJson, eventToJson, roomsToJson } from '../calendar/api';
|
|
7
|
-
import { eventIsListed, eventIsPublic, expandEvents, featuredOccurrence, type Occurrence } from '../calendar/events';
|
|
7
|
+
import { eventIsListed, eventIsPublic, eventIsSearchable, expandEvents, featuredOccurrence, type Occurrence } from '../calendar/events';
|
|
8
8
|
import { availabilityContext } from '../booking/service';
|
|
9
9
|
import { roomBusy } from '../booking/holds';
|
|
10
10
|
import { sha256Hex } from '../api-keys';
|
|
@@ -19,6 +19,8 @@ import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
|
19
19
|
import { confirmsInstantly, linkApproval } from '../booking/approval';
|
|
20
20
|
import { changeTopics } from '../push/service';
|
|
21
21
|
import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../calendar/filter';
|
|
22
|
+
import { bookingAllDay, publicBookingKey, publicBookingTitle, scheduleBookings } from '../booking/public';
|
|
23
|
+
import { eventStrings } from '../calendar/event-words';
|
|
22
24
|
|
|
23
25
|
export function requireCalendar(bookings = false) {
|
|
24
26
|
if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
|
|
@@ -40,7 +42,8 @@ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: i
|
|
|
40
42
|
calendar: filterList('Only events in these calendars (slugs).'), cal: filterList('Alias of calendar.'),
|
|
41
43
|
tag: filterList('Only events with one of these tags (a name in any case, or a slug).'), excludeTag: filterList('Leave out events with any of these tags.'), notag: filterList('Alias of excludeTag.'),
|
|
42
44
|
room: filterList('Only events held in one of these rooms (slugs of rooms shown on public events).'),
|
|
43
|
-
|
|
45
|
+
bookings: z.enum(['1', 'true']).optional().describe('Also the confirmed bookings of rooms set to “Show on the calendar”, as `bookings` (with the first page): the booking link’s name in the requested language, the time and the room, never who booked. Only `room` narrows them; `calendar`, `tag` and `excludeTag` don’t (a booking belongs to no calendar and has no tags).'),
|
|
46
|
+
q: z.string().max(100).optional().describe('Search: the events /search finds (unlisted calendars too, never Google booking-page appointments), listed by date. Without it, only public calendars and the ones named in calendar.') }).strict();
|
|
44
47
|
export async function listEvents(repo: ContentRepository, url: URL) {
|
|
45
48
|
requireCalendar();
|
|
46
49
|
const q = eventsQuery.parse(queryParams(url, FILTER_LISTS));
|
|
@@ -65,7 +68,9 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
65
68
|
if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Room not found.');
|
|
66
69
|
const picked = f.calendarIds;
|
|
67
70
|
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || picked?.includes(c.id)).map(c => [c.id, c]));
|
|
68
|
-
|
|
71
|
+
// q continues /search, so it finds what search finds (unlisted calendars too, no booking-page appointments)
|
|
72
|
+
const shown = (e: CalEvent) => (term ? eventIsSearchable(e, all) : picked ? eventIsPublic(e, all) : eventIsListed(e, all));
|
|
73
|
+
const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (!picked || e.calendarIds.some(id => picked.includes(id))) && shown(e)
|
|
69
74
|
&& tagsMatch(f, e.tags)
|
|
70
75
|
&& (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
|
|
71
76
|
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
|
|
@@ -78,11 +83,29 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
78
83
|
locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
|
|
79
84
|
occurrences: occurrences.slice(start, start + q.limit).map(o => ({
|
|
80
85
|
...occurrenceDto(o, resources, locale || siteLang(settings)),
|
|
81
|
-
event: publicEventDto(o.event, origin, publicCalendars, settings, locale, resources),
|
|
86
|
+
event: publicEventDto(o.event, origin, publicCalendars, all, settings, locale, resources),
|
|
82
87
|
})),
|
|
88
|
+
// the whole window's bookings come with the first page, so paging never repeats them
|
|
89
|
+
...(q.bookings ? { bookings: q.page === 1 && bookingsEnabled() ? await publicBookingsDto(repo, [...resources.values()], f.roomIds, from, to, term, locale || siteLang(settings)) : [] } : {}),
|
|
83
90
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
84
91
|
};
|
|
85
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Confirmed bookings of rooms set to show on the calendar (in `roomIds` when
|
|
95
|
+
* the address names rooms; `term` matches their title), as the public sees
|
|
96
|
+
* them on /calendar: the booking link's name in `locale`, the time and the
|
|
97
|
+
* room. Never who booked, and not the booking's id (`id` is an opaque key).
|
|
98
|
+
*/
|
|
99
|
+
async function publicBookingsDto(repo: ContentRepository, rooms: Resource[], roomIds: string[] | null, from: number, to: number, term: string, locale: string) {
|
|
100
|
+
const shown = rooms.filter(r => r.showOnCalendar && (!roomIds || roomIds.includes(r.id)));
|
|
101
|
+
const reserved = eventStrings(locale).calendar.reserved;
|
|
102
|
+
const iso = (n: number) => new Date(n).toISOString();
|
|
103
|
+
const items = (await scheduleBookings(repo, shown, from, to, 500)).map(({ booking: b, resource: r, link }) => ({ b, r, title: publicBookingTitle(link, locale, reserved), allDay: bookingAllDay(b, link) }))
|
|
104
|
+
.filter(x => !term || x.title.toLowerCase().includes(term.toLowerCase()));
|
|
105
|
+
return Promise.all(items.map(async ({ b, r, title, allDay }) => ({
|
|
106
|
+
id: await publicBookingKey(b), title, start: iso(b.start), end: iso(b.end), allDay, rooms: roomsToJson([r.id], new Map([[r.id, r]]), true, locale),
|
|
107
|
+
})));
|
|
108
|
+
}
|
|
86
109
|
// one event by slug (or id), whatever its date: apps open it from a link, a
|
|
87
110
|
// push or an announcement, often weeks ahead. `on` ('YYYY-MM-DD', like the
|
|
88
111
|
// website's ?on=) picks that day's occurrence; otherwise the next one, or the
|
|
@@ -102,7 +125,7 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
|
|
|
102
125
|
return {
|
|
103
126
|
locale,
|
|
104
127
|
occurrence: o ? occurrenceDto(o, resources, locale || siteLang(settings)) : null,
|
|
105
|
-
event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale, resources),
|
|
128
|
+
event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, all, settings, locale, resources),
|
|
106
129
|
};
|
|
107
130
|
}
|
|
108
131
|
/** every room by id, for naming the public ones (Resource.showOnEvents) */
|
|
@@ -116,8 +139,9 @@ export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>, l
|
|
|
116
139
|
location: o.location ? { name: o.location.name, address: o.location.address, url: o.location.url } : null,
|
|
117
140
|
rooms: roomsToJson(o.roomIds, resources, false, locale) };
|
|
118
141
|
}
|
|
119
|
-
// e is already localized; publicCalendars limits which calendars are named
|
|
120
|
-
|
|
142
|
+
// e is already localized; publicCalendars limits which calendars are named,
|
|
143
|
+
// `all` holds every calendar (their visibility decides which changes notify)
|
|
144
|
+
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, all: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
|
|
121
145
|
// the repeat line in the request's language, else the site's main one
|
|
122
146
|
const event = eventToJson(e, origin, publicCalendars, settings.timezone, { resources, locale: locale || siteLang(settings) });
|
|
123
147
|
event.url = localizedUrl(event.url, origin, locale);
|
|
@@ -126,20 +150,23 @@ export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map
|
|
|
126
150
|
event.image = safeUrl(e.cover, origin);
|
|
127
151
|
event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
|
|
128
152
|
// which push topics hear about date, time or place changes ([] = none do)
|
|
129
|
-
return { ...event, notify: { changes: changeTopics(e, settings,
|
|
153
|
+
return { ...event, notify: { changes: changeTopics(e, settings, all) } };
|
|
130
154
|
}
|
|
155
|
+
export const resourcesQuery = z.object({ ...pageQuery, locale: localeQuery }).strict();
|
|
131
156
|
export async function listResources(repo: ContentRepository, url: URL) {
|
|
132
157
|
requireCalendar(true);
|
|
133
|
-
const q =
|
|
158
|
+
const q = resourcesQuery.parse(queryParams(url));
|
|
159
|
+
// rooms and booking links named in the request's language (their main words without one)
|
|
160
|
+
const locale = mobileLocale(q.locale);
|
|
134
161
|
const [settings, resources, links] = await Promise.all([repo.getSettings(), repo.listResources(), repo.listBookingLinks()]);
|
|
135
162
|
const origin = siteOrigin(settings, url), start = (q.page - 1) * q.limit;
|
|
136
163
|
const visible = resources.filter(r => r.visibility === 'public');
|
|
137
164
|
return { resources: visible.slice(start, start + q.limit).map(r => ({
|
|
138
|
-
id: r.id, slug: r.slug, name: r
|
|
139
|
-
photo: safeUrl(r.photo, origin), timezone: r.timezone || settings.timezone || 'UTC', url: origin + resourceUrl(r.slug),
|
|
165
|
+
id: r.id, slug: r.slug, name: roomName(r, locale), kind: r.kind, description: r.description, capacity: r.capacity, amenities: r.amenities,
|
|
166
|
+
photo: safeUrl(r.photo, origin), timezone: r.timezone || settings.timezone || 'UTC', url: localizedUrl(origin + resourceUrl(r.slug), origin, locale),
|
|
140
167
|
links: links.filter(l => l.resourceId === r.id && l.active && l.visibility === 'public').map(l => ({
|
|
141
|
-
id: l.id, slug: l.slug,
|
|
142
|
-
askPhone: l.askPhone, autoConfirm: confirmsInstantly(linkApproval(l, settings)), url: origin + bookingLinkUrl(r.slug, l.slug),
|
|
168
|
+
id: l.id, slug: l.slug, ...linkWords(l, locale), mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
|
|
169
|
+
askPhone: l.askPhone, autoConfirm: confirmsInstantly(linkApproval(l, settings)), url: localizedUrl(origin + bookingLinkUrl(r.slug, l.slug), origin, locale),
|
|
143
170
|
questions: l.questions.map(({ id, label, type, required, options }) => ({ id, label, type, required, options })),
|
|
144
171
|
})),
|
|
145
172
|
})), pagination: { page: q.page, limit: q.limit, nextPage: visible.length > start + q.limit ? q.page + 1 : null } };
|
package/src/lib/mobile/guest.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { ApiError, readJson } from '../api';
|
|
|
5
5
|
import { sha256Hex } from '../api-keys';
|
|
6
6
|
import { randomToken, safeEqual } from '../editor/auth';
|
|
7
7
|
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
8
|
-
import { bookingManagePath } from '../booking/emails';
|
|
8
|
+
import { bookingLanguage, bookingManagePath } from '../booking/emails';
|
|
9
|
+
import { bookingTitle } from '../booking/view';
|
|
9
10
|
import { encrypt, encryptionAvailable, resolveConfig, validAddress } from '../email/config';
|
|
10
11
|
import { kickEmail } from '../email/service';
|
|
11
12
|
import { guestChanges } from '../booking/service';
|
|
@@ -144,7 +145,8 @@ export async function myBookings(c: PublicContext) {
|
|
|
144
145
|
return {
|
|
145
146
|
id: b.id, reference: b.id.slice(0, 8).toUpperCase(), kind: b.kind as 'booking' | 'rsvp', status: b.status,
|
|
146
147
|
start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(), seats: b.seats,
|
|
147
|
-
|
|
148
|
+
// a room booking in the language it was booked in, as its emails name it
|
|
149
|
+
title: event ? event.title : bookingTitle(resource, link, bookingLanguage(b, settings).locale) || 'Booking',
|
|
148
150
|
// use with the Booking-Token header on /bookings/manage/*
|
|
149
151
|
manageToken: b.manageToken, manageUrl: origin + bookingManagePath(b),
|
|
150
152
|
can: guestChanges(b, link, now),
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { bookingsEnabled, eventUrl } from '../calendar/config';
|
|
|
5
5
|
import { eventTimeZone, locationLine } from '../calendar/events';
|
|
6
6
|
import { resourceZone, linkLengths } from '../booking/availability';
|
|
7
7
|
import { bookingIcs, bookingLanguage, bookingManagePath } from '../booking/emails';
|
|
8
|
+
import { bookingTitle } from '../booking/view';
|
|
9
|
+
import { linkWords, roomName } from '../schema';
|
|
8
10
|
import { localePath } from '../i18n';
|
|
9
11
|
import { confirmOffer, guestCancel, guestChanges, guestMoveLink, guestReschedule, waitlistPosition } from '../booking/service';
|
|
10
12
|
import { siteOrigin } from '../seo';
|
|
@@ -48,6 +50,8 @@ async function managed(c: PublicContext): Promise<Managed> {
|
|
|
48
50
|
function view(m: Managed, booking = m.booking, position: number | null = null) {
|
|
49
51
|
const { resource, link, event, settings, origin } = m;
|
|
50
52
|
const can = guestChanges(booking, link);
|
|
53
|
+
// the room and booking link named in the language it was booked in, as its emails name them
|
|
54
|
+
const { locale } = bookingLanguage(booking, settings);
|
|
51
55
|
return {
|
|
52
56
|
booking: {
|
|
53
57
|
id: booking.id, reference: booking.id.slice(0, 8).toUpperCase(), kind: booking.kind as 'booking' | 'rsvp', status: booking.status,
|
|
@@ -59,10 +63,10 @@ function view(m: Managed, booking = m.booking, position: number | null = null) {
|
|
|
59
63
|
// in line: its place (1 = next); a held spot: until when
|
|
60
64
|
position, holdUntil: booking.status === 'offered' && booking.holdUntil !== null ? new Date(booking.holdUntil).toISOString() : null,
|
|
61
65
|
},
|
|
62
|
-
title: event ? event.title :
|
|
63
|
-
location: event ? locationLine(event.location) : resource
|
|
64
|
-
resource: resource ? { slug: resource.slug, name: resource
|
|
65
|
-
link: link ? { slug: link.slug, name: link.name, mode: link.mode, lengths: linkLengths(link) } : null,
|
|
66
|
+
title: event ? event.title : bookingTitle(resource, link, locale) || 'Booking',
|
|
67
|
+
location: event ? locationLine(event.location) : resource ? roomName(resource, locale) : '',
|
|
68
|
+
resource: resource ? { slug: resource.slug, name: roomName(resource, locale) } : null,
|
|
69
|
+
link: link ? { slug: link.slug, name: linkWords(link, locale).name, mode: link.mode, lengths: linkLengths(link) } : null,
|
|
66
70
|
// the web pages in the language it was booked in, as the emails link them
|
|
67
71
|
event: event ? { slug: event.slug, title: event.title, url: origin + localePath(eventUrl(event.slug), bookingLanguage(booking, settings).locale) } : null,
|
|
68
72
|
// the web page for this booking (the same private link the emails carry)
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
3
3
|
import type { ContentRepository } from '../repo/types';
|
|
4
4
|
import { contentQuery, contentDetailQuery } from './content';
|
|
5
|
-
import { eventsQuery, eventDetailQuery, availabilityQuery, roomFreeQuery } from './calendar';
|
|
5
|
+
import { eventsQuery, eventDetailQuery, availabilityQuery, roomFreeQuery, resourcesQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
7
|
import { rsvpQuery } from './rsvp';
|
|
8
8
|
import { pagesQuery } from './pages';
|
|
@@ -15,7 +15,7 @@ import { codeInput, tokenInput, myBookingsQuery } from './guest';
|
|
|
15
15
|
import { deviceInput, deviceUpdate, staffLinkInput } from '../push/api';
|
|
16
16
|
import { announcementsQuery } from '../announcements/api';
|
|
17
17
|
import { localeOnlyQuery } from './locale';
|
|
18
|
-
import {
|
|
18
|
+
import { queryParams } from './http';
|
|
19
19
|
import * as schemas from './schemas';
|
|
20
20
|
import { editorPaths, editorSchemas, editorSecuritySchemes } from '../editor/openapi';
|
|
21
21
|
|
|
@@ -52,11 +52,11 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
52
52
|
['/content', 'Published content in the requested language; q (requires search) returns ranked matches, posts before pages, up to 200 per kind', contentQuery, jsonSchema(z.object({ locale: z.string().nullable(), items: z.array(schemas.ContentSummary), pagination: schemas.Pagination }))],
|
|
53
53
|
['/content/{id}', 'Published content detail', contentDetailQuery, ref('Content')],
|
|
54
54
|
['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
|
|
55
|
-
['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
|
|
55
|
+
['/events', 'Public event occurrences; bookings=1 adds the confirmed bookings of rooms shown on the calendar (link name, time, room; never who booked); requires calendar', eventsQuery, ref('Events')],
|
|
56
56
|
['/events/{slug}', 'One public event by slug or id, whatever its date, with its next occurrence (or the one on `on`, YYYY-MM-DD; the most recent once it is over); requires calendar', eventDetailQuery, ref('EventDetail')],
|
|
57
57
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
58
58
|
['/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],
|
|
59
|
-
['/resources', 'Public resources and booking links; requires bookings',
|
|
59
|
+
['/resources', 'Public resources and booking links, named in the requested language (locale: their translated names and descriptions); requires bookings', resourcesQuery, ref('Resources')],
|
|
60
60
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
61
61
|
['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
|
|
62
62
|
['/announcements', 'Announcements up right now, newest first, in the requested language; pinned=true lists only the banners, urgent first; requires announcements', announcementsQuery, ref('Announcements')],
|
|
@@ -50,8 +50,13 @@ export const Event = CalEvent.pick({
|
|
|
50
50
|
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
51
51
|
notify: z.object({ changes: strings }),
|
|
52
52
|
});
|
|
53
|
+
// a confirmed room booking as the public sees it (bookings=1): its booking link's name in the requested
|
|
54
|
+
// language ("Reserved" without one), the time and the room; never who booked. id: an opaque key, not the booking's
|
|
55
|
+
export const ScheduleBooking = z.object({ id: str, title: str, start: date, end: date, allDay: bool, rooms: z.array(EventRoom) });
|
|
53
56
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
54
57
|
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, location: OccurrencePlace, rooms: z.array(EventRoom), event: Event })),
|
|
58
|
+
// only with bookings=1: the window's bookings, on the first page ([] on later pages)
|
|
59
|
+
bookings: z.array(ScheduleBooking).optional(),
|
|
55
60
|
});
|
|
56
61
|
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
57
62
|
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
@@ -125,7 +130,7 @@ export const GuestBookings = z.object({ email: str, pagination: Pagination, item
|
|
|
125
130
|
export const GuestSignedOut = z.object({ signedOut: z.literal(true) });
|
|
126
131
|
export const Calendars = z.object({ calendars: z.array(Calendar.pick({ id: true, slug: true, name: true, color: true, description: true, kind: true, visibility: true }).extend({ feedUrl: str })) });
|
|
127
132
|
export const Resources = z.object({ pagination: Pagination, resources: z.array(z.object({
|
|
128
|
-
id: str, slug: str, name: str, kind: str, description: str, capacity: z.number(), amenities: strings, photo: str, timezone: str, url: str,
|
|
133
|
+
id: str, slug: str, name: str, kind: str, description: str, capacity: z.number().nullable(), amenities: strings, photo: str, timezone: str, url: str,
|
|
129
134
|
links: z.array(z.object({ id: str, slug: str, name: str, description: str, mode: z.enum(['slots', 'day', 'range']), lengths: z.array(z.number()), rules: str,
|
|
130
135
|
askPhone: bool, autoConfirm: bool, url: str, questions: z.array(BookingQuestion.omit({ help: true })),
|
|
131
136
|
})),
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { siteOrigin } from '../seo';
|
|
|
6
6
|
import { siteLang } from '../dates';
|
|
7
7
|
import { SEARCH } from '../search-config';
|
|
8
8
|
import { CALENDAR } from '../calendar/config';
|
|
9
|
-
import {
|
|
9
|
+
import { eventIsSearchable, featuredOccurrence } from '../calendar/events';
|
|
10
10
|
import { localizeDoc } from '../translations';
|
|
11
11
|
import { ApiError } from '../api';
|
|
12
12
|
import { contentDto } from './content';
|
|
@@ -68,12 +68,13 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
68
68
|
const pages = want.has('page') && !q.collection ? found.pages : [];
|
|
69
69
|
const all = new Map(calendars.map((c) => [c.id, c]));
|
|
70
70
|
const listed = new Map(calendars.filter((c) => c.visibility === 'public').map((c) => [c.id, c]));
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
71
|
+
// the website's rule: any calendar that isn't private, no booking-page appointments
|
|
72
|
+
let events = want.has('event') && CALENDAR.enabled && !q.collection ? found.events.filter(({ doc }) => eventIsSearchable(doc, all)) : [];
|
|
73
|
+
// the index ranks before visibility is known: if private events or bookings
|
|
74
|
+
// filled the first batch, look deeper once (the repo's 200-row cap)
|
|
74
75
|
if (want.has('event') && CALENDAR.enabled && !q.collection && events.length <= q.limit && found.events.length >= q.limit + 1) {
|
|
75
76
|
const deeper = await repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: 200, collections: [] });
|
|
76
|
-
events = deeper.events.filter(({ doc }) =>
|
|
77
|
+
events = deeper.events.filter(({ doc }) => eventIsSearchable(doc, all));
|
|
77
78
|
}
|
|
78
79
|
const now = Date.now();
|
|
79
80
|
return {
|
|
@@ -87,7 +88,7 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
87
88
|
snippet: plainSnippet(snippet),
|
|
88
89
|
// the next occurrence, or the latest one when the event is over
|
|
89
90
|
occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources, locale || siteLang(settings))) : null,
|
|
90
|
-
event: publicEventDto(e, origin, listed, settings, locale, resources),
|
|
91
|
+
event: publicEventDto(e, origin, listed, all, settings, locale, resources),
|
|
91
92
|
};
|
|
92
93
|
}),
|
|
93
94
|
more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
|