@acms-subir/core 0.3.37 → 0.3.39
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 +22 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +18 -11
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +18 -11
- package/mobile-template/recipes/rsvp/recipe.json +2 -1
- package/package.json +1 -1
- package/src/actions/index.ts +2 -1
- package/src/components/EventPage.astro +53 -30
- package/src/components/MobileApp.tsx +44 -23
- package/src/components/editor/EventPanel.tsx +2 -1
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +51 -1
- package/src/components/editor/event/registration.ts +18 -1
- package/src/lib/booking/emails.ts +62 -45
- package/src/lib/booking/service.ts +57 -19
- package/src/lib/calendar/cards.ts +5 -3
- package/src/lib/calendar/event-words.ts +171 -0
- package/src/lib/calendar/events.ts +16 -2
- package/src/lib/calendar/render.ts +3 -1
- package/src/lib/calendar/rrule.ts +14 -18
- package/src/lib/calendar/rsvp-words.ts +31 -2
- package/src/lib/content-service.ts +26 -14
- package/src/lib/drafts.ts +2 -0
- package/src/lib/editor/documents.ts +3 -1
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +212 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/mobile/bookings.ts +10 -3
- package/src/lib/mobile/calendar.ts +6 -4
- package/src/lib/mobile/guest.ts +3 -2
- package/src/lib/mobile/http.ts +1 -1
- package/src/lib/mobile/manage.ts +7 -5
- package/src/lib/mobile/openapi.ts +1 -1
- package/src/lib/mobile/rsvp.ts +14 -8
- package/src/lib/mobile/schemas.ts +7 -2
- package/src/lib/mobile/search.ts +1 -1
- package/src/lib/mobile/site.ts +7 -3
- package/src/lib/push/api.ts +7 -8
- package/src/lib/push/service.ts +26 -11
- package/src/lib/push/types.ts +18 -8
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +7 -4
- package/src/lib/repo/types.ts +5 -3
- package/src/lib/schema.ts +8 -1
- package/src/pages/book/[resource]/[link].astro +8 -4
- package/src/pages/book/manage/[token].astro +22 -14
- package/src/pages/events/[slug].astro +12 -5
- package/src/styles/editor.css +4 -0
- package/src/styles/mobile-app.css +7 -0
- package/src/styles/site.css +3 -0
- package/src/theme-sdk/index.ts +4 -1
package/src/lib/push/service.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { ANNOUNCEMENTS } from '../announcements/config';
|
|
|
15
15
|
import type { LiveSnapshot } from '../live/types';
|
|
16
16
|
import { PUSH } from './config';
|
|
17
17
|
import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
|
|
18
|
-
import { EventSnapshot, type
|
|
18
|
+
import { EventSnapshot, type ChangeRule, type PushNotification, type PushSource, type PushTopic } from './types';
|
|
19
19
|
|
|
20
20
|
const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
|
|
21
21
|
/** automatic notices queued longer ago than this are skipped, never sent late */
|
|
@@ -57,9 +57,9 @@ export async function pushTopics(repo: ContentRepository, locale?: string | null
|
|
|
57
57
|
const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
|
|
58
58
|
if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
|
|
59
59
|
if (CALENDAR.enabled) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
out.push({ id: 'events', label:
|
|
60
|
+
// event-change notices go to `events` from a rule that names no other topics
|
|
61
|
+
const toEvents = settings.push.auto.eventChanges.some((r) => r.calendars.length && !r.topics.length);
|
|
62
|
+
out.push({ id: 'events', label: toEvents ? w.eventsChanges : w.events, kind: 'general' });
|
|
63
63
|
const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
|
|
64
64
|
if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
|
|
65
65
|
}
|
|
@@ -224,11 +224,25 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
|
|
|
224
224
|
|
|
225
225
|
const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
|
|
226
226
|
const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
|
|
227
|
-
//
|
|
228
|
-
function inChangeRule(rule:
|
|
227
|
+
// a change rule: its calendars, and one of its tags when it lists some (any case)
|
|
228
|
+
function inChangeRule(rule: ChangeRule, 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
|
+
// every rule that covers the event adds its topics, or the event's own when it
|
|
233
|
+
// names none: in rule order, once each ([] = no rule covers it)
|
|
234
|
+
function changeAudience(rules: ChangeRule[], e: CalEvent, calendars: Map<string, Calendar>) {
|
|
235
|
+
return [...new Set(rules.filter((r) => inChangeRule(r, e)).flatMap((r) => (r.topics.length ? r.topics : eventTopics(e, calendars))))];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The topics this event's change notices go to (Admin → Mobile app → When an
|
|
239
|
+
* event changes), or [] while no rule covers it. Apps show it to promise
|
|
240
|
+
* "we'll let you know if it changes" only when it's true.
|
|
241
|
+
*/
|
|
242
|
+
export function changeTopics(e: CalEvent, settings: Settings, calendars: Map<string, Calendar>): string[] {
|
|
243
|
+
if (!PUSH.enabled || !CALENDAR.enabled) return [];
|
|
244
|
+
return changeAudience(settings.push.auto.eventChanges, e, calendars);
|
|
245
|
+
}
|
|
232
246
|
function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
|
|
233
247
|
return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
|
|
234
248
|
}
|
|
@@ -345,7 +359,7 @@ export async function queueEventChange(repo: ContentRepository, before: CalEvent
|
|
|
345
359
|
const was = eventSnapshot(before);
|
|
346
360
|
if (JSON.stringify(was) === JSON.stringify(eventSnapshot(after))) return false;
|
|
347
361
|
const [settings, all] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
348
|
-
if (!
|
|
362
|
+
if (!settings.push.auto.eventChanges.some((r) => inChangeRule(r, after)) || !eventIsListed(after, listedCalendars(all))) return false;
|
|
349
363
|
const key = `change:${after.id}`;
|
|
350
364
|
// a change to a date that starts soon waits only a minute (judged from what people last knew)
|
|
351
365
|
const row = await repo.push.queuedChange(key);
|
|
@@ -371,7 +385,7 @@ async function announcedLately(repo: ContentRepository, e: CalEvent, origin: str
|
|
|
371
385
|
return (await repo.announcements.pushedSince(now - HOUR)).some((a) => a.link !== '' && sitePath(a.link, origin) === page);
|
|
372
386
|
}
|
|
373
387
|
|
|
374
|
-
/** `topics`: the
|
|
388
|
+
/** `topics`: the audience of the rules that cover it (changeTopics); [] = the event's topics. */
|
|
375
389
|
export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Settings, calendars: Map<string, Calendar>, topics: string[] = []): SendInput {
|
|
376
390
|
const tz = eventTimeZone(e, settings);
|
|
377
391
|
const siteTz = settings.timezone || 'UTC';
|
|
@@ -438,9 +452,10 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
|
|
|
438
452
|
// compared at send time: edits that cancel out, or come too late, send nothing
|
|
439
453
|
const e = await repo.getEvent(row.docId);
|
|
440
454
|
const was = readSnapshot(row.payload);
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
455
|
+
// one notice, to everyone the rules that cover it send to
|
|
456
|
+
const topics = e ? changeAudience(auto.eventChanges, e, calendars) : [];
|
|
457
|
+
const change = e && was && topics.length && eventIsListed(e, calendars) ? eventChange(was, e, settings, now) : null;
|
|
458
|
+
if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, topics);
|
|
444
459
|
}
|
|
445
460
|
if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
|
|
446
461
|
const n = await sendNotification(repo, notice, run);
|
package/src/lib/push/types.ts
CHANGED
|
@@ -2,6 +2,22 @@ import { z } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
// '*' in a calendars list = every public event
|
|
4
4
|
const keys = z.array(z.string().min(1).max(200)).max(100);
|
|
5
|
+
/** One "when an event changes" rule: the events it covers and who hears. */
|
|
6
|
+
export const ChangeRule = z.object({
|
|
7
|
+
calendars: keys.default([]),
|
|
8
|
+
/** only events with one of these tags (any case); [] = every event in the calendars */
|
|
9
|
+
tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
|
|
10
|
+
/** sent to these topics instead of the event's own (`events` and its calendars) */
|
|
11
|
+
topics: z.array(z.string().min(1).max(200)).max(10).default([]),
|
|
12
|
+
});
|
|
13
|
+
export type ChangeRule = z.infer<typeof ChangeRule>;
|
|
14
|
+
export const MAX_CHANGE_RULES = 10;
|
|
15
|
+
// one rule, as saved before there could be several: a list of one, or none while it had no calendars
|
|
16
|
+
const oneRule = (v: unknown) => {
|
|
17
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return v;
|
|
18
|
+
const calendars = (v as { calendars?: unknown }).calendars;
|
|
19
|
+
return Array.isArray(calendars) && calendars.length ? [v] : [];
|
|
20
|
+
};
|
|
5
21
|
export const PushAuto = z.object({
|
|
6
22
|
/** collection keys whose first publish sends a notification */
|
|
7
23
|
newPost: z.object({ collections: keys.default([]) }).default({}),
|
|
@@ -11,14 +27,8 @@ export const PushAuto = z.object({
|
|
|
11
27
|
startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
|
|
12
28
|
/** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
|
|
13
29
|
live: z.object({ enabled: z.boolean().default(false) }).default({}),
|
|
14
|
-
/** a published event's new date, time or place, or a cancellation, once its edits settle */
|
|
15
|
-
eventChanges: z.
|
|
16
|
-
calendars: keys.default([]),
|
|
17
|
-
/** only events with one of these tags (any case); [] = every event in the calendars */
|
|
18
|
-
tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
|
|
19
|
-
/** sent to these topics instead of the event's own (`events` and its calendars) */
|
|
20
|
-
topics: z.array(z.string().min(1).max(200)).max(10).default([]),
|
|
21
|
-
}).default({}),
|
|
30
|
+
/** a published event's new date, time or place, or a cancellation, once its edits settle: one notice when any rule covers it */
|
|
31
|
+
eventChanges: z.preprocess(oneRule, z.array(ChangeRule).max(MAX_CHANGE_RULES)).default([]),
|
|
22
32
|
});
|
|
23
33
|
export type PushAuto = z.infer<typeof PushAuto>;
|
|
24
34
|
export const PushSettings = z.object({ auto: PushAuto.default({}) });
|
|
@@ -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() {
|
|
@@ -1225,6 +1225,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1225
1225
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
|
|
1226
1226
|
WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ('pending','confirmed')) + ? <= ?)`,
|
|
1227
1227
|
args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
|
|
1228
|
+
// same statement, so two at once from one address can't both get in
|
|
1229
|
+
if (guard.oncePerEmail && holds && b.email) write = { sql: write.sql + ` AND NOT EXISTS (SELECT 1 FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND email=? AND status IN ('pending','confirmed'))`,
|
|
1230
|
+
args: [...write.args!,b.eventId,b.start,b.email] };
|
|
1228
1231
|
} else if (holds && b.resourceId) {
|
|
1229
1232
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
|
|
1230
1233
|
SELECT 1 FROM bookings WHERE resource_id=? AND kind IN ('booking','block') AND status IN ('pending','confirmed') AND hold_start < ? AND hold_end > ?)`,
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -187,7 +187,8 @@ export interface ContentRepository {
|
|
|
187
187
|
|
|
188
188
|
// bookings (+ staff blocks and event RSVPs). createBooking is the only
|
|
189
189
|
// write that can fail on a race: it inserts atomically only if the held
|
|
190
|
-
// range is still free (resource bookings) or seats remain (RSVPs
|
|
190
|
+
// range is still free (resource bookings) or seats remain (RSVPs; with
|
|
191
|
+
// oncePerEmail, and the address has no active RSVP for that date), and
|
|
191
192
|
// returns null when it lost.
|
|
192
193
|
/** from/to: overlaps the window; endedBy: ended at or before; default order start ascending, at most 5000 rows */
|
|
193
194
|
listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; endedBy?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][]; newestFirst?: boolean; limit?: number; offset?: number }): Promise<Booking[]>;
|
|
@@ -195,9 +196,10 @@ export interface ContentRepository {
|
|
|
195
196
|
getBookingByToken(token: string): Promise<Booking | null>;
|
|
196
197
|
// bookings + RSVPs (never staff blocks) this address made since `since`
|
|
197
198
|
countBookingsByEmail(email: string, since: number): Promise<number>;
|
|
199
|
+
// locale: the booker's language; '' (the main language) when left out
|
|
198
200
|
createBooking(
|
|
199
|
-
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'
|
|
200
|
-
guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
|
|
201
|
+
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
|
|
202
|
+
guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string } }
|
|
201
203
|
): Promise<Booking | null>;
|
|
202
204
|
// moves a booking only if the new held range is free (reschedule)
|
|
203
205
|
moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
|
package/src/lib/schema.ts
CHANGED
|
@@ -415,12 +415,16 @@ export const CalEvent = z.object({
|
|
|
415
415
|
// UID of the feed event this was imported from ('' = authored here)
|
|
416
416
|
sourceUid: z.string().default(''),
|
|
417
417
|
// RSVP: seats per occurrence (0 = unlimited) and whether RSVPs confirm at
|
|
418
|
-
// once; RSVPs are stored as bookings of kind 'rsvp'
|
|
418
|
+
// once; RSVPs are stored as bookings of kind 'rsvp'. They close at closesAt
|
|
419
|
+
// (wall time in the event's zone, like start; every date), else closeBefore
|
|
420
|
+
// minutes before each date's start, else when the date ends.
|
|
419
421
|
rsvp: z.object({
|
|
420
422
|
enabled: z.boolean().default(false),
|
|
421
423
|
capacity: z.number().int().default(0),
|
|
422
424
|
autoConfirm: z.boolean().default(true),
|
|
423
425
|
maxSeats: z.number().int().default(4),
|
|
426
|
+
closesAt: z.string().nullable().default(null),
|
|
427
|
+
closeBefore: z.number().int().default(0),
|
|
424
428
|
form: FormDefinition.optional(),
|
|
425
429
|
}).default({}),
|
|
426
430
|
// rooms/people this event uses: they can't be booked while it happens (or
|
|
@@ -674,6 +678,9 @@ export const Booking = z.object({
|
|
|
674
678
|
manageToken: z.string().default(''),
|
|
675
679
|
// web: the website; app: the public app API (/api/v1/bookings)
|
|
676
680
|
source: z.enum(['web', 'app', 'admin', 'api', 'agent']).default('web'),
|
|
681
|
+
// the language the booker used (an extra site locale, e.g. 'es'): their
|
|
682
|
+
// emails and links speak it. '' = the site's main language, as on page rows
|
|
683
|
+
locale: z.string().default(''),
|
|
677
684
|
reminderSentAt: z.number().nullable().default(null),
|
|
678
685
|
notificationVersion: z.number().default(0),
|
|
679
686
|
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)) {
|
|
@@ -12,13 +12,16 @@ import { I18N, localePath, requestLocale } from '../../../lib/i18n';
|
|
|
12
12
|
import { siteLang } from '../../../lib/dates';
|
|
13
13
|
import { isExtraLocale, localizeDoc } from '../../../lib/translations';
|
|
14
14
|
import { rsvpStrings } from '../../../lib/calendar/rsvp-words';
|
|
15
|
+
import { bookingStrings } from '../../../lib/email/booking-words';
|
|
15
16
|
import { fill } from '../../../lib/live/words';
|
|
16
17
|
|
|
17
18
|
// The booker's own page, reached from the confirmation and every email: see
|
|
18
19
|
// the status, add it to a calendar, move it or cancel it. The token in the
|
|
19
|
-
// URL is the only credential — it's long and random, and never listed.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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.
|
|
22
25
|
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
23
26
|
const repo = await getRepo(Astro.locals);
|
|
24
27
|
const booking = await repo.getBookingByToken(Astro.params.token ?? '');
|
|
@@ -32,14 +35,18 @@ const [resource, link, rawEvent] = await Promise.all([
|
|
|
32
35
|
]);
|
|
33
36
|
const isRsvp = booking.kind === 'rsvp';
|
|
34
37
|
const locale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
35
|
-
const
|
|
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 };
|
|
36
42
|
const event = rawEvent && localizeDoc(rawEvent, locale || undefined);
|
|
37
|
-
const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') ||
|
|
43
|
+
const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || bw.untitled;
|
|
38
44
|
const location = event ? locationLine(event.location) : resource?.name ?? '';
|
|
39
|
-
|
|
45
|
+
// the address the visitor sees (/es/… is rewritten to this route)
|
|
46
|
+
const url = origin + localePath(Astro.url.pathname, locale);
|
|
40
47
|
|
|
41
48
|
if (Astro.url.searchParams.get('ics')) {
|
|
42
|
-
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 }), {
|
|
43
50
|
headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'inline; filename="booking.ics"', 'Cache-Control': 'private, no-store' },
|
|
44
51
|
});
|
|
45
52
|
}
|
|
@@ -56,7 +63,7 @@ if (Astro.request.method === 'POST') {
|
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
const tz = event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
|
|
59
|
-
const when = whenText(current, tz,
|
|
66
|
+
const when = whenText(current, tz, isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
|
|
60
67
|
const active = current.status === 'pending' || current.status === 'confirmed';
|
|
61
68
|
const upcoming = current.end > Date.now();
|
|
62
69
|
const can = guestChanges(current, link);
|
|
@@ -65,11 +72,12 @@ const moved = Astro.url.searchParams.has('moved');
|
|
|
65
72
|
const heading = current.status === 'cancelled' ? w.cancelledTitle
|
|
66
73
|
: current.status === 'declined' ? w.notAvailable
|
|
67
74
|
: current.status === 'pending' ? (isNew ? w.requestSent : w.awaitingApproval)
|
|
68
|
-
: moved ?
|
|
75
|
+
: moved ? bw.moved : isNew ? w.booked : w.yours;
|
|
69
76
|
const lede = current.status === 'pending' ? fill(w.emailWhenApproved, { email: current.email })
|
|
70
77
|
: current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || fill(w.confirmationOnItsWay, { email: current.email }))
|
|
71
|
-
: current.status === 'declined' ?
|
|
78
|
+
: current.status === 'declined' ? w.declined
|
|
72
79
|
: w.cancelledText;
|
|
80
|
+
const cutoff = fill(bw.cutoff, { hours: String(can.cutoffHours) });
|
|
73
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 })}`;
|
|
74
82
|
---
|
|
75
83
|
|
|
@@ -104,17 +112,17 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
|
|
|
104
112
|
</details>
|
|
105
113
|
)}
|
|
106
114
|
{resource && link && can.reschedule && (
|
|
107
|
-
<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>
|
|
108
116
|
)}
|
|
109
117
|
{can.cancel && (
|
|
110
|
-
<form method="post" onsubmit={`return confirm(${JSON.stringify(
|
|
118
|
+
<form method="post" onsubmit={`return confirm(${JSON.stringify(w.cancelConfirm)})`}>
|
|
111
119
|
<input type="hidden" name="op" value="cancel" />
|
|
112
|
-
<button class="pc-manage-btn is-danger" type="submit">{
|
|
120
|
+
<button class="pc-manage-btn is-danger" type="submit">{w.cancel}</button>
|
|
113
121
|
</form>
|
|
114
122
|
)}
|
|
115
123
|
</div>
|
|
116
124
|
)}
|
|
117
|
-
{can.closed && <p class="pc-manage-note">
|
|
125
|
+
{can.closed && <p class="pc-manage-note">{cutoff}</p>}
|
|
118
126
|
<p class="pc-bk-foot">
|
|
119
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}
|
|
120
128
|
</p>
|
|
@@ -19,7 +19,7 @@ import { effectiveSettings } from '../../lib/designPreview';
|
|
|
19
19
|
import { resolveDesign } from '../../lib/theme';
|
|
20
20
|
import { siteOrigin } from '../../lib/seo';
|
|
21
21
|
import { CALENDAR, eventUrl, withoutPrivateFields } from '../../lib/calendar/config';
|
|
22
|
-
import { eventIsPublic, featuredOccurrence } from '../../lib/calendar/events';
|
|
22
|
+
import { eventIsPublic, featuredOccurrence, rsvpDeadline } from '../../lib/calendar/events';
|
|
23
23
|
import { eventJsonLd } from '../../lib/calendar/seo';
|
|
24
24
|
import { upcomingEvents } from '../../lib/calendar/cards';
|
|
25
25
|
import { seatsTaken, BookingError } from '../../lib/booking/service';
|
|
@@ -48,8 +48,9 @@ const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-
|
|
|
48
48
|
if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
49
49
|
const origin = siteOrigin(settings, Astro.url);
|
|
50
50
|
// RSVP: "Save my seat" posts back here and lands on the manage page, in the
|
|
51
|
-
// visitor's language
|
|
51
|
+
// visitor's language (its emails speak it too)
|
|
52
52
|
let rsvpError = '';
|
|
53
|
+
let rsvpDuplicate = false;
|
|
53
54
|
let formValues: Record<string,string> = {};
|
|
54
55
|
let coreValues = { name: '', email: '', seats: '1' };
|
|
55
56
|
let requestId: string = crypto.randomUUID();
|
|
@@ -63,16 +64,22 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
|
|
|
63
64
|
requestId = await submissionGuard(repo, form, Astro.clientAddress, `event:${event.id}`);
|
|
64
65
|
formValues = readAnswers(form, definition);
|
|
65
66
|
coreValues = { name: String(form.get('name') || '').slice(0,120), email: String(form.get('email') || '').slice(0,200), seats: String(form.get('seats') || '1') };
|
|
66
|
-
const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
|
|
67
|
+
const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) }, locale);
|
|
67
68
|
return Astro.redirect(`${localePath(manageUrl(booking.manageToken), locale)}?new=1`, 303);
|
|
68
69
|
} catch (err) {
|
|
69
|
-
|
|
70
|
+
const w = rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings));
|
|
71
|
+
// the address already has one: a notice in the page's words (templates that don't know it show it as the error)
|
|
72
|
+
rsvpDuplicate = err instanceof BookingError && err.code === 'duplicate';
|
|
73
|
+
rsvpError = rsvpDuplicate ? `${w.duplicate} ${w.duplicateNote}` : err instanceof BookingError || err instanceof ApiError ? err.message : w.saveError;
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
|
|
73
77
|
const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
|
|
78
|
+
// the event's RSVP deadline for this date (null: open until it ends)
|
|
79
|
+
const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
|
|
74
80
|
const rsvp = event.rsvp.enabled
|
|
75
|
-
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues,
|
|
81
|
+
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues,
|
|
82
|
+
open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt }
|
|
76
83
|
: null;
|
|
77
84
|
const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
|
|
78
85
|
const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
|
package/src/styles/editor.css
CHANGED
|
@@ -1168,6 +1168,10 @@
|
|
|
1168
1168
|
.pe-evr-modes + .pe-evs-fields { margin-top: 22px; }
|
|
1169
1169
|
.pe-evr-link { grid-template-columns: minmax(0, 1fr) 150px; }
|
|
1170
1170
|
.pe-evr-seats { grid-template-columns: repeat(2, minmax(0, 200px)); }
|
|
1171
|
+
.pe-evr-when { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
|
|
1172
|
+
.pe-evr-when > span { font-size: 13px; color: var(--muted); }
|
|
1173
|
+
.pe-field .pe-evr-when input, .pe-field .pe-evr-when select { width: auto; }
|
|
1174
|
+
.pe-field .pe-evr-when input[type='number'] { width: 84px; }
|
|
1171
1175
|
.pe-evr-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--panel); }
|
|
1172
1176
|
.pe-evr-form-t { flex: 1 1 240px; min-width: 0; }
|
|
1173
1177
|
.pe-evr-form-t b { display: block; font-size: 13.5px; font-weight: 600; color: var(--text); }
|
|
@@ -88,6 +88,13 @@
|
|
|
88
88
|
.ma-tag { display: inline-flex; align-items: center; gap: 5px; background: var(--sel); color: var(--text-2); font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: var(--r-full); }
|
|
89
89
|
.ma-tag button { background: none; border: 0; color: inherit; cursor: pointer; font-size: 10px; padding: 0; }
|
|
90
90
|
.ma-tags input { flex: 1; min-width: 70px; background: none !important; border: 0 !important; box-shadow: none !important; padding: 2px !important; }
|
|
91
|
+
/* "when an event changes": a card per rule */
|
|
92
|
+
.ma-subrules { display: grid; gap: 8px; margin-top: 10px; }
|
|
93
|
+
.ma-subrule { padding: 6px 12px 12px; border: 1px solid var(--line); border-radius: var(--r-md); min-width: 0; }
|
|
94
|
+
.ma-subrule-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 30px; }
|
|
95
|
+
.ma-subrule-head b { font-size: 12.5px; color: var(--text-2); }
|
|
96
|
+
.ma-subrule .ma-choices { margin-top: 2px; }
|
|
97
|
+
.ma-add { margin-top: 10px; }
|
|
91
98
|
.ma .form-actions { margin-top: 6px; }
|
|
92
99
|
.ma-saved { color: var(--green); font-size: 12.5px; font-weight: 500; }
|
|
93
100
|
|
package/src/styles/site.css
CHANGED
|
@@ -1147,6 +1147,9 @@ li::marker { color: var(--muted); }
|
|
|
1147
1147
|
.pc-ev-rsvpform[open] > summary { display: none; }
|
|
1148
1148
|
.pc-ev-rsvpform form { margin-top: 4px; }
|
|
1149
1149
|
.pc-ev-full { font-size: 14px; font-weight: 600; color: var(--muted); }
|
|
1150
|
+
.pc-ev-closes { font-size: 13.5px; color: var(--muted); margin: 0 0 10px; }
|
|
1151
|
+
.pc-ev-full + .pc-ev-closes { margin-top: 4px; }
|
|
1152
|
+
.pc-ev-notice { font-size: 14px; line-height: 1.5; background: var(--soft); border-radius: var(--r-sm); padding: 9px 12px; margin: 0 0 14px; }
|
|
1150
1153
|
|
|
1151
1154
|
@media (max-width: 860px) {
|
|
1152
1155
|
.pc-bk { grid-template-columns: 1fr; }
|
package/src/theme-sdk/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface HomeProps { settings: Settings; page: Page | null; posts: Post[
|
|
|
11
11
|
export interface PageProps { settings: Settings; page: Page; html: string }
|
|
12
12
|
export interface PostProps { settings: Settings; post: Post; html: string; authors: User[]; related: Post[] }
|
|
13
13
|
import type { Occurrence } from '../lib/calendar/events';
|
|
14
|
-
export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean}|null }
|
|
14
|
+
export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean;closesAt?:number|null;duplicate?:boolean}|null }
|
|
15
15
|
/**
|
|
16
16
|
* Props for a coded section. `data` holds resolved values: link fields arrive
|
|
17
17
|
* as LinkValue, lists as row arrays, source fields as fresh item arrays
|
|
@@ -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
|
|