@acms-subir/core 0.3.62 → 0.3.64
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 +27 -0
- package/cli/migrations.generated.mjs +31 -1
- package/docs/calendar.md +61 -1
- package/docs/google-calendar.md +81 -4
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
- package/mobile-template/recipes/booking/files/app/book/details.tsx +35 -25
- package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
- package/mobile-template/recipes/booking/files/app/book/manage.tsx +93 -0
- package/mobile-template/recipes/booking/recipe.json +6 -3
- package/package.json +1 -1
- 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/google/bookings.ts +296 -0
- package/src/lib/calendar/google/inbound.ts +7 -1
- package/src/lib/calendar/google/links.ts +3 -0
- package/src/lib/calendar/google/map.ts +9 -5
- package/src/lib/calendar/google/match.ts +6 -2
- package/src/lib/calendar/google/migration.ts +34 -0
- package/src/lib/calendar/google/outbound.ts +9 -2
- package/src/lib/calendar/google/store.ts +38 -5
- package/src/lib/calendar/google/types.ts +17 -0
- package/src/lib/display/board.ts +5 -3
- package/src/lib/editor/schedule.ts +2 -0
- package/src/lib/mobile/calendar.ts +30 -6
- 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/repo/migrations.ts +13 -1
- package/src/lib/repo/sql.ts +10 -5
- package/src/lib/schema.ts +19 -5
- package/src/lib/sections.ts +4 -2
- package/src/pages/admin/calendar/resources/link.astro +14 -1
- 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
|
@@ -1,6 +1,6 @@
|
|
|
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';
|
|
@@ -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,6 +42,7 @@ 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).'),
|
|
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).'),
|
|
43
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();
|
|
@@ -82,9 +85,27 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
82
85
|
...occurrenceDto(o, resources, locale || siteLang(settings)),
|
|
83
86
|
event: publicEventDto(o.event, origin, publicCalendars, all, settings, locale, resources),
|
|
84
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)) : [] } : {}),
|
|
85
90
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
86
91
|
};
|
|
87
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
|
+
}
|
|
88
109
|
// one event by slug (or id), whatever its date: apps open it from a link, a
|
|
89
110
|
// push or an announcement, often weeks ahead. `on` ('YYYY-MM-DD', like the
|
|
90
111
|
// website's ?on=) picks that day's occurrence; otherwise the next one, or the
|
|
@@ -131,18 +152,21 @@ export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map
|
|
|
131
152
|
// which push topics hear about date, time or place changes ([] = none do)
|
|
132
153
|
return { ...event, notify: { changes: changeTopics(e, settings, all) } };
|
|
133
154
|
}
|
|
155
|
+
export const resourcesQuery = z.object({ ...pageQuery, locale: localeQuery }).strict();
|
|
134
156
|
export async function listResources(repo: ContentRepository, url: URL) {
|
|
135
157
|
requireCalendar(true);
|
|
136
|
-
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);
|
|
137
161
|
const [settings, resources, links] = await Promise.all([repo.getSettings(), repo.listResources(), repo.listBookingLinks()]);
|
|
138
162
|
const origin = siteOrigin(settings, url), start = (q.page - 1) * q.limit;
|
|
139
163
|
const visible = resources.filter(r => r.visibility === 'public');
|
|
140
164
|
return { resources: visible.slice(start, start + q.limit).map(r => ({
|
|
141
|
-
id: r.id, slug: r.slug, name: r
|
|
142
|
-
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),
|
|
143
167
|
links: links.filter(l => l.resourceId === r.id && l.active && l.visibility === 'public').map(l => ({
|
|
144
|
-
id: l.id, slug: l.slug,
|
|
145
|
-
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),
|
|
146
170
|
questions: l.questions.map(({ id, label, type, required, options }) => ({ id, label, type, required, options })),
|
|
147
171
|
})),
|
|
148
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
|
})),
|
|
@@ -12,7 +12,7 @@ import { SYNC_MIGRATION } from '../sync/migration';
|
|
|
12
12
|
import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
|
|
13
13
|
import { SCREENS_MIGRATION } from '../display/store';
|
|
14
14
|
import { REQUEST_TABLE } from '../calendar/request-migration';
|
|
15
|
-
import { GCAL_MIGRATION } from '../calendar/google/migration';
|
|
15
|
+
import { GCAL_BOOKINGS_MIGRATION, GCAL_MIGRATION } from '../calendar/google/migration';
|
|
16
16
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
17
17
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
18
18
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -748,6 +748,17 @@ 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
|
+
] },
|
|
758
|
+
// copies of confirmed room bookings in Google (lib/calendar/google/bookings.ts):
|
|
759
|
+
// their pairs, and triggers on bookings and booking links feeding gcal_queue.
|
|
760
|
+
// Not public, so no cache trigger.
|
|
761
|
+
GCAL_BOOKINGS_MIGRATION,
|
|
751
762
|
];
|
|
752
763
|
|
|
753
764
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
@@ -756,6 +767,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
|
756
767
|
settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
|
|
757
768
|
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'],
|
|
758
769
|
resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'],
|
|
770
|
+
booking_links: [...CONTENT_VERSION_COLUMNS.booking_links, 'translations'],
|
|
759
771
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
|
|
760
772
|
};
|
|
761
773
|
|
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
|
@@ -584,6 +584,15 @@ export const DateOverride = z.object({
|
|
|
584
584
|
});
|
|
585
585
|
export type DateOverride = z.infer<typeof DateOverride>;
|
|
586
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
|
+
|
|
587
596
|
export const Resource = z.object({
|
|
588
597
|
id: z.string(),
|
|
589
598
|
slug: z.string(),
|
|
@@ -614,7 +623,7 @@ export const Resource = z.object({
|
|
|
614
623
|
requestable: z.boolean().default(false),
|
|
615
624
|
// its name in the site's other languages ({ es: { name: 'Salón de jóvenes' } });
|
|
616
625
|
// roomName() picks one, else `name`. The slug stays the same in every language.
|
|
617
|
-
translations: z.record(
|
|
626
|
+
translations: z.record(LocaleKey, z.object({ name: z.string().max(120).default('') })).default({}),
|
|
618
627
|
// secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
|
|
619
628
|
feedToken: z.string().default(''),
|
|
620
629
|
// the saved place this room is in ('' = none)
|
|
@@ -628,10 +637,7 @@ export type Resource = z.infer<typeof Resource>;
|
|
|
628
637
|
export const roomColor = (r: Pick<Resource, 'color'>) => (/^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '');
|
|
629
638
|
/** A room's name in `locale` ('es', 'es-MX' → its 'es' name), else its main name. */
|
|
630
639
|
export function roomName(r: Pick<Resource, 'name'> & { translations?: Resource['translations'] }, locale?: string | null): string {
|
|
631
|
-
|
|
632
|
-
const want = locale.toLowerCase();
|
|
633
|
-
const t = r.translations[want] ?? r.translations[want.split('-')[0]];
|
|
634
|
-
return t?.name.trim() || r.name;
|
|
640
|
+
return forLocale(r.translations, locale)?.name.trim() || r.name;
|
|
635
641
|
}
|
|
636
642
|
|
|
637
643
|
// A saved place (HQ, the park pavilion…): picked in an event's Where field,
|
|
@@ -804,11 +810,19 @@ export const BookingLink = z.object({
|
|
|
804
810
|
confirmMessage: z.string().default(''),
|
|
805
811
|
redirectUrl: z.string().default(''),
|
|
806
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({}),
|
|
807
816
|
order: z.number().default(0),
|
|
808
817
|
createdAt: z.number(),
|
|
809
818
|
updatedAt: z.number(),
|
|
810
819
|
});
|
|
811
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
|
+
}
|
|
812
826
|
|
|
813
827
|
// RSVPs only: waitlisted = in line for a full date (takes no seats); offered =
|
|
814
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">
|
|
@@ -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 }, '*'); };
|