@acms-subir/core 0.3.62 → 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 +14 -0
- package/cli/migrations.generated.mjs +8 -0
- package/docs/calendar.md +57 -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/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/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 +8 -0
- 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
|
@@ -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
|
|
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,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
|
})),
|
|
@@ -748,6 +748,13 @@ export const MIGRATIONS: Migration[] = [
|
|
|
748
748
|
`DROP TRIGGER content_version_resources_update`,
|
|
749
749
|
contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'])[2],
|
|
750
750
|
] },
|
|
751
|
+
// a booking link's name and description in other languages
|
|
752
|
+
// (BookingLink.translations): public, so a change refreshes cached pages and API answers
|
|
753
|
+
{ version: 72, statements: [
|
|
754
|
+
`ALTER TABLE booking_links ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
755
|
+
`DROP TRIGGER content_version_booking_links_update`,
|
|
756
|
+
contentVersionTriggers('booking_links', [...CONTENT_VERSION_COLUMNS.booking_links, 'translations'])[2],
|
|
757
|
+
] },
|
|
751
758
|
];
|
|
752
759
|
|
|
753
760
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
@@ -756,6 +763,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
|
756
763
|
settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
|
|
757
764
|
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'],
|
|
758
765
|
resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'],
|
|
766
|
+
booking_links: [...CONTENT_VERSION_COLUMNS.booking_links, 'translations'],
|
|
759
767
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
|
|
760
768
|
};
|
|
761
769
|
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -230,10 +230,15 @@ function rowToLink2(r: Row): BookingLink {
|
|
|
230
230
|
hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
|
|
231
231
|
approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
|
|
232
232
|
askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
|
|
233
|
-
redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
|
|
233
|
+
redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0), translations: linkTranslations(r.translations),
|
|
234
234
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
235
235
|
});
|
|
236
236
|
}
|
|
237
|
+
// a booking link's words in other languages; a bad entry (or a broken column) reads as untranslated
|
|
238
|
+
function linkTranslations(raw: unknown): BookingLink['translations'] {
|
|
239
|
+
const parsed = BookingLink.shape.translations.safeParse(json(raw, {}));
|
|
240
|
+
return parsed.success ? parsed.data : {};
|
|
241
|
+
}
|
|
237
242
|
function rowToBooking(r: Row): Booking {
|
|
238
243
|
return Booking.parse({
|
|
239
244
|
id: r.id, kind: r.kind ?? 'booking', resourceId: r.resource_id ?? '', linkId: r.link_id ?? '', eventId: r.event_id ?? '',
|
|
@@ -1175,11 +1180,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
1175
1180
|
return {
|
|
1176
1181
|
sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
|
|
1177
1182
|
buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
|
|
1178
|
-
active, ord, created_at, updated_at, approval, approval_rules, change_cutoff) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1183
|
+
active, ord, created_at, updated_at, approval, approval_rules, change_cutoff, translations) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1179
1184
|
args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
|
|
1180
1185
|
l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
|
|
1181
1186
|
l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
|
|
1182
|
-
l.approval, JSON.stringify(l.approvalRules), l.changeCutoff],
|
|
1187
|
+
l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, JSON.stringify(l.translations)],
|
|
1183
1188
|
};
|
|
1184
1189
|
}
|
|
1185
1190
|
private bookingInsert(b: Booking): SqlStatement {
|
|
@@ -1271,10 +1276,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1271
1276
|
await this.driver.run(
|
|
1272
1277
|
`UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
|
|
1273
1278
|
buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
|
|
1274
|
-
ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ? WHERE id = ?`,
|
|
1279
|
+
ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ?, translations = ? WHERE id = ?`,
|
|
1275
1280
|
[l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
|
|
1276
1281
|
l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
|
|
1277
|
-
JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, id]
|
|
1282
|
+
JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, JSON.stringify(l.translations), id]
|
|
1278
1283
|
);
|
|
1279
1284
|
}
|
|
1280
1285
|
async deleteBookingLink(id: string) {
|
package/src/lib/schema.ts
CHANGED
|
@@ -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>
|