@acms-subir/core 0.3.35 → 0.3.37
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 +25 -0
- package/docs/mobile.md +7 -4
- package/docs/theme-authoring.md +5 -4
- package/integration.mjs +3 -1
- package/mobile-template/client/AGENTS.md +2 -1
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
- package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +274 -0
- package/mobile-template/recipes/events/recipe.json +6 -3
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +274 -0
- package/mobile-template/recipes/rsvp/recipe.json +15 -0
- package/package.json +1 -1
- package/src/components/EventPage.astro +14 -8
- package/src/layouts/Site.astro +1 -0
- package/src/lib/cache/policy.ts +2 -2
- package/src/lib/calendar/rsvp-words.ts +121 -0
- package/src/lib/legal/app-privacy.ts +5 -3
- package/src/lib/legal/text-en.ts +1 -0
- package/src/lib/legal/text-es.ts +1 -0
- package/src/lib/mobile/bookings.ts +17 -11
- package/src/lib/mobile/forms.ts +21 -11
- package/src/lib/mobile/manage.ts +2 -0
- package/src/lib/mobile/openapi.ts +8 -3
- package/src/lib/mobile/rsvp.ts +73 -0
- package/src/lib/mobile/schemas.ts +9 -1
- package/src/lib/mobile/site.ts +3 -1
- package/src/lib/schema.ts +2 -1
- package/src/lib/site-chrome.ts +8 -0
- package/src/pages/admin/calendar/bookings.astro +3 -1
- package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
- package/src/pages/book/manage/[token].astro +35 -24
- package/src/pages/events/[slug].astro +7 -4
- package/src/theme-sdk/index.ts +2 -0
package/src/lib/mobile/forms.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import type { SavedForm } from '../forms/schema';
|
|
3
|
+
import type { FormDefinition, SavedForm } from '../forms/schema';
|
|
4
4
|
import { collectResponse, formVersion } from '../forms/service';
|
|
5
5
|
import { ApiError, readJson } from '../api';
|
|
6
6
|
import { sha256Hex } from '../api-keys';
|
|
@@ -24,14 +24,11 @@ async function publishedForm(repo: ContentRepository, id: string): Promise<Saved
|
|
|
24
24
|
return form;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
await requireAccess(repo, 'forms');
|
|
30
|
-
const form = await publishedForm(repo, id);
|
|
31
|
-
const d = form.definition;
|
|
27
|
+
/** A form's questions for apps; `version` is the one collectResponse accepts (also for event RSVPs). */
|
|
28
|
+
export async function formJson(d: FormDefinition) {
|
|
32
29
|
return {
|
|
33
|
-
|
|
34
|
-
title: d.title
|
|
30
|
+
version: await formVersion(d),
|
|
31
|
+
title: d.title, intro: d.intro, submitLabel: d.submitLabel, confirmation: d.confirmation,
|
|
35
32
|
fields: d.fields.map((f) => ({
|
|
36
33
|
id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
|
|
37
34
|
placeholder: f.placeholder ?? '', width: f.width ?? 'full', allowOther: f.allowOther ?? false,
|
|
@@ -39,9 +36,22 @@ export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
|
39
36
|
};
|
|
40
37
|
}
|
|
41
38
|
|
|
39
|
+
export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
40
|
+
z.object({}).strict().parse(queryParams(url));
|
|
41
|
+
await requireAccess(repo, 'forms');
|
|
42
|
+
const form = await publishedForm(repo, id);
|
|
43
|
+
const d = form.definition;
|
|
44
|
+
return { id: form.id, ...await formJson(d), title: d.title || form.name, submitLabel: d.submitLabel || 'Submit' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// field id → answer; checkbox groups send a list. "Other" answers send the typed text.
|
|
48
|
+
export const appAnswers = z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)]));
|
|
49
|
+
/** lists become the stored newline-joined form */
|
|
50
|
+
export const joinAnswers = (answers: z.infer<typeof appAnswers>): Record<string, string> =>
|
|
51
|
+
Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => x.trim()).filter(Boolean).join('\n') : v]));
|
|
52
|
+
|
|
42
53
|
export const formResponseInput = z.object({
|
|
43
|
-
|
|
44
|
-
answers: z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)])).refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
|
|
54
|
+
answers: appAnswers.refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
|
|
45
55
|
// the version from GET /forms/{id}; a changed form answers 409 instead of saving
|
|
46
56
|
version: z.string().max(100).optional(),
|
|
47
57
|
// spam trap: apps leave it out or empty
|
|
@@ -57,7 +67,7 @@ export async function submitFormResponse(c: PublicContext) {
|
|
|
57
67
|
if (body.website) throw new ApiError(400, 'Could not submit this form.');
|
|
58
68
|
const form = await publishedForm(c.repo, id);
|
|
59
69
|
if (!await c.repo.email.rateLimit(`forms:app:${await sha256Hex(`${form.id}:${c.address}`)}`, 10, 60_000)) throw new ApiError(429, 'Too many responses. Please try again in a minute.');
|
|
60
|
-
const answers =
|
|
70
|
+
const answers = joinAnswers(body.answers);
|
|
61
71
|
// the key is the stored response id: a retry of a saved response answers
|
|
62
72
|
// as before and costs nothing (also after the form changed); the same key
|
|
63
73
|
// with different answers is a 409
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -63,6 +63,8 @@ function view(m: Managed, booking = m.booking) {
|
|
|
63
63
|
// the web page for this booking (the same private link the emails carry)
|
|
64
64
|
manageUrl: origin + manageUrl(booking.manageToken),
|
|
65
65
|
can,
|
|
66
|
+
// the RSVP form as it was when they sent it (like the web page), else the booking type's message
|
|
67
|
+
confirmation: booking.formResponse?.definition.confirmation || link?.confirmMessage || null,
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
const flow = (m: Managed, c: PublicContext) => ({ origin: m.origin, locals: c.locals, waitUntil: waitUntilFrom(c.locals) });
|
|
@@ -4,6 +4,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
4
4
|
import { contentQuery, contentDetailQuery } from './content';
|
|
5
5
|
import { eventsQuery, availabilityQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
|
+
import { rsvpQuery } from './rsvp';
|
|
7
8
|
import { pagesQuery } from './pages';
|
|
8
9
|
import { searchQuery } from './search';
|
|
9
10
|
import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
@@ -33,7 +34,10 @@ const errors = Object.fromEntries([400, 404, 405, 429, 500].map(status => [statu
|
|
|
33
34
|
...(status === 429 ? { headers: { 'Retry-After': { description: 'Seconds to wait: 60 for per-minute limits, longer for hourly, daily and lockout limits.', schema: { type: 'string' } } } } : {}),
|
|
34
35
|
}]));
|
|
35
36
|
function parameters(query: z.AnyZodObject) {
|
|
36
|
-
return Object.entries(query.shape).map(([name, schema]) =>
|
|
37
|
+
return Object.entries(query.shape).map(([name, schema]) => {
|
|
38
|
+
const s = schema as z.ZodTypeAny;
|
|
39
|
+
return { name, in: 'query', required: !s.isOptional(), ...(s.description ? { description: s.description } : {}), schema: jsonSchema(s) };
|
|
40
|
+
});
|
|
37
41
|
}
|
|
38
42
|
export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
39
43
|
empty.parse(queryParams(url));
|
|
@@ -48,6 +52,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
48
52
|
['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
|
|
49
53
|
['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
|
|
50
54
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
55
|
+
['/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],
|
|
51
56
|
['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
|
|
52
57
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
53
58
|
['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
|
|
@@ -73,8 +78,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
73
78
|
}
|
|
74
79
|
paths['/api/v1/bookings'] = {
|
|
75
80
|
post: {
|
|
76
|
-
summary: 'Create a guest booking or replay an existing request;
|
|
77
|
-
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent.',
|
|
81
|
+
summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
|
|
82
|
+
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409.',
|
|
78
83
|
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
|
|
79
84
|
requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
|
|
80
85
|
responses: { ...errors,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { CalEvent } from '../schema';
|
|
4
|
+
import { ApiError } from '../api';
|
|
5
|
+
import { answerFields } from '../forms/schema';
|
|
6
|
+
import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
|
|
7
|
+
import { seatsTaken } from '../booking/service';
|
|
8
|
+
import { localizeDoc } from '../translations';
|
|
9
|
+
import { siteLang } from '../dates';
|
|
10
|
+
import { rsvpStrings } from '../calendar/rsvp-words';
|
|
11
|
+
import { queryParams } from './http';
|
|
12
|
+
import { requireCalendar } from './calendar';
|
|
13
|
+
import { formJson } from './forms';
|
|
14
|
+
import { localeQuery, mobileLocale } from './locale';
|
|
15
|
+
|
|
16
|
+
// RSVP status of one date of an event, for apps: whether it takes RSVPs now,
|
|
17
|
+
// live seats and the questions with their version. The same rules as the
|
|
18
|
+
// event's web page: open until the date ends, no deadline or waitlist.
|
|
19
|
+
// Never cached; seats change with every RSVP. POST /bookings saves one.
|
|
20
|
+
|
|
21
|
+
/** a public event (slug or id) and its occurrence starting exactly at `start` */
|
|
22
|
+
export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
|
|
23
|
+
const event = await repo.getEventBySlug(ref) ?? await repo.getEvent(ref);
|
|
24
|
+
const calendars = new Map((await repo.listCalendars()).map(c => [c.id, c]));
|
|
25
|
+
if (!event || !eventIsPublic(event, calendars)) throw new ApiError(404, 'Event not found.');
|
|
26
|
+
const settings = await repo.getSettings();
|
|
27
|
+
const at = Date.parse(start);
|
|
28
|
+
const occurrence = expandEvents([event], settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: at, windowEndMs: at + 1 }).find(o => o.start === at);
|
|
29
|
+
if (!occurrence) throw new ApiError(400, 'Start must match an event occurrence exactly.');
|
|
30
|
+
return occurrence;
|
|
31
|
+
}
|
|
32
|
+
/** the RSVP form when it asks something; apps must then send its version */
|
|
33
|
+
export const rsvpQuestions = (e: CalEvent) => e.rsvp.form && answerFields(e.rsvp.form).length ? e.rsvp.form : null;
|
|
34
|
+
/** why this date takes no RSVPs (seats aside), or null */
|
|
35
|
+
export function rsvpClosed(o: Occurrence, now = Date.now()) {
|
|
36
|
+
if (!o.event.rsvp.enabled) return 'disabled' as const;
|
|
37
|
+
if (o.cancelled) return 'cancelled' as const;
|
|
38
|
+
if (o.end <= now) return 'ended' as const;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const rsvpQuery = z.object({
|
|
43
|
+
start: z.string().datetime({ offset: true }).describe('The occurrence’s start exactly as GET /events returns it, a UTC ISO date-time such as 2026-10-05T14:00:00.000Z. A start that isn’t one of the event’s dates answers 400.'),
|
|
44
|
+
locale: localeQuery,
|
|
45
|
+
}).strict();
|
|
46
|
+
export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: string) {
|
|
47
|
+
requireCalendar();
|
|
48
|
+
const q = rsvpQuery.parse(queryParams(url));
|
|
49
|
+
const locale = mobileLocale(q.locale);
|
|
50
|
+
const o = await rsvpOccurrence(repo, slug, q.start);
|
|
51
|
+
const { rsvp } = o.event;
|
|
52
|
+
// pending and confirmed RSVPs hold seats, as in the capacity check
|
|
53
|
+
const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
|
|
54
|
+
const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
|
|
55
|
+
const closed = rsvpClosed(o);
|
|
56
|
+
const reason = closed ?? (left === 0 ? 'full' as const : null);
|
|
57
|
+
const form = rsvpQuestions(o.event);
|
|
58
|
+
const title = localizeDoc(o.event, locale ?? undefined).title;
|
|
59
|
+
// the built-in button speaks the app's language (the site's own without languages set up)
|
|
60
|
+
let submitLabel = form?.submitLabel ?? '';
|
|
61
|
+
if (form && !submitLabel) {
|
|
62
|
+
const words = rsvpStrings(locale ?? siteLang(await repo.getSettings()));
|
|
63
|
+
submitLabel = rsvp.autoConfirm ? words.confirmSeat : words.requestSeat;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
event: { id: o.event.id, slug: o.event.slug, title },
|
|
67
|
+
start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
68
|
+
open: !reason, reason, going, left, capacity: rsvp.capacity,
|
|
69
|
+
// seats one RSVP may take right now
|
|
70
|
+
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
71
|
+
form: form ? { ...await formJson(form), submitLabel } : null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -8,7 +8,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
|
|
|
8
8
|
export const Site = z.object({
|
|
9
9
|
name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
|
|
10
10
|
languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
|
|
11
|
-
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
|
|
11
|
+
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
|
|
12
12
|
legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
|
|
13
13
|
});
|
|
14
14
|
export const Theme = z.object({
|
|
@@ -68,6 +68,12 @@ export const Form = z.object({
|
|
|
68
68
|
})),
|
|
69
69
|
});
|
|
70
70
|
export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
|
|
71
|
+
export const RsvpStatus = z.object({
|
|
72
|
+
event: z.object({ id: str, slug: str, title: str }), start: date, end: date,
|
|
73
|
+
open: bool, reason: z.enum(['disabled', 'ended', 'cancelled', 'full']).nullable(),
|
|
74
|
+
going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
|
|
75
|
+
form: Form.omit({ id: true }).nullable(),
|
|
76
|
+
});
|
|
71
77
|
const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
|
|
72
78
|
export const ManagedBooking = z.object({
|
|
73
79
|
booking: z.object({
|
|
@@ -79,6 +85,8 @@ export const ManagedBooking = z.object({
|
|
|
79
85
|
link: z.object({ slug: str, name: str, mode: z.enum(['slots', 'range', 'day']), lengths: z.array(z.number()) }).nullable(),
|
|
80
86
|
event: z.object({ slug: str, title: str, url: str }).nullable(),
|
|
81
87
|
manageUrl: str, can: GuestChanges,
|
|
88
|
+
// what the web page says once it's confirmed: the RSVP form's or booking type's message
|
|
89
|
+
confirmation: str.nullable(),
|
|
82
90
|
});
|
|
83
91
|
export const GuestCodeSent = z.object({ sent: z.literal(true), expiresInMinutes: z.number().int() });
|
|
84
92
|
export const GuestToken = z.object({ accessToken: str, tokenType: z.literal('Bearer'), email: str, idleDays: z.number().int() });
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -37,7 +37,9 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
37
37
|
features: {
|
|
38
38
|
// redirectFrom: old addresses the website 301s to url, so apps can follow them too,
|
|
39
39
|
// minus any another collection now lives at (the website serves that one first)
|
|
40
|
-
blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin), redirectFrom: legacyBases(key, c).filter(from => !bases.has(from)).map(from => safeUrl(from, origin)) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(),
|
|
40
|
+
blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin), redirectFrom: legacyBases(key, c).filter(from => !bases.has(from)).map(from => safeUrl(from, origin)) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(),
|
|
41
|
+
// RSVPs from apps (GET /events/{slug}/rsvp, POST /bookings) come with the calendar
|
|
42
|
+
rsvp: CALENDAR.enabled, search: SEARCH.enabled,
|
|
41
43
|
// Admin → Mobile app → App access
|
|
42
44
|
bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
|
|
43
45
|
forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
|
package/src/lib/schema.ts
CHANGED
|
@@ -672,7 +672,8 @@ export const Booking = z.object({
|
|
|
672
672
|
formResponse: FormResponse.optional(),
|
|
673
673
|
notes: z.string().default(''), // staff-only
|
|
674
674
|
manageToken: z.string().default(''),
|
|
675
|
-
|
|
675
|
+
// web: the website; app: the public app API (/api/v1/bookings)
|
|
676
|
+
source: z.enum(['web', 'app', 'admin', 'api', 'agent']).default('web'),
|
|
676
677
|
reminderSentAt: z.number().nullable().default(null),
|
|
677
678
|
notificationVersion: z.number().default(0),
|
|
678
679
|
createdAt: z.number(),
|
package/src/lib/site-chrome.ts
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
// [data-scheme-toggle] flips light/dark (when site.schemeToggle is true)
|
|
9
9
|
// [data-menu-open] opens the phone menu sheet (built from site.links)
|
|
10
10
|
// [data-live-header] (hidden) becomes the "Live" link during a stream
|
|
11
|
+
//
|
|
12
|
+
// `words` are core's own labels in the page's language (skip link, menu and
|
|
13
|
+
// dark-mode buttons, landmark and footer labels), so a custom header labels
|
|
14
|
+
// its controls the same way the built-in one does.
|
|
15
|
+
import type { SiteStrings } from './site-words';
|
|
16
|
+
|
|
11
17
|
export interface ChromeLink {
|
|
12
18
|
label: string;
|
|
13
19
|
url: string;
|
|
@@ -32,4 +38,6 @@ export interface SiteChrome {
|
|
|
32
38
|
liveHeader: boolean;
|
|
33
39
|
headerStyle: string; // theme token `header`
|
|
34
40
|
footerStyle: string; // theme token `footer`
|
|
41
|
+
lang: string; // the page's language, as on <html lang>
|
|
42
|
+
words: SiteStrings;
|
|
35
43
|
}
|
|
@@ -185,6 +185,8 @@ const emailNotice = !emailConfig.config.enabled || emailConfig.config.provider =
|
|
|
185
185
|
: emailStatus.linkError;
|
|
186
186
|
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
187
187
|
const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
|
|
188
|
+
// where it was made: the website, an app (/api/v1/bookings), staff, an API key or the booking agent
|
|
189
|
+
const SOURCES: Record<Booking['source'], string> = { web: 'Website', app: 'App', admin: 'Staff', api: 'API', agent: 'Agent' };
|
|
188
190
|
const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
|
|
189
191
|
? `${f.day.format(b.start)} – ${f.day.format(b.end - 1)}`
|
|
190
192
|
: b.end - b.start >= 86_400_000 - 3_600_000 ? 'All day' : `${f.time.format(b.start)} – ${f.time.format(b.end)}`);
|
|
@@ -327,7 +329,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
327
329
|
<div><dt>{ev ? 'Event' : 'Where'}</dt><dd>{ev ? <a href={eventUrl(ev.slug)} target="_blank" rel="noopener">{ev.title}</a> : r?.name ?? '—'}{l ? ` · ${l.name}` : ''}</dd></div>
|
|
328
330
|
{b.seats > 1 && <div><dt>Seats</dt><dd>{b.seats}</dd></div>}
|
|
329
331
|
{Object.entries(b.answers).map(([k, v]) => <div><dt>{k}</dt><dd>{v}</dd></div>)}
|
|
330
|
-
<div><dt>Booked</dt><dd>{f.day.format(b.createdAt)} · {b.source}</dd></div>
|
|
332
|
+
<div><dt>Booked</dt><dd>{f.day.format(b.createdAt)} · {SOURCES[b.source] ?? b.source}</dd></div>
|
|
331
333
|
</dl>
|
|
332
334
|
<form method="post" class="bk-notes">
|
|
333
335
|
<input type="hidden" name="op" value="notes" /><input type="hidden" name="id" value={b.id} />
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getRsvpStatus } from '../../../../../lib/mobile/rsvp';
|
|
2
|
+
import { readRoute } from '../../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
5
|
+
// no-store: live seats; a cached "3 left" would invite RSVPs that fail
|
|
6
|
+
export const GET = readRoute(getRsvpStatus, false);
|
|
7
|
+
export const HEAD = GET;
|
|
8
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -8,21 +8,32 @@ import { resourceZone } from '../../../lib/booking/availability';
|
|
|
8
8
|
import { eventTimeZone, locationLine } from '../../../lib/calendar/events';
|
|
9
9
|
import { siteOrigin } from '../../../lib/seo';
|
|
10
10
|
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
11
|
+
import { I18N, localePath, requestLocale } from '../../../lib/i18n';
|
|
12
|
+
import { siteLang } from '../../../lib/dates';
|
|
13
|
+
import { isExtraLocale, localizeDoc } from '../../../lib/translations';
|
|
14
|
+
import { rsvpStrings } from '../../../lib/calendar/rsvp-words';
|
|
15
|
+
import { fill } from '../../../lib/live/words';
|
|
11
16
|
|
|
12
17
|
// The booker's own page, reached from the confirmation and every email: see
|
|
13
18
|
// the status, add it to a calendar, move it or cancel it. The token in the
|
|
14
|
-
// URL is the only credential — it's long and random, and never listed.
|
|
19
|
+
// URL is the only credential — it's long and random, and never listed. An
|
|
20
|
+
// RSVP's page speaks the page's language (/es/book/manage/… in Spanish);
|
|
21
|
+
// room bookings stay in English like the rest of their pages.
|
|
15
22
|
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
16
23
|
const repo = await getRepo(Astro.locals);
|
|
17
24
|
const booking = await repo.getBookingByToken(Astro.params.token ?? '');
|
|
18
25
|
if (!booking || (!bookingsEnabled() && booking.kind !== 'rsvp')) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
26
|
const settings = await repo.getSettings();
|
|
20
27
|
const origin = siteOrigin(settings, Astro.url);
|
|
21
|
-
const [resource, link,
|
|
28
|
+
const [resource, link, rawEvent] = await Promise.all([
|
|
22
29
|
booking.resourceId ? repo.getResource(booking.resourceId) : Promise.resolve(null),
|
|
23
30
|
booking.linkId ? repo.getBookingLink(booking.linkId) : Promise.resolve(null),
|
|
24
31
|
booking.eventId ? repo.getEvent(booking.eventId) : Promise.resolve(null),
|
|
25
32
|
]);
|
|
33
|
+
const isRsvp = booking.kind === 'rsvp';
|
|
34
|
+
const locale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
35
|
+
const w = rsvpStrings(isRsvp ? locale || siteLang(settings) : 'en');
|
|
36
|
+
const event = rawEvent && localizeDoc(rawEvent, locale || undefined);
|
|
26
37
|
const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking';
|
|
27
38
|
const location = event ? locationLine(event.location) : resource?.name ?? '';
|
|
28
39
|
const url = origin + Astro.url.pathname;
|
|
@@ -40,25 +51,25 @@ if (Astro.request.method === 'POST') {
|
|
|
40
51
|
try {
|
|
41
52
|
current = await guestCancel(repo, booking, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
|
|
42
53
|
} catch (err) {
|
|
43
|
-
error = err instanceof BookingError ? err.message :
|
|
54
|
+
error = err instanceof BookingError ? err.message : w.cancelError;
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
const tz = event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
|
|
48
|
-
const when = whenText(current, tz, settings, link?.mode === 'day' || event?.allDay);
|
|
59
|
+
const when = whenText(current, tz, isRsvp && isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
|
|
49
60
|
const active = current.status === 'pending' || current.status === 'confirmed';
|
|
50
61
|
const upcoming = current.end > Date.now();
|
|
51
62
|
const can = guestChanges(current, link);
|
|
52
63
|
const isNew = Astro.url.searchParams.has('new');
|
|
53
64
|
const moved = Astro.url.searchParams.has('moved');
|
|
54
|
-
const heading = current.status === 'cancelled' ?
|
|
55
|
-
: current.status === 'declined' ?
|
|
56
|
-
: current.status === 'pending' ? (isNew ?
|
|
57
|
-
: moved ? 'Booking moved' : isNew ?
|
|
58
|
-
const lede = current.status === 'pending' ?
|
|
59
|
-
: current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage ||
|
|
60
|
-
: current.status === 'declined' ? 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.'
|
|
61
|
-
:
|
|
65
|
+
const heading = current.status === 'cancelled' ? w.cancelledTitle
|
|
66
|
+
: current.status === 'declined' ? w.notAvailable
|
|
67
|
+
: current.status === 'pending' ? (isNew ? w.requestSent : w.awaitingApproval)
|
|
68
|
+
: moved ? 'Booking moved' : isNew ? w.booked : w.yours;
|
|
69
|
+
const lede = current.status === 'pending' ? fill(w.emailWhenApproved, { email: current.email })
|
|
70
|
+
: current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || fill(w.confirmationOnItsWay, { email: current.email }))
|
|
71
|
+
: current.status === 'declined' ? (isRsvp ? w.declined : 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.')
|
|
72
|
+
: w.cancelledText;
|
|
62
73
|
const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchParams({ action: 'TEMPLATE', text: title, dates: `${new Date(current.start).toISOString().replace(/[-:]|\.\d{3}/g, '')}/${new Date(current.end).toISOString().replace(/[-:]|\.\d{3}/g, '')}`, details: url, location })}`;
|
|
63
74
|
---
|
|
64
75
|
|
|
@@ -73,22 +84,22 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
|
|
|
73
84
|
<p class="pc-manage-lede">{lede}</p>
|
|
74
85
|
{error && <p class="pc-bk-error">{error}</p>}
|
|
75
86
|
<dl class="pc-manage-card">
|
|
76
|
-
<div><dt>
|
|
77
|
-
<div><dt>
|
|
78
|
-
{location && <div><dt>
|
|
79
|
-
{current.seats > 1 && <div><dt>
|
|
80
|
-
<div><dt>
|
|
81
|
-
<div><dt>
|
|
87
|
+
<div><dt>{w.what}</dt><dd>{title}</dd></div>
|
|
88
|
+
<div><dt>{w.when}</dt><dd>{when}</dd></div>
|
|
89
|
+
{location && <div><dt>{w.where}</dt><dd>{location}</dd></div>}
|
|
90
|
+
{current.seats > 1 && <div><dt>{w.seatsField}</dt><dd>{current.seats}</dd></div>}
|
|
91
|
+
<div><dt>{w.status}</dt><dd class={`pc-manage-status is-${current.status}`}>{w.statuses[current.status]}</dd></div>
|
|
92
|
+
<div><dt>{w.reference}</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
|
|
82
93
|
</dl>
|
|
83
94
|
{active && upcoming && (
|
|
84
95
|
<div class="pc-manage-actions">
|
|
85
96
|
{current.status === 'confirmed' && (
|
|
86
97
|
<details class="pc-ev-add">
|
|
87
|
-
<summary>
|
|
98
|
+
<summary>{w.addToCalendar}</summary>
|
|
88
99
|
<div class="pc-ev-add-menu">
|
|
89
100
|
<a href={googleUrl} target="_blank" rel="noopener">Google Calendar</a>
|
|
90
101
|
<a href="?ics=1">Apple Calendar</a>
|
|
91
|
-
<a href="?ics=1" download="booking.ics">
|
|
102
|
+
<a href="?ics=1" download="booking.ics">{w.downloadIcs}</a>
|
|
92
103
|
</div>
|
|
93
104
|
</details>
|
|
94
105
|
)}
|
|
@@ -96,17 +107,17 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
|
|
|
96
107
|
<a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>Reschedule</a>
|
|
97
108
|
)}
|
|
98
109
|
{can.cancel && (
|
|
99
|
-
<form method="post" onsubmit=
|
|
110
|
+
<form method="post" onsubmit={`return confirm(${JSON.stringify(isRsvp ? w.cancelConfirm : 'Cancel this booking?')})`}>
|
|
100
111
|
<input type="hidden" name="op" value="cancel" />
|
|
101
|
-
<button class="pc-manage-btn is-danger" type="submit">
|
|
112
|
+
<button class="pc-manage-btn is-danger" type="submit">{isRsvp ? w.cancel : 'Cancel booking'}</button>
|
|
102
113
|
</form>
|
|
103
114
|
)}
|
|
104
115
|
</div>
|
|
105
116
|
)}
|
|
106
117
|
{can.closed && <p class="pc-manage-note">Changes close {can.cutoffHours} hours before the start. Please contact us to change or cancel.</p>}
|
|
107
118
|
<p class="pc-bk-foot">
|
|
108
|
-
{event ? <a href={eventUrl(event.slug)}>
|
|
119
|
+
{event ? <a href={localePath(eventUrl(event.slug), locale)}>{fill(w.backTo, { title: event.title })}</a> : resource ? <a href={resourceUrl(resource.slug)}>{fill(w.backTo, { title: resource.name })}</a> : null}
|
|
109
120
|
</p>
|
|
110
|
-
<p class="pc-manage-note">
|
|
121
|
+
<p class="pc-manage-note">{w.keepLink}</p>
|
|
111
122
|
</div>
|
|
112
123
|
</Site>
|
|
@@ -5,7 +5,9 @@ import { formVersion } from '../../lib/forms/service';
|
|
|
5
5
|
import { renderFields, hiddenFields } from '../../lib/forms/render';
|
|
6
6
|
import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
|
|
7
7
|
import { submitEventForm, prepareLegacyEventForm } from '../../lib/forms/events';
|
|
8
|
-
import { requestLocale } from '../../lib/i18n';
|
|
8
|
+
import { I18N, localePath, requestLocale } from '../../lib/i18n';
|
|
9
|
+
import { siteLang } from '../../lib/dates';
|
|
10
|
+
import { rsvpStrings } from '../../lib/calendar/rsvp-words';
|
|
9
11
|
import { localizeDoc, isExtraLocale } from '../../lib/translations';
|
|
10
12
|
import Site from '../../layouts/Site.astro';
|
|
11
13
|
import EventPage from '../../components/EventPage.astro';
|
|
@@ -45,7 +47,8 @@ const on = Astro.url.searchParams.get('on') ?? undefined;
|
|
|
45
47
|
const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-\d{2}$/.test(on) ? on : undefined);
|
|
46
48
|
if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
47
49
|
const origin = siteOrigin(settings, Astro.url);
|
|
48
|
-
// RSVP: "Save my seat" posts back here and lands on the manage page
|
|
50
|
+
// RSVP: "Save my seat" posts back here and lands on the manage page, in the
|
|
51
|
+
// visitor's language
|
|
49
52
|
let rsvpError = '';
|
|
50
53
|
let formValues: Record<string,string> = {};
|
|
51
54
|
let coreValues = { name: '', email: '', seats: '1' };
|
|
@@ -61,9 +64,9 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
|
|
|
61
64
|
formValues = readAnswers(form, definition);
|
|
62
65
|
coreValues = { name: String(form.get('name') || '').slice(0,120), email: String(form.get('email') || '').slice(0,200), seats: String(form.get('seats') || '1') };
|
|
63
66
|
const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
|
|
64
|
-
return Astro.redirect(`${manageUrl(booking.manageToken)}?new=1`, 303);
|
|
67
|
+
return Astro.redirect(`${localePath(manageUrl(booking.manageToken), locale)}?new=1`, 303);
|
|
65
68
|
} catch (err) {
|
|
66
|
-
rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message :
|
|
69
|
+
rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings)).saveError;
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
|
package/src/theme-sdk/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface SectionProps<D = Record<string, unknown>> { data: D; variant: s
|
|
|
22
22
|
export type { LinkValue, EventItem, PostItem, PostAuthor, BookingItem } from '../lib/sections';
|
|
23
23
|
/** Props for templates/header.astro and templates/footer.astro. */
|
|
24
24
|
export type { SiteChrome, ChromeLink } from '../lib/site-chrome';
|
|
25
|
+
// core's chrome labels for any language (a template already gets them as site.words)
|
|
26
|
+
export { siteStrings, type SiteStrings } from '../lib/site-words';
|
|
25
27
|
import type { SiteChrome } from '../lib/site-chrome';
|
|
26
28
|
export interface ChromeProps { site: SiteChrome }
|
|
27
29
|
|