@acms-subir/core 0.3.36 → 0.3.38
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 +21 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/theme-authoring.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +54 -47
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +54 -47
- package/mobile-template/recipes/rsvp/recipe.json +2 -1
- package/package.json +1 -1
- package/src/components/EventPage.astro +50 -30
- package/src/lib/booking/emails.ts +59 -44
- package/src/lib/booking/service.ts +22 -11
- package/src/lib/calendar/cards.ts +5 -3
- package/src/lib/calendar/event-words.ts +171 -0
- package/src/lib/calendar/events.ts +5 -2
- package/src/lib/calendar/render.ts +3 -1
- package/src/lib/calendar/rrule.ts +14 -18
- package/src/lib/calendar/rsvp-words.ts +121 -0
- package/src/lib/email/booking-words.ts +203 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/mobile/bookings.ts +9 -2
- package/src/lib/mobile/calendar.ts +6 -4
- package/src/lib/mobile/guest.ts +3 -2
- package/src/lib/mobile/manage.ts +7 -5
- package/src/lib/mobile/rsvp.ts +9 -1
- package/src/lib/mobile/schemas.ts +2 -0
- package/src/lib/mobile/search.ts +1 -1
- package/src/lib/mobile/site.ts +7 -3
- package/src/lib/push/service.ts +10 -0
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +4 -4
- package/src/lib/repo/types.ts +2 -1
- package/src/lib/schema.ts +3 -0
- package/src/pages/book/[resource]/[link].astro +8 -4
- package/src/pages/book/manage/[token].astro +48 -29
- package/src/pages/events/[slug].astro +8 -5
- package/src/theme-sdk/index.ts +3 -0
package/src/lib/forms/events.ts
CHANGED
|
@@ -17,7 +17,8 @@ export async function prepareLegacyEventForm(event: CalEvent, start: number, dat
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// locale: the page's language, so the RSVP's emails and links speak it
|
|
21
|
+
export async function submitEventForm(repo: ContentRepository, event: CalEvent, occurrence: { start: number; end: number }, data: FormData, requestId: string, flow: Parameters<typeof createRsvp>[2], locale = '') {
|
|
21
22
|
if (event.status !== 'published') throw new ApiError(404, 'Event not found.');
|
|
22
23
|
if (Number(data.get('occurrence')) !== occurrence.start) throw new ApiError(409, 'The event time has changed. Review the date and try again.');
|
|
23
24
|
const seats = Number(data.get('seats') || 1);
|
|
@@ -35,6 +36,7 @@ export async function submitEventForm(repo: ContentRepository, event: CalEvent,
|
|
|
35
36
|
};
|
|
36
37
|
const existing = await replay(); if (existing) return existing;
|
|
37
38
|
await collectResponse(definition, booker.answers, booker.formVersion);
|
|
38
|
-
|
|
39
|
+
// the language isn't part of the retry check: it doesn't change what was asked for
|
|
40
|
+
try { return await createRsvp(repo, { event, ...occurrence, seats, booker: { ...booker, locale } }, { ...flow, idempotency }); }
|
|
39
41
|
catch (e) { const existing = await replay(); if (existing) return existing; throw e; }
|
|
40
42
|
}
|
|
@@ -9,12 +9,15 @@ import { waitUntilFrom } from '../webhooks';
|
|
|
9
9
|
import { requireCalendar } from './calendar';
|
|
10
10
|
import { appAnswers, joinAnswers } from './forms';
|
|
11
11
|
import { rsvpClosed, rsvpOccurrence, rsvpQuestions } from './rsvp';
|
|
12
|
+
import { localeQuery, mobileLocale } from './locale';
|
|
12
13
|
|
|
13
14
|
const common = {
|
|
14
15
|
start: z.string().datetime({ offset: true }),
|
|
15
16
|
name: z.string().trim().min(1).max(120), email: z.string().trim().email().max(200).transform(v => v.toLowerCase()),
|
|
16
17
|
phone: z.string().trim().max(40).default(''),
|
|
17
18
|
answers: appAnswers.default({}),
|
|
19
|
+
// the app's language (one of the site's locales): the emails and links speak it
|
|
20
|
+
locale: localeQuery,
|
|
18
21
|
};
|
|
19
22
|
export const bookingInput = z.union([
|
|
20
23
|
z.object({ ...common, resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
|
|
@@ -36,7 +39,11 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
36
39
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
|
|
37
40
|
const body = bookingInput.parse(await readJson(request, 50000));
|
|
38
41
|
if (!('event' in body)) requireCalendar(true);
|
|
39
|
-
const
|
|
42
|
+
const locale = mobileLocale(body.locale) ?? '';
|
|
43
|
+
// the language isn't a booking detail: a retry in another one replays the
|
|
44
|
+
// same booking, and keys saved before apps sent it still match
|
|
45
|
+
const { locale: _language, ...asked } = body;
|
|
46
|
+
const idempotency = { keyHash: await sha256Hex(key.toLowerCase()), requestHash: await sha256Hex(canonical(asked)) };
|
|
40
47
|
const replay = async () => {
|
|
41
48
|
const record = await repo.getBookingRequest(idempotency.keyHash);
|
|
42
49
|
if (!record) return null;
|
|
@@ -51,7 +58,7 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
51
58
|
const settings = await repo.getSettings();
|
|
52
59
|
const flow = { origin: siteOrigin(settings, new URL(request.url)), locals, waitUntil: waitUntilFrom(locals), idempotency };
|
|
53
60
|
const start = Date.parse(body.start);
|
|
54
|
-
const booker = { ...body, answers: joinAnswers(body.answers) };
|
|
61
|
+
const booker = { ...body, answers: joinAnswers(body.answers), locale };
|
|
55
62
|
let booking: Booking;
|
|
56
63
|
if ('event' in body) {
|
|
57
64
|
const occurrence = await rsvpOccurrence(repo, body.event, body.start);
|
|
@@ -13,6 +13,7 @@ import { pageQuery, isoDay, isoDayOrInstant, queryParams } from './http';
|
|
|
13
13
|
import { localizeDoc } from '../translations';
|
|
14
14
|
import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
15
15
|
import { confirmsInstantly, linkApproval } from '../booking/approval';
|
|
16
|
+
import { changeTopics } from '../push/service';
|
|
16
17
|
|
|
17
18
|
export function requireCalendar(bookings = false) {
|
|
18
19
|
if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
|
|
@@ -59,20 +60,21 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
59
60
|
occurrences: occurrences.slice(start, start + q.limit).map(o => ({
|
|
60
61
|
id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay,
|
|
61
62
|
cancelled: o.cancelled,
|
|
62
|
-
event: publicEventDto(o.event, origin, publicCalendars, settings
|
|
63
|
+
event: publicEventDto(o.event, origin, publicCalendars, settings, locale),
|
|
63
64
|
})),
|
|
64
65
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
// e is already localized; publicCalendars limits which calendars are named
|
|
68
|
-
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>,
|
|
69
|
-
const event = eventToJson(e, origin, publicCalendars,
|
|
69
|
+
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null) {
|
|
70
|
+
const event = eventToJson(e, origin, publicCalendars, settings.timezone);
|
|
70
71
|
event.url = localizedUrl(event.url, origin, locale);
|
|
71
72
|
// Only configured public event fields; no undeclared imported metadata, no private fields.
|
|
72
73
|
event.fields = publicEventFields(e.fields);
|
|
73
74
|
event.image = safeUrl(e.cover, origin);
|
|
74
75
|
event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
|
|
75
|
-
|
|
76
|
+
// which push topics hear about date, time or place changes ([] = none do)
|
|
77
|
+
return { ...event, notify: { changes: changeTopics(e, settings, publicCalendars) } };
|
|
76
78
|
}
|
|
77
79
|
export async function listResources(repo: ContentRepository, url: URL) {
|
|
78
80
|
requireCalendar(true);
|
package/src/lib/mobile/guest.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { Booking } from '../schema';
|
|
|
4
4
|
import { ApiError, readJson } from '../api';
|
|
5
5
|
import { sha256Hex } from '../api-keys';
|
|
6
6
|
import { randomToken, safeEqual } from '../editor/auth';
|
|
7
|
-
import { CALENDAR, bookingsEnabled
|
|
7
|
+
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
8
|
+
import { bookingManagePath } from '../booking/emails';
|
|
8
9
|
import { encrypt, encryptionAvailable, resolveConfig, validAddress } from '../email/config';
|
|
9
10
|
import { kickEmail } from '../email/service';
|
|
10
11
|
import { guestChanges } from '../booking/service';
|
|
@@ -144,7 +145,7 @@ export async function myBookings(c: PublicContext) {
|
|
|
144
145
|
start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(), seats: b.seats,
|
|
145
146
|
title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
146
147
|
// use with the Booking-Token header on /bookings/manage/*
|
|
147
|
-
manageToken: b.manageToken, manageUrl: origin +
|
|
148
|
+
manageToken: b.manageToken, manageUrl: origin + bookingManagePath(b),
|
|
148
149
|
can: guestChanges(b, link, now),
|
|
149
150
|
};
|
|
150
151
|
}),
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { Booking, BookingLink, CalEvent, Resource, Settings } from '../schema';
|
|
3
3
|
import { ApiError, readJson } from '../api';
|
|
4
|
-
import { bookingsEnabled, eventUrl
|
|
4
|
+
import { bookingsEnabled, eventUrl } from '../calendar/config';
|
|
5
5
|
import { eventTimeZone, locationLine } from '../calendar/events';
|
|
6
6
|
import { resourceZone, linkLengths } from '../booking/availability';
|
|
7
|
-
import { bookingIcs } from '../booking/emails';
|
|
7
|
+
import { bookingIcs, bookingLanguage, bookingManagePath } from '../booking/emails';
|
|
8
|
+
import { localePath } from '../i18n';
|
|
8
9
|
import { guestCancel, guestChanges, guestMoveLink, guestReschedule } from '../booking/service';
|
|
9
10
|
import { siteOrigin } from '../seo';
|
|
10
11
|
import { waitUntilFrom } from '../webhooks';
|
|
@@ -59,9 +60,10 @@ function view(m: Managed, booking = m.booking) {
|
|
|
59
60
|
location: event ? locationLine(event.location) : resource?.name ?? '',
|
|
60
61
|
resource: resource ? { slug: resource.slug, name: resource.name } : null,
|
|
61
62
|
link: link ? { slug: link.slug, name: link.name, mode: link.mode, lengths: linkLengths(link) } : null,
|
|
62
|
-
|
|
63
|
+
// the web pages in the language it was booked in, as the emails link them
|
|
64
|
+
event: event ? { slug: event.slug, title: event.title, url: origin + localePath(eventUrl(event.slug), bookingLanguage(booking, settings).locale) } : null,
|
|
63
65
|
// the web page for this booking (the same private link the emails carry)
|
|
64
|
-
manageUrl: origin +
|
|
66
|
+
manageUrl: origin + bookingManagePath(booking),
|
|
65
67
|
can,
|
|
66
68
|
// the RSVP form as it was when they sent it (like the web page), else the booking type's message
|
|
67
69
|
confirmation: booking.formResponse?.definition.confirmation || link?.confirmMessage || null,
|
|
@@ -106,7 +108,7 @@ export async function managedIcs(c: PublicContext) {
|
|
|
106
108
|
z.object({}).strict().parse(queryParams(c.url));
|
|
107
109
|
const m = await managed(c);
|
|
108
110
|
const v = view(m);
|
|
109
|
-
const body = bookingIcs(m.booking, v.title, v.location, v.manageUrl, new URL(m.origin).host, m.booking.status === 'cancelled' ? 'CANCEL' : 'REQUEST', { allDay: v.booking.allDay });
|
|
111
|
+
const body = bookingIcs(m.booking, v.title, v.location, v.manageUrl, new URL(m.origin).host, m.booking.status === 'cancelled' ? 'CANCEL' : 'REQUEST', { allDay: v.booking.allDay, lang: bookingLanguage(m.booking, m.settings).lang });
|
|
110
112
|
return new Response(body, { headers: {
|
|
111
113
|
'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'attachment; filename="booking.ics"',
|
|
112
114
|
'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff',
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { answerFields } from '../forms/schema';
|
|
|
6
6
|
import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
|
|
7
7
|
import { seatsTaken } from '../booking/service';
|
|
8
8
|
import { localizeDoc } from '../translations';
|
|
9
|
+
import { siteLang } from '../dates';
|
|
10
|
+
import { rsvpStrings } from '../calendar/rsvp-words';
|
|
9
11
|
import { queryParams } from './http';
|
|
10
12
|
import { requireCalendar } from './calendar';
|
|
11
13
|
import { formJson } from './forms';
|
|
@@ -54,12 +56,18 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
54
56
|
const reason = closed ?? (left === 0 ? 'full' as const : null);
|
|
55
57
|
const form = rsvpQuestions(o.event);
|
|
56
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
|
+
}
|
|
57
65
|
return {
|
|
58
66
|
event: { id: o.event.id, slug: o.event.slug, title },
|
|
59
67
|
start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
60
68
|
open: !reason, reason, going, left, capacity: rsvp.capacity,
|
|
61
69
|
// seats one RSVP may take right now
|
|
62
70
|
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
63
|
-
form: form ? { ...await formJson(form), submitLabel
|
|
71
|
+
form: form ? { ...await formJson(form), submitLabel } : null,
|
|
64
72
|
};
|
|
65
73
|
}
|
|
@@ -32,6 +32,8 @@ export const Event = CalEvent.pick({
|
|
|
32
32
|
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
33
33
|
}).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
34
34
|
calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
|
|
35
|
+
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
36
|
+
notify: z.object({ changes: strings }),
|
|
35
37
|
});
|
|
36
38
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
37
39
|
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, event: Event })),
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -87,7 +87,7 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
87
87
|
snippet: plainSnippet(snippet),
|
|
88
88
|
// the next occurrence, or the latest one when the event is over
|
|
89
89
|
occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled } : null,
|
|
90
|
-
event: publicEventDto(e, origin, listed, settings
|
|
90
|
+
event: publicEventDto(e, origin, listed, settings, locale),
|
|
91
91
|
};
|
|
92
92
|
}),
|
|
93
93
|
more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { queryParams } from './http';
|
|
|
13
13
|
import { localeOnlyQuery, localizedUrl, mobileLocale } from './locale';
|
|
14
14
|
import { guestSignInReady } from './guest';
|
|
15
15
|
import { LEGAL } from '../legal/config';
|
|
16
|
+
import { siteStrings } from '../site-words';
|
|
16
17
|
|
|
17
18
|
export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
|
|
18
19
|
// locale: the legal links point at that language's pages (/es/privacy)
|
|
@@ -23,16 +24,19 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
23
24
|
const origin = siteOrigin(settings, url);
|
|
24
25
|
const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
|
|
25
26
|
const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
|
|
27
|
+
// the same list the website's header shows in that language: the owner's
|
|
28
|
+
// menu as written, or the automatic one with its words translated
|
|
29
|
+
const words = siteStrings(locale || settings.locale || 'en');
|
|
26
30
|
const menu = header.length ? header : [
|
|
27
|
-
{ label:
|
|
31
|
+
{ label: words.home, url: '/' },
|
|
28
32
|
...pages.filter(p => p.status === 'published' && !p.isHome).map(p => ({ label: p.title, url: `/${p.slug}` })),
|
|
29
33
|
...Object.values(CONTENT.collections).map(c => ({ label: c.plural, url: c.base })),
|
|
30
|
-
{ label:
|
|
34
|
+
{ label: words.links, url: '/links' },
|
|
31
35
|
];
|
|
32
36
|
return {
|
|
33
37
|
name: settings.siteTitle, description: settings.tagline,
|
|
34
38
|
logo: safeUrl(settings.siteLogo, origin), logoDark: safeUrl(settings.siteLogoDark, origin), icon: safeUrl(settings.siteFavicon, origin),
|
|
35
|
-
menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
|
|
39
|
+
menu: menu.map(n => ({ label: n.label, href: localizedUrl(safeUrl(n.url, origin, true), origin, locale) })),
|
|
36
40
|
languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
|
|
37
41
|
features: {
|
|
38
42
|
// redirectFrom: old addresses the website 301s to url, so apps can follow them too,
|
package/src/lib/push/service.ts
CHANGED
|
@@ -229,6 +229,16 @@ function inChangeRule(rule: PushAuto['eventChanges'], e: CalEvent) {
|
|
|
229
229
|
const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
|
|
230
230
|
return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
|
|
231
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* The topics this event's change notices go to (Admin → Mobile app → When an
|
|
234
|
+
* event changes), or [] while that rule doesn't cover it. Apps show it to
|
|
235
|
+
* promise "we'll let you know if it changes" only when it's true.
|
|
236
|
+
*/
|
|
237
|
+
export function changeTopics(e: CalEvent, settings: Settings, calendars: Map<string, Calendar>): string[] {
|
|
238
|
+
const rule = settings.push.auto.eventChanges;
|
|
239
|
+
if (!PUSH.enabled || !CALENDAR.enabled || !inChangeRule(rule, e)) return [];
|
|
240
|
+
return rule.topics.length ? [...rule.topics] : eventTopics(e, calendars);
|
|
241
|
+
}
|
|
232
242
|
function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
|
|
233
243
|
return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
|
|
234
244
|
}
|
|
@@ -685,6 +685,11 @@ export const MIGRATIONS: Migration[] = [
|
|
|
685
685
|
`ALTER TABLE push_queue ADD COLUMN payload TEXT`,
|
|
686
686
|
`ALTER TABLE push_queue ADD COLUMN due_at INTEGER`,
|
|
687
687
|
] },
|
|
688
|
+
// the language a booking or RSVP was made in (Booking.locale), so its
|
|
689
|
+
// emails and links follow it; '' = the main language, so older rows need no backfill
|
|
690
|
+
{ version: 60, statements: [
|
|
691
|
+
`ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`,
|
|
692
|
+
] },
|
|
688
693
|
];
|
|
689
694
|
|
|
690
695
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -210,7 +210,7 @@ function rowToBooking(r: Row): Booking {
|
|
|
210
210
|
status: r.status, start: Number(r.start_at), end: Number(r.end_at), holdStart: Number(r.hold_start), holdEnd: Number(r.hold_end),
|
|
211
211
|
timezone: r.timezone ?? '', name: r.name ?? '', email: r.email ?? '', phone: r.phone ?? '', seats: Number(r.seats ?? 1),
|
|
212
212
|
formResponse: r.form_response ? json(r.form_response, undefined) : undefined,
|
|
213
|
-
answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web',
|
|
213
|
+
answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web', locale: r.locale ?? '',
|
|
214
214
|
notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
|
|
215
215
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
216
216
|
});
|
|
@@ -446,7 +446,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
446
446
|
await this.ready;
|
|
447
447
|
// One SELECT snapshots bookings and their retry ledger together. No UI
|
|
448
448
|
// list limit; include tombstones for deleted bookings as well.
|
|
449
|
-
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','reminder_sent_at','notification_version','created_at','updated_at'];
|
|
449
|
+
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','locale','reminder_sent_at','notification_version','created_at','updated_at'];
|
|
450
450
|
const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
|
|
451
451
|
UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
|
|
452
452
|
return {
|
|
@@ -1078,9 +1078,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1078
1078
|
private bookingInsert(b: Booking): SqlStatement {
|
|
1079
1079
|
return {
|
|
1080
1080
|
sql: `INSERT INTO bookings (id, kind, resource_id, link_id, event_id, status, start_at, end_at, hold_start, hold_end, timezone, name, email, phone,
|
|
1081
|
-
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1081
|
+
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response, locale) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1082
1082
|
args: [b.id, b.kind, b.resourceId, b.linkId, b.eventId, b.status, b.start, b.end, b.holdStart, b.holdEnd, b.timezone, b.name, b.email, b.phone,
|
|
1083
|
-
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null],
|
|
1083
|
+
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null, b.locale],
|
|
1084
1084
|
};
|
|
1085
1085
|
}
|
|
1086
1086
|
async listResources() {
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -195,8 +195,9 @@ export interface ContentRepository {
|
|
|
195
195
|
getBookingByToken(token: string): Promise<Booking | null>;
|
|
196
196
|
// bookings + RSVPs (never staff blocks) this address made since `since`
|
|
197
197
|
countBookingsByEmail(email: string, since: number): Promise<number>;
|
|
198
|
+
// locale: the booker's language; '' (the main language) when left out
|
|
198
199
|
createBooking(
|
|
199
|
-
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'
|
|
200
|
+
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
|
|
200
201
|
guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
|
|
201
202
|
): Promise<Booking | null>;
|
|
202
203
|
// moves a booking only if the new held range is free (reschedule)
|
package/src/lib/schema.ts
CHANGED
|
@@ -674,6 +674,9 @@ export const Booking = z.object({
|
|
|
674
674
|
manageToken: z.string().default(''),
|
|
675
675
|
// web: the website; app: the public app API (/api/v1/bookings)
|
|
676
676
|
source: z.enum(['web', 'app', 'admin', 'api', 'agent']).default('web'),
|
|
677
|
+
// the language the booker used (an extra site locale, e.g. 'es'): their
|
|
678
|
+
// emails and links speak it. '' = the site's main language, as on page rows
|
|
679
|
+
locale: z.string().default(''),
|
|
677
680
|
reminderSentAt: z.number().nullable().default(null),
|
|
678
681
|
notificationVersion: z.number().default(0),
|
|
679
682
|
createdAt: z.number(),
|
|
@@ -6,9 +6,12 @@ import { bookingsEnabled, manageUrl, resourceUrl } from '../../../lib/calendar/c
|
|
|
6
6
|
import { availabilityContext, createResourceBooking, guestMoveLink, guestReschedule, BookingError } from '../../../lib/booking/service';
|
|
7
7
|
import { siteOrigin } from '../../../lib/seo';
|
|
8
8
|
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
9
|
+
import { localePath, requestLocale } from '../../../lib/i18n';
|
|
9
10
|
|
|
10
11
|
// One booking link: pick → answer → done. POSTs create the booking (or move
|
|
11
|
-
// one when ?reschedule=<token>) and land on the manage page.
|
|
12
|
+
// one when ?reschedule=<token>) and land on the manage page. These pages are
|
|
13
|
+
// in English, but a booking made from /es/… keeps the visitor's language for
|
|
14
|
+
// its emails and its own page.
|
|
12
15
|
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
13
16
|
const repo = await getRepo(Astro.locals);
|
|
14
17
|
const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
|
|
@@ -17,6 +20,7 @@ if (!resource || !link || !link.active) return new Response(null, { status: 404,
|
|
|
17
20
|
const settings = await repo.getSettings();
|
|
18
21
|
const origin = siteOrigin(settings, Astro.url);
|
|
19
22
|
const flow = { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) };
|
|
23
|
+
const locale = requestLocale(Astro.url, Astro.locals);
|
|
20
24
|
const rescheduleToken = Astro.url.searchParams.get('reschedule') ?? '';
|
|
21
25
|
const reschedule = rescheduleToken ? await repo.getBookingByToken(rescheduleToken) : null;
|
|
22
26
|
if (reschedule && reschedule.linkId !== link.id) return Astro.redirect(manageUrl(reschedule.manageToken));
|
|
@@ -37,16 +41,16 @@ if (Astro.request.method === 'POST') {
|
|
|
37
41
|
const b = await repo.getBookingByToken(token);
|
|
38
42
|
if (!b || b.linkId !== link.id) throw new BookingError(404, 'That booking can’t be found.');
|
|
39
43
|
await guestReschedule(repo, b, start, end, flow);
|
|
40
|
-
return Astro.redirect(`${manageUrl(b.manageToken)}?moved=1`);
|
|
44
|
+
return Astro.redirect(`${localePath(manageUrl(b.manageToken), locale)}?moved=1`);
|
|
41
45
|
}
|
|
42
46
|
const answers: Record<string, string> = {};
|
|
43
47
|
for (const qn of link.questions) answers[qn.id] = String(form.get(`q_${qn.id}`) ?? '');
|
|
44
48
|
const b = await createResourceBooking(repo, {
|
|
45
49
|
resource, link, start, end,
|
|
46
|
-
booker: { name: String(form.get('name') ?? ''), email: String(form.get('email') ?? ''), phone: String(form.get('phone') ?? ''), answers },
|
|
50
|
+
booker: { name: String(form.get('name') ?? ''), email: String(form.get('email') ?? ''), phone: String(form.get('phone') ?? ''), answers, locale },
|
|
47
51
|
}, flow);
|
|
48
52
|
if (link.redirectUrl && /^https?:\/\//.test(link.redirectUrl)) return Astro.redirect(link.redirectUrl);
|
|
49
|
-
return Astro.redirect(`${manageUrl(b.manageToken)}?new=1`);
|
|
53
|
+
return Astro.redirect(`${localePath(manageUrl(b.manageToken), locale)}?new=1`);
|
|
50
54
|
} catch (err) {
|
|
51
55
|
error = err instanceof BookingError ? err.message : 'Something went wrong. Please try again.';
|
|
52
56
|
if (Number.isFinite(start)) {
|
|
@@ -8,27 +8,45 @@ 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 { bookingStrings } from '../../../lib/email/booking-words';
|
|
16
|
+
import { fill } from '../../../lib/live/words';
|
|
11
17
|
|
|
12
18
|
// The booker's own page, reached from the confirmation and every email: see
|
|
13
19
|
// 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.
|
|
20
|
+
// URL is the only credential — it's long and random, and never listed. It
|
|
21
|
+
// speaks the request's language, for RSVPs and room bookings alike
|
|
22
|
+
// (/es/book/manage/… in Spanish, as the emails of a booking made in Spanish
|
|
23
|
+
// link it); opened without the prefix it shows the main language, with no
|
|
24
|
+
// redirect. The room booking flow it links back to stays in English.
|
|
15
25
|
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
16
26
|
const repo = await getRepo(Astro.locals);
|
|
17
27
|
const booking = await repo.getBookingByToken(Astro.params.token ?? '');
|
|
18
28
|
if (!booking || (!bookingsEnabled() && booking.kind !== 'rsvp')) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
29
|
const settings = await repo.getSettings();
|
|
20
30
|
const origin = siteOrigin(settings, Astro.url);
|
|
21
|
-
const [resource, link,
|
|
31
|
+
const [resource, link, rawEvent] = await Promise.all([
|
|
22
32
|
booking.resourceId ? repo.getResource(booking.resourceId) : Promise.resolve(null),
|
|
23
33
|
booking.linkId ? repo.getBookingLink(booking.linkId) : Promise.resolve(null),
|
|
24
34
|
booking.eventId ? repo.getEvent(booking.eventId) : Promise.resolve(null),
|
|
25
35
|
]);
|
|
26
|
-
const
|
|
36
|
+
const isRsvp = booking.kind === 'rsvp';
|
|
37
|
+
const locale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
38
|
+
const lang = locale || siteLang(settings);
|
|
39
|
+
// a room booking words a few things its own way ("Your booking", not "Your RSVP")
|
|
40
|
+
const bw = bookingStrings(lang).page;
|
|
41
|
+
const w = isRsvp ? rsvpStrings(lang) : { ...rsvpStrings(lang), ...bw };
|
|
42
|
+
const event = rawEvent && localizeDoc(rawEvent, locale || undefined);
|
|
43
|
+
const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || bw.untitled;
|
|
27
44
|
const location = event ? locationLine(event.location) : resource?.name ?? '';
|
|
28
|
-
|
|
45
|
+
// the address the visitor sees (/es/… is rewritten to this route)
|
|
46
|
+
const url = origin + localePath(Astro.url.pathname, locale);
|
|
29
47
|
|
|
30
48
|
if (Astro.url.searchParams.get('ics')) {
|
|
31
|
-
return new Response(bookingIcs(booking, title, location, url, new URL(origin).host), {
|
|
49
|
+
return new Response(bookingIcs(booking, title, location, url, new URL(origin).host, 'REQUEST', { lang }), {
|
|
32
50
|
headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'inline; filename="booking.ics"', 'Cache-Control': 'private, no-store' },
|
|
33
51
|
});
|
|
34
52
|
}
|
|
@@ -40,25 +58,26 @@ if (Astro.request.method === 'POST') {
|
|
|
40
58
|
try {
|
|
41
59
|
current = await guestCancel(repo, booking, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
|
|
42
60
|
} catch (err) {
|
|
43
|
-
error = err instanceof BookingError ? err.message :
|
|
61
|
+
error = err instanceof BookingError ? err.message : w.cancelError;
|
|
44
62
|
}
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
65
|
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);
|
|
66
|
+
const when = whenText(current, tz, isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
|
|
49
67
|
const active = current.status === 'pending' || current.status === 'confirmed';
|
|
50
68
|
const upcoming = current.end > Date.now();
|
|
51
69
|
const can = guestChanges(current, link);
|
|
52
70
|
const isNew = Astro.url.searchParams.has('new');
|
|
53
71
|
const moved = Astro.url.searchParams.has('moved');
|
|
54
|
-
const heading = current.status === 'cancelled' ?
|
|
55
|
-
: current.status === 'declined' ?
|
|
56
|
-
: current.status === 'pending' ? (isNew ?
|
|
57
|
-
: moved ?
|
|
58
|
-
const lede = current.status === 'pending' ?
|
|
59
|
-
: current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage ||
|
|
60
|
-
: current.status === 'declined' ?
|
|
61
|
-
:
|
|
72
|
+
const heading = current.status === 'cancelled' ? w.cancelledTitle
|
|
73
|
+
: current.status === 'declined' ? w.notAvailable
|
|
74
|
+
: current.status === 'pending' ? (isNew ? w.requestSent : w.awaitingApproval)
|
|
75
|
+
: moved ? bw.moved : isNew ? w.booked : w.yours;
|
|
76
|
+
const lede = current.status === 'pending' ? fill(w.emailWhenApproved, { email: current.email })
|
|
77
|
+
: current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || fill(w.confirmationOnItsWay, { email: current.email }))
|
|
78
|
+
: current.status === 'declined' ? w.declined
|
|
79
|
+
: w.cancelledText;
|
|
80
|
+
const cutoff = fill(bw.cutoff, { hours: String(can.cutoffHours) });
|
|
62
81
|
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
82
|
---
|
|
64
83
|
|
|
@@ -73,40 +92,40 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
|
|
|
73
92
|
<p class="pc-manage-lede">{lede}</p>
|
|
74
93
|
{error && <p class="pc-bk-error">{error}</p>}
|
|
75
94
|
<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>
|
|
95
|
+
<div><dt>{w.what}</dt><dd>{title}</dd></div>
|
|
96
|
+
<div><dt>{w.when}</dt><dd>{when}</dd></div>
|
|
97
|
+
{location && <div><dt>{w.where}</dt><dd>{location}</dd></div>}
|
|
98
|
+
{current.seats > 1 && <div><dt>{w.seatsField}</dt><dd>{current.seats}</dd></div>}
|
|
99
|
+
<div><dt>{w.status}</dt><dd class={`pc-manage-status is-${current.status}`}>{w.statuses[current.status]}</dd></div>
|
|
100
|
+
<div><dt>{w.reference}</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
|
|
82
101
|
</dl>
|
|
83
102
|
{active && upcoming && (
|
|
84
103
|
<div class="pc-manage-actions">
|
|
85
104
|
{current.status === 'confirmed' && (
|
|
86
105
|
<details class="pc-ev-add">
|
|
87
|
-
<summary>
|
|
106
|
+
<summary>{w.addToCalendar}</summary>
|
|
88
107
|
<div class="pc-ev-add-menu">
|
|
89
108
|
<a href={googleUrl} target="_blank" rel="noopener">Google Calendar</a>
|
|
90
109
|
<a href="?ics=1">Apple Calendar</a>
|
|
91
|
-
<a href="?ics=1" download="booking.ics">
|
|
110
|
+
<a href="?ics=1" download="booking.ics">{w.downloadIcs}</a>
|
|
92
111
|
</div>
|
|
93
112
|
</details>
|
|
94
113
|
)}
|
|
95
114
|
{resource && link && can.reschedule && (
|
|
96
|
-
<a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>
|
|
115
|
+
<a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>{bw.reschedule}</a>
|
|
97
116
|
)}
|
|
98
117
|
{can.cancel && (
|
|
99
|
-
<form method="post" onsubmit=
|
|
118
|
+
<form method="post" onsubmit={`return confirm(${JSON.stringify(w.cancelConfirm)})`}>
|
|
100
119
|
<input type="hidden" name="op" value="cancel" />
|
|
101
|
-
<button class="pc-manage-btn is-danger" type="submit">
|
|
120
|
+
<button class="pc-manage-btn is-danger" type="submit">{w.cancel}</button>
|
|
102
121
|
</form>
|
|
103
122
|
)}
|
|
104
123
|
</div>
|
|
105
124
|
)}
|
|
106
|
-
{can.closed && <p class="pc-manage-note">
|
|
125
|
+
{can.closed && <p class="pc-manage-note">{cutoff}</p>}
|
|
107
126
|
<p class="pc-bk-foot">
|
|
108
|
-
{event ? <a href={eventUrl(event.slug)}>
|
|
127
|
+
{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
128
|
</p>
|
|
110
|
-
<p class="pc-manage-note">
|
|
129
|
+
<p class="pc-manage-note">{w.keepLink}</p>
|
|
111
130
|
</div>
|
|
112
131
|
</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 (its emails speak it too)
|
|
49
52
|
let rsvpError = '';
|
|
50
53
|
let formValues: Record<string,string> = {};
|
|
51
54
|
let coreValues = { name: '', email: '', seats: '1' };
|
|
@@ -60,10 +63,10 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
|
|
|
60
63
|
requestId = await submissionGuard(repo, form, Astro.clientAddress, `event:${event.id}`);
|
|
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
|
-
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);
|
|
66
|
+
const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) }, locale);
|
|
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
|
@@ -24,6 +24,9 @@ export type { LinkValue, EventItem, PostItem, PostAuthor, BookingItem } from '..
|
|
|
24
24
|
export type { SiteChrome, ChromeLink } from '../lib/site-chrome';
|
|
25
25
|
// core's chrome labels for any language (a template already gets them as site.words)
|
|
26
26
|
export { siteStrings, type SiteStrings } from '../lib/site-words';
|
|
27
|
+
// the built-in event page's words and repeat line ("Weekly on Sunday"), for templates/event.astro
|
|
28
|
+
export { eventStrings, type EventStrings } from '../lib/calendar/event-words';
|
|
29
|
+
export { describeRepeat } from '../lib/calendar/rrule';
|
|
27
30
|
import type { SiteChrome } from '../lib/site-chrome';
|
|
28
31
|
export interface ChromeProps { site: SiteChrome }
|
|
29
32
|
|