@acms-subir/core 0.2.0 → 0.3.1
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 +87 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/live-video.md +91 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +189 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +578 -49
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +119 -58
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +58 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +352 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +56 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +221 -0
- package/src/lib/repo/sql.ts +907 -68
- package/src/lib/repo/types.ts +125 -5
- package/src/lib/scheduler.ts +27 -0
- package/src/lib/schema.ts +316 -1
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +183 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +147 -0
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +45 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +14 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +4 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +66 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +12 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +29 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +81 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +55 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +27 -37
- package/src/themes/editorial/theme.json +25 -3
- package/src/themes/field/theme.json +27 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +28 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Agent / MCP tools for bookings. Reads are always available to the agent
|
|
2
|
+
// key; anything that emails a guest or changes a booking needs the owner's
|
|
3
|
+
// "agent publishing" permission, like publishing content.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { ContentRepository } from '../repo/types';
|
|
6
|
+
import type { Booking } from '../schema';
|
|
7
|
+
import { ApiError } from '../api';
|
|
8
|
+
import { bookingsEnabled } from '../calendar/config';
|
|
9
|
+
import { availabilityContext, bookingJson, BookingError, createResourceBooking, rescheduleBooking, setBookingStatus } from './service';
|
|
10
|
+
import { addDaysYmd, describeLink, slotsForDay, ymd } from './availability';
|
|
11
|
+
|
|
12
|
+
export const BookingToolInputs = [
|
|
13
|
+
z.object({ tool: z.literal('list_resources'), arguments: z.object({}).strict() }).strict(),
|
|
14
|
+
z.object({ tool: z.literal('check_availability'), arguments: z.object({
|
|
15
|
+
resource: z.string().min(1), link: z.string().min(1),
|
|
16
|
+
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), days: z.number().int().min(1).max(31).default(7),
|
|
17
|
+
minutes: z.number().int().optional(),
|
|
18
|
+
}).strict() }).strict(),
|
|
19
|
+
z.object({ tool: z.literal('list_bookings'), arguments: z.object({
|
|
20
|
+
status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']).optional(), resource: z.string().optional(),
|
|
21
|
+
from: z.string().optional(), limit: z.number().int().min(1).max(100).default(25),
|
|
22
|
+
}).strict() }).strict(),
|
|
23
|
+
z.object({ tool: z.literal('create_booking'), arguments: z.object({
|
|
24
|
+
resource: z.string().min(1), link: z.string().min(1), start: z.string().min(1), minutes: z.number().int().optional(),
|
|
25
|
+
name: z.string().min(1).max(120), email: z.string().email(), phone: z.string().max(40).optional(),
|
|
26
|
+
answers: z.record(z.string()).optional(),
|
|
27
|
+
}).strict() }).strict(),
|
|
28
|
+
z.object({ tool: z.literal('update_booking'), arguments: z.object({
|
|
29
|
+
id: z.string().min(1), status: z.enum(['confirmed', 'declined', 'cancelled']).optional(),
|
|
30
|
+
start: z.string().optional(), notes: z.string().max(4000).optional(), message: z.string().max(1000).optional(),
|
|
31
|
+
}).strict() }).strict(),
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export const BOOKING_TOOL_NAMES = ['list_resources', 'check_availability', 'list_bookings', 'create_booking', 'update_booking'] as const;
|
|
35
|
+
export const BOOKING_WRITE_TOOLS = ['create_booking', 'update_booking'];
|
|
36
|
+
export const BOOKING_TOOL_DESCRIPTIONS: Record<(typeof BOOKING_TOOL_NAMES)[number], string> = {
|
|
37
|
+
list_resources: 'List bookable resources (rooms, people, equipment) and their booking links with rules (length, auto-confirm, visibility).',
|
|
38
|
+
check_availability: 'Open start times for a resource + link (slugs) over the next days. Returns ISO start/end per slot.',
|
|
39
|
+
list_bookings: 'List bookings (guest name, email, answers, status). Filter by status (pending = waiting for approval) or resource slug.',
|
|
40
|
+
create_booking: 'Book a slot for a guest (checks availability; confirms or holds for approval per the link; emails the guest). Needs publishing permission.',
|
|
41
|
+
update_booking: 'Confirm, decline or cancel a booking, move it (start), or set private notes. Emails the guest. Needs publishing permission.',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Input = z.infer<(typeof BookingToolInputs)[number]>;
|
|
45
|
+
const ms = (s: string) => {
|
|
46
|
+
const n = Date.parse(s);
|
|
47
|
+
if (!Number.isFinite(n)) throw new ApiError(400, `"${s}" isn't an ISO date-time.`);
|
|
48
|
+
return n;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export async function runBookingTool(repo: ContentRepository, input: Input, allowWrite: boolean, ctx: { origin: string; waitUntil?: (p: Promise<unknown>) => void }) {
|
|
52
|
+
if (!bookingsEnabled()) throw new ApiError(404, 'Bookings are disabled on this site.');
|
|
53
|
+
if (BOOKING_WRITE_TOOLS.includes(input.tool) && !allowWrite) throw new ApiError(403, 'Agent publishing is disabled. An owner can enable it in Settings.');
|
|
54
|
+
const settings = await repo.getSettings();
|
|
55
|
+
const resources = await repo.listResources();
|
|
56
|
+
const links = await repo.listBookingLinks();
|
|
57
|
+
const resolve = (resource: string, link: string) => {
|
|
58
|
+
const r = resources.find((x) => x.slug === resource || x.id === resource);
|
|
59
|
+
const l = r ? links.find((x) => x.resourceId === r.id && (x.slug === link || x.id === link)) : undefined;
|
|
60
|
+
if (!r || !l) throw new ApiError(404, 'Unknown resource or link. Call list_resources.');
|
|
61
|
+
return { r, l };
|
|
62
|
+
};
|
|
63
|
+
const flow = { origin: ctx.origin, waitUntil: ctx.waitUntil };
|
|
64
|
+
const out = (b: Booking) => bookingJson(b, { resourceSlug: resources.find((r) => r.id === b.resourceId)?.slug, linkSlug: links.find((l) => l.id === b.linkId)?.slug });
|
|
65
|
+
try {
|
|
66
|
+
switch (input.tool) {
|
|
67
|
+
case 'list_resources':
|
|
68
|
+
return { resources: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, capacity: r.capacity, visibility: r.visibility, links: links.filter((l) => l.resourceId === r.id).map((l) => ({ slug: l.slug, name: l.name, rules: describeLink(l), mode: l.mode, lengths: l.mode === 'range' ? l.lengths : [l.duration], autoConfirm: l.autoConfirm, active: l.active, visibility: l.visibility })) })) };
|
|
69
|
+
case 'check_availability': {
|
|
70
|
+
const { r, l } = resolve(input.arguments.resource, input.arguments.link);
|
|
71
|
+
const c = await availabilityContext(repo, r, l, settings);
|
|
72
|
+
const tz = r.timezone || settings.timezone || 'UTC';
|
|
73
|
+
const from = input.arguments.from ?? ymd(Date.now(), tz);
|
|
74
|
+
const days = Array.from({ length: input.arguments.days }, (_, i) => addDaysYmd(from, i)).map((date) => ({
|
|
75
|
+
date, slots: slotsForDay(c, date, input.arguments.minutes).slice(0, 60).map((s) => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
|
|
76
|
+
}));
|
|
77
|
+
return { timezone: tz, autoConfirm: l.autoConfirm, days };
|
|
78
|
+
}
|
|
79
|
+
case 'list_bookings': {
|
|
80
|
+
const a = input.arguments;
|
|
81
|
+
const r = a.resource ? resources.find((x) => x.slug === a.resource) : undefined;
|
|
82
|
+
if (a.resource && !r) throw new ApiError(404, 'Unknown resource.');
|
|
83
|
+
const list = await repo.listBookings({ resourceId: r?.id, from: a.from ? ms(a.from) : Date.now() - 86_400_000, statuses: a.status ? [a.status] : undefined });
|
|
84
|
+
return { total: list.length, items: list.slice(0, a.limit).map(out) };
|
|
85
|
+
}
|
|
86
|
+
case 'create_booking': {
|
|
87
|
+
const a = input.arguments;
|
|
88
|
+
const { r, l } = resolve(a.resource, a.link);
|
|
89
|
+
const start = ms(a.start);
|
|
90
|
+
const minutes = a.minutes ?? (l.mode === 'day' ? 1440 : l.mode === 'range' ? l.lengths[0] ?? l.duration : l.duration);
|
|
91
|
+
const b = await createResourceBooking(repo, { resource: r, link: l, start, end: start + minutes * 60_000, source: 'agent', booker: { name: a.name, email: a.email, phone: a.phone, answers: a.answers } }, flow);
|
|
92
|
+
return out(b);
|
|
93
|
+
}
|
|
94
|
+
case 'update_booking': {
|
|
95
|
+
const a = input.arguments;
|
|
96
|
+
let b = await repo.getBooking(a.id);
|
|
97
|
+
if (!b) throw new ApiError(404, 'Booking not found.');
|
|
98
|
+
if (a.notes !== undefined) await repo.updateBooking(b.id, { notes: a.notes });
|
|
99
|
+
if (a.start) b = await rescheduleBooking(repo, b, ms(a.start), ms(a.start) + (b.end - b.start), { ...flow, skipRules: false });
|
|
100
|
+
if (a.status) b = await setBookingStatus(repo, b, a.status, flow, a.message);
|
|
101
|
+
return out((await repo.getBooking(b.id))!);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err instanceof BookingError) throw new ApiError(err.status, err.message);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// The availability engine: what can be booked, and when. Pure — callers pass
|
|
2
|
+
// in the resource, the link, the bookings that hold time and any busy
|
|
3
|
+
// intervals from external calendars.
|
|
4
|
+
//
|
|
5
|
+
// resource hours (or a date override) ∩ link hours → open windows
|
|
6
|
+
// open windows − holds (bookings ± buffers) − busy feeds − notice/advance
|
|
7
|
+
// → start times of the link's length (slots / range) or whole days (day)
|
|
8
|
+
//
|
|
9
|
+
// All wall-clock math happens in the resource's zone, so "9am–5pm" stays
|
|
10
|
+
// 9–5 across DST.
|
|
11
|
+
import type { Booking, BookingLink, Resource, TimeRange, Weekday } from '../schema';
|
|
12
|
+
import { WEEKDAYS } from '../schema';
|
|
13
|
+
import { tzParts, zonedToUtc } from '../calendar/ics';
|
|
14
|
+
import { isValidTimeZone } from '../calendar/events';
|
|
15
|
+
|
|
16
|
+
const DAY = 86_400_000;
|
|
17
|
+
const MIN = 60_000;
|
|
18
|
+
|
|
19
|
+
export interface Interval { start: number; end: number }
|
|
20
|
+
export interface Slot { start: number; end: number; minutes: number }
|
|
21
|
+
|
|
22
|
+
export interface AvailabilityContext {
|
|
23
|
+
resource: Resource;
|
|
24
|
+
link: BookingLink;
|
|
25
|
+
// pending + confirmed holds on this resource (any link) — other links'
|
|
26
|
+
// buffers already live in each booking's holdStart/holdEnd
|
|
27
|
+
bookings: Pick<Booking, 'holdStart' | 'holdEnd' | 'start' | 'linkId' | 'status' | 'kind' | 'id'>[];
|
|
28
|
+
busy: Interval[];
|
|
29
|
+
siteTimezone: string;
|
|
30
|
+
now: number;
|
|
31
|
+
// reschedule: ignore this booking's own hold
|
|
32
|
+
ignoreBookingId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
36
|
+
export const resourceZone = (r: Pick<Resource, 'timezone'>, siteTz: string) =>
|
|
37
|
+
isValidTimeZone(r.timezone) ? r.timezone : isValidTimeZone(siteTz) ? siteTz : 'UTC';
|
|
38
|
+
|
|
39
|
+
const minutesOf = (hhmm: string) => {
|
|
40
|
+
const [h, m] = hhmm.split(':').map(Number);
|
|
41
|
+
return h * 60 + m;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function ymd(ms: number, tz: string): string {
|
|
45
|
+
const p = tzParts(ms, tz);
|
|
46
|
+
return `${p.y}-${pad(p.m)}-${pad(p.d)}`;
|
|
47
|
+
}
|
|
48
|
+
export function addDaysYmd(date: string, n: number): string {
|
|
49
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
50
|
+
const t = new Date(Date.UTC(y, m - 1, d + n));
|
|
51
|
+
return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
|
|
52
|
+
}
|
|
53
|
+
const weekdayOf = (date: string): Weekday => {
|
|
54
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
55
|
+
return WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
|
|
56
|
+
};
|
|
57
|
+
export const localMidnight = (date: string, tz: string) => {
|
|
58
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
59
|
+
return zonedToUtc(y, m, d, 0, 0, 0, tz);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// minute ranges for one local date: override wins over weekly hours; a link
|
|
63
|
+
// with its own hours can only narrow them
|
|
64
|
+
export function dayRanges(resource: Resource, link: BookingLink | null, date: string): [number, number][] {
|
|
65
|
+
const ov = resource.overrides.find((o) => date >= o.date && date <= (o.endDate || o.date));
|
|
66
|
+
const base = (ov ? ov.ranges : resource.hours[weekdayOf(date)]) ?? [];
|
|
67
|
+
let ranges = normalize(base);
|
|
68
|
+
if (link?.hours) ranges = intersect(ranges, normalize(link.hours[weekdayOf(date)] ?? []));
|
|
69
|
+
return ranges;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalize(list: TimeRange[]): [number, number][] {
|
|
73
|
+
const r = list
|
|
74
|
+
.map((t) => [minutesOf(t.start), t.end === '00:00' ? 1440 : minutesOf(t.end)] as [number, number])
|
|
75
|
+
.filter(([a, b]) => b > a)
|
|
76
|
+
.sort((a, b) => a[0] - b[0]);
|
|
77
|
+
const out: [number, number][] = [];
|
|
78
|
+
for (const cur of r) {
|
|
79
|
+
const last = out[out.length - 1];
|
|
80
|
+
if (last && cur[0] <= last[1]) last[1] = Math.max(last[1], cur[1]);
|
|
81
|
+
else out.push([...cur]);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function intersect(a: [number, number][], b: [number, number][]): [number, number][] {
|
|
87
|
+
const out: [number, number][] = [];
|
|
88
|
+
for (const [s1, e1] of a) for (const [s2, e2] of b) {
|
|
89
|
+
const s = Math.max(s1, s2), e = Math.min(e1, e2);
|
|
90
|
+
if (e > s) out.push([s, e]);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const overlaps = (a0: number, a1: number, b0: number, b1: number) => a0 < b1 && a1 > b0;
|
|
96
|
+
|
|
97
|
+
function blocked(ctx: AvailabilityContext, start: number, end: number): boolean {
|
|
98
|
+
const hs = start - ctx.link.bufferBefore * MIN;
|
|
99
|
+
const he = end + ctx.link.bufferAfter * MIN;
|
|
100
|
+
for (const b of ctx.bookings) {
|
|
101
|
+
if (b.id === ctx.ignoreBookingId) continue;
|
|
102
|
+
if (b.status !== 'pending' && b.status !== 'confirmed') continue;
|
|
103
|
+
if (b.kind === 'rsvp') continue;
|
|
104
|
+
if (overlaps(hs, he, b.holdStart, b.holdEnd)) return true;
|
|
105
|
+
}
|
|
106
|
+
for (const i of ctx.busy) if (overlaps(start, end, i.start, i.end)) return true;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function capReached(ctx: AvailabilityContext, date: string, tz: string): boolean {
|
|
111
|
+
if (!ctx.link.perDayCap) return false;
|
|
112
|
+
const n = ctx.bookings.filter(
|
|
113
|
+
(b) => b.linkId === ctx.link.id && b.id !== ctx.ignoreBookingId && (b.status === 'pending' || b.status === 'confirmed') && ymd(b.start, tz) === date
|
|
114
|
+
).length;
|
|
115
|
+
return n >= ctx.link.perDayCap;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function linkLengths(link: BookingLink): number[] {
|
|
119
|
+
if (link.mode === 'range') {
|
|
120
|
+
const l = [...new Set(link.lengths.filter((n) => n >= 5 && n <= 24 * 60))].sort((a, b) => a - b);
|
|
121
|
+
return l.length ? l : [link.duration];
|
|
122
|
+
}
|
|
123
|
+
return [Math.max(5, link.duration)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// the open start times on one local date (all lengths unless `minutes` given)
|
|
127
|
+
export function slotsForDay(ctx: AvailabilityContext, date: string, minutes?: number): Slot[] {
|
|
128
|
+
const tz = resourceZone(ctx.resource, ctx.siteTimezone);
|
|
129
|
+
const earliest = ctx.now + ctx.link.minNotice * MIN;
|
|
130
|
+
const latest = ctx.now + ctx.link.maxAdvance * DAY;
|
|
131
|
+
if (capReached(ctx, date, tz)) return [];
|
|
132
|
+
const midnight = localMidnight(date, tz);
|
|
133
|
+
if (ctx.link.mode === 'day') {
|
|
134
|
+
const out: Slot[] = [];
|
|
135
|
+
for (let n = Math.max(1, ctx.link.minDays); n <= Math.max(ctx.link.minDays, ctx.link.maxDays); n++) {
|
|
136
|
+
if (minutes && minutes !== n * 1440) continue;
|
|
137
|
+
// every day in the span must be open at all; the whole span must be free
|
|
138
|
+
let open = true;
|
|
139
|
+
for (let k = 0; k < n; k++) if (!dayRanges(ctx.resource, ctx.link, addDaysYmd(date, k)).length) open = false;
|
|
140
|
+
const start = midnight;
|
|
141
|
+
const end = localMidnight(addDaysYmd(date, n), tz);
|
|
142
|
+
// a day that has already begun (or falls inside the notice window) is out
|
|
143
|
+
if (!open || start < earliest || start > latest) continue;
|
|
144
|
+
if (blocked(ctx, start, end)) continue;
|
|
145
|
+
out.push({ start, end, minutes: n * 1440 });
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
const step = Math.max(5, ctx.link.step) * MIN;
|
|
150
|
+
const lengths = minutes ? [minutes] : linkLengths(ctx.link);
|
|
151
|
+
const out: Slot[] = [];
|
|
152
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
153
|
+
for (const [a, b] of dayRanges(ctx.resource, ctx.link, date)) {
|
|
154
|
+
// wall-clock → UTC per boundary, so a window spanning a DST change is right
|
|
155
|
+
const wStart = zonedToUtc(y, m, d, Math.floor(a / 60), a % 60, 0, tz);
|
|
156
|
+
const wEnd = b >= 1440 ? localMidnight(addDaysYmd(date, 1), tz) : zonedToUtc(y, m, d, Math.floor(b / 60), b % 60, 0, tz);
|
|
157
|
+
for (const len of lengths) {
|
|
158
|
+
for (let s = wStart; s + len * MIN <= wEnd; s += step) {
|
|
159
|
+
if (s < earliest || s > latest) continue;
|
|
160
|
+
const e = s + len * MIN;
|
|
161
|
+
if (blocked(ctx, s, e)) continue;
|
|
162
|
+
out.push({ start: s, end: e, minutes: len });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out.sort((p, q) => p.start - q.start || p.minutes - q.minutes);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// dates (in the resource zone) with at least one opening
|
|
170
|
+
export function openDays(ctx: AvailabilityContext, fromDate: string, days: number, minutes?: number): string[] {
|
|
171
|
+
const out: string[] = [];
|
|
172
|
+
for (let i = 0; i < days; i++) {
|
|
173
|
+
const date = addDaysYmd(fromDate, i);
|
|
174
|
+
if (slotsForDay(ctx, date, minutes).length) out.push(date);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function nextOpenings(ctx: AvailabilityContext, count: number, horizonDays = 60): Slot[] {
|
|
180
|
+
const tz = resourceZone(ctx.resource, ctx.siteTimezone);
|
|
181
|
+
const out: Slot[] = [];
|
|
182
|
+
const seenDays = new Set<string>();
|
|
183
|
+
let date = ymd(ctx.now, tz);
|
|
184
|
+
for (let i = 0; i < Math.min(horizonDays, ctx.link.maxAdvance + 1) && out.length < count; i++, date = addDaysYmd(date, 1)) {
|
|
185
|
+
const slots = slotsForDay(ctx, date);
|
|
186
|
+
// range/day links: one chip per day reads better than 5 starts on day one
|
|
187
|
+
const pick = ctx.link.mode === 'slots' ? slots : slots.slice(0, 1);
|
|
188
|
+
for (const s of pick) {
|
|
189
|
+
if (ctx.link.mode !== 'slots' && seenDays.has(date)) continue;
|
|
190
|
+
seenDays.add(date);
|
|
191
|
+
out.push(s);
|
|
192
|
+
if (out.length >= count) break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// server-side check before an insert (the DB guard backs it up for races)
|
|
199
|
+
export function isBookable(ctx: AvailabilityContext, start: number, end: number): boolean {
|
|
200
|
+
const tz = resourceZone(ctx.resource, ctx.siteTimezone);
|
|
201
|
+
const minutes = Math.round((end - start) / MIN);
|
|
202
|
+
return slotsForDay(ctx, ymd(start, tz), minutes).some((s) => s.start === start && s.end === end);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const holdOf = (link: Pick<BookingLink, 'bufferBefore' | 'bufferAfter'>, start: number, end: number) => ({
|
|
206
|
+
holdStart: start - link.bufferBefore * MIN,
|
|
207
|
+
holdEnd: end + link.bufferAfter * MIN,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
export function describeLength(minutes: number): string {
|
|
211
|
+
if (minutes % 1440 === 0) return `${minutes / 1440} day${minutes === 1440 ? '' : 's'}`;
|
|
212
|
+
if (minutes < 60) return `${minutes} min`;
|
|
213
|
+
const h = Math.floor(minutes / 60), m = minutes % 60;
|
|
214
|
+
return m ? `${h} h ${m} min` : `${h} h`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function describeLink(link: BookingLink): string {
|
|
218
|
+
if (link.mode === 'day') return link.minDays === link.maxDays ? describeLength(link.minDays * 1440) : `${link.minDays}–${link.maxDays} days`;
|
|
219
|
+
const l = linkLengths(link);
|
|
220
|
+
return l.length === 1 ? describeLength(l[0]) : `${describeLength(l[0])} – ${describeLength(l[l.length - 1])}`;
|
|
221
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Booking emails: plain, readable HTML (inline styles, no images) plus a
|
|
2
|
+
// text part. Confirmed bookings carry an .ics so one tap adds them to the
|
|
3
|
+
// booker's calendar.
|
|
4
|
+
import type { Booking, BookingLink, Resource, Settings } from '../schema';
|
|
5
|
+
import { siteLocale } from '../dates';
|
|
6
|
+
import { escapeText, foldLine } from '../calendar/ics-write';
|
|
7
|
+
import type { Mail } from '../email';
|
|
8
|
+
|
|
9
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
10
|
+
|
|
11
|
+
export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings: Settings, allDayish = false): string {
|
|
12
|
+
const locale = siteLocale(settings);
|
|
13
|
+
const f = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: tz });
|
|
14
|
+
if (allDayish) {
|
|
15
|
+
const last = b.end - 1;
|
|
16
|
+
const d = f({ weekday: 'short', month: 'short', day: 'numeric' });
|
|
17
|
+
return d.format(b.start) === d.format(last) ? `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · all day` : `${d.format(b.start)} – ${d.format(last)}`;
|
|
18
|
+
}
|
|
19
|
+
const zone = f({ hour: 'numeric', timeZoneName: 'short' }).formatToParts(b.start).find((p) => p.type === 'timeZoneName')?.value ?? '';
|
|
20
|
+
return `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · ${f({ hour: 'numeric', minute: '2-digit' }).format(b.start)} – ${f({ hour: 'numeric', minute: '2-digit' }).format(b.end)} ${zone}`.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
24
|
+
|
|
25
|
+
export function bookingIcs(b: Booking, title: string, location: string, url: string, host: string, method: 'REQUEST' | 'CANCEL' = 'REQUEST', options: { organizer?: string; allDay?: boolean } = {}): string {
|
|
26
|
+
const day = (ms: number) => new Intl.DateTimeFormat('en-CA', { timeZone: b.timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(ms).replaceAll('-', '');
|
|
27
|
+
const actualMethod = method === 'CANCEL' ? 'CANCEL' : options.organizer ? 'REQUEST' : 'PUBLISH';
|
|
28
|
+
const lines = [
|
|
29
|
+
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//acms-subir//Bookings//EN', `METHOD:${actualMethod}`,
|
|
30
|
+
'BEGIN:VEVENT', `UID:booking-${b.id}@acms.invalid`, `DTSTAMP:${utc(Date.now())}`, ...(options.allDay ? [`DTSTART;VALUE=DATE:${day(b.start)}`, `DTEND;VALUE=DATE:${day(b.end)}`] : [`DTSTART:${utc(b.start)}`, `DTEND:${utc(b.end)}`]),
|
|
31
|
+
...(options.organizer ? [`ORGANIZER:mailto:${options.organizer}`, `ATTENDEE;RSVP=FALSE:mailto:${b.email}`] : []),
|
|
32
|
+
`SUMMARY:${escapeText(title)}`, ...(location ? [`LOCATION:${escapeText(location)}`] : []), `DESCRIPTION:${escapeText(`Manage: ${url}`)}`,
|
|
33
|
+
`URL:${url}`, `STATUS:${method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`, `SEQUENCE:${b.notificationVersion ?? 0}`,
|
|
34
|
+
'END:VEVENT', 'END:VCALENDAR',
|
|
35
|
+
];
|
|
36
|
+
return lines.map(foldLine).join('\r\n') + '\r\n';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Ctx {
|
|
40
|
+
booking: Booking;
|
|
41
|
+
resource: Resource | null;
|
|
42
|
+
link: BookingLink | null;
|
|
43
|
+
title: string; // "Fellowship Hall · Event or reception" / event title
|
|
44
|
+
location: string;
|
|
45
|
+
when: string;
|
|
46
|
+
manageUrl: string;
|
|
47
|
+
siteTitle: string;
|
|
48
|
+
host: string;
|
|
49
|
+
message?: string;
|
|
50
|
+
organizer?: string;
|
|
51
|
+
allDay?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function layout(heading: string, intro: string, c: Ctx, button?: { href: string; label: string }): { html: string; text: string } {
|
|
55
|
+
const rows: [string, string][] = [['What', c.title], ['When', c.when], ...(c.location ? [['Where', c.location] as [string, string]] : [])];
|
|
56
|
+
if (c.booking.seats > 1) rows.push(['Seats', String(c.booking.seats)]);
|
|
57
|
+
const html = `<!doctype html><html><body style="margin:0;background:#f6f5f2;font-family:-apple-system,Segoe UI,Inter,Arial,sans-serif;color:#1e1f24">
|
|
58
|
+
<div style="max-width:520px;margin:0 auto;padding:28px 20px">
|
|
59
|
+
<p style="font-size:13px;color:#6b6e78;margin:0 0 18px">${esc(c.siteTitle)}</p>
|
|
60
|
+
<h1 style="font-size:22px;margin:0 0 8px">${esc(heading)}</h1>
|
|
61
|
+
<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>
|
|
62
|
+
<table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #e5e3de;border-radius:10px">${rows
|
|
63
|
+
.map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${k}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v)}</td></tr>`)
|
|
64
|
+
.join('')}</table>
|
|
65
|
+
${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
|
|
66
|
+
${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="display:inline-block;background:#1e1f24;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:10px 16px;border-radius:8px">${esc(button.label)}</a></p>` : ''}
|
|
67
|
+
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">Reference ${esc(c.booking.id.slice(0, 8).toUpperCase())}</p>
|
|
68
|
+
</div></body></html>`;
|
|
69
|
+
const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', `Reference ${c.booking.id.slice(0, 8).toUpperCase()}`].join('\n');
|
|
70
|
+
return { html, text };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'staff-new';
|
|
74
|
+
|
|
75
|
+
export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
76
|
+
const manage = { href: c.manageUrl, label: 'View or change your booking' };
|
|
77
|
+
const ics = (method: 'REQUEST' | 'CANCEL' = 'REQUEST') => [{ filename: 'booking.ics', type: `text/calendar; charset=utf-8; method=${method === 'CANCEL' ? 'CANCEL' : c.organizer ? 'REQUEST' : 'PUBLISH'}`, content: bookingIcs(c.booking, c.title, c.location, c.manageUrl, c.host, method, { organizer: c.organizer, allDay: c.allDay }) }];
|
|
78
|
+
switch (kind) {
|
|
79
|
+
case 'received': {
|
|
80
|
+
const m = layout('Request received', `Thanks, ${c.booking.name || 'there'}. We'll confirm by email soon.`, c, manage);
|
|
81
|
+
return { to: c.booking.email, subject: `Request received: ${c.title}`, ...m };
|
|
82
|
+
}
|
|
83
|
+
case 'confirmed': {
|
|
84
|
+
const m = layout('You’re booked', `It’s confirmed. Import the attached file to add it to your calendar.`, { ...c, message: c.message || c.link?.confirmMessage || '' }, manage);
|
|
85
|
+
return { to: c.booking.email, subject: `Confirmed: ${c.title} — ${c.when}`, ...m, attachments: ics() };
|
|
86
|
+
}
|
|
87
|
+
case 'declined': {
|
|
88
|
+
const m = layout('We couldn’t confirm your request', 'Sorry — this time isn’t available. You’re welcome to pick another.', c);
|
|
89
|
+
return { to: c.booking.email, subject: `Not available: ${c.title}`, ...m };
|
|
90
|
+
}
|
|
91
|
+
case 'cancelled': {
|
|
92
|
+
const m = layout('Booking cancelled', 'This booking has been cancelled.', c);
|
|
93
|
+
return { to: c.booking.email, subject: `Cancelled: ${c.title}`, ...m, attachments: ics('CANCEL') };
|
|
94
|
+
}
|
|
95
|
+
case 'updated': {
|
|
96
|
+
const m = layout('Booking details updated', 'Please review the latest details. Use the attached calendar file to update your calendar.', c, manage);
|
|
97
|
+
return { to: c.booking.email, subject: `Updated: ${c.title}`, ...m, attachments: ics() };
|
|
98
|
+
}
|
|
99
|
+
case 'rescheduled': {
|
|
100
|
+
const m = layout('Booking moved', 'Your booking has a new time. Import the attached calendar file to update your calendar.', c, manage);
|
|
101
|
+
return { to: c.booking.email, subject: `New time: ${c.title} — ${c.when}`, ...m, attachments: ics() };
|
|
102
|
+
}
|
|
103
|
+
case 'reminder': {
|
|
104
|
+
const m = layout('Coming up soon', 'A quick reminder about your booking.', c, manage);
|
|
105
|
+
return { to: c.booking.email, subject: `Reminder: ${c.title} — ${c.when}`, ...m };
|
|
106
|
+
}
|
|
107
|
+
case 'staff-new': {
|
|
108
|
+
const pending = c.booking.status === 'pending';
|
|
109
|
+
const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
|
|
110
|
+
const answers = Object.entries(c.booking.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
111
|
+
const m = layout(pending ? 'New request to approve' : 'New booking', `${who}${answers ? `\n${answers}` : ''}`, c, { href: c.manageUrl, label: pending ? 'Review in the admin' : 'Open in the admin' });
|
|
112
|
+
return { to: [], subject: `${pending ? 'Approve' : 'New booking'}: ${c.title} — ${c.when}`, ...m, replyTo: c.booking.email || undefined };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Form ⇄ data helpers for the admin booking screens (plain HTML forms, no
|
|
2
|
+
// island): weekly hours rows, date overrides, questions, comma lists.
|
|
3
|
+
import { WEEKDAYS, type BookingQuestion, type DateOverride, type TimeRange, type WeeklyHours } from '../schema';
|
|
4
|
+
import { QUESTION_TYPES } from '../schema';
|
|
5
|
+
|
|
6
|
+
export const DAY_LABELS: Record<(typeof WEEKDAYS)[number], string> = { su: 'Sun', mo: 'Mon', tu: 'Tue', we: 'Wed', th: 'Thu', fr: 'Fri', sa: 'Sat' };
|
|
7
|
+
// Monday-first reads naturally in an hours editor
|
|
8
|
+
export const DAY_ORDER = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] as const;
|
|
9
|
+
|
|
10
|
+
const hhmm = (v: FormDataEntryValue | null) => {
|
|
11
|
+
const s = String(v ?? '').trim();
|
|
12
|
+
return /^\d{2}:\d{2}$/.test(s) ? s : '';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// hours_<day>_on, hours_<day>_s1/e1, hours_<day>_s2/e2 (second range optional)
|
|
16
|
+
export function hoursFromForm(form: FormData, prefix = 'hours'): WeeklyHours {
|
|
17
|
+
const out = {} as WeeklyHours;
|
|
18
|
+
for (const d of WEEKDAYS) {
|
|
19
|
+
const ranges: TimeRange[] = [];
|
|
20
|
+
if (form.get(`${prefix}_${d}_on`)) {
|
|
21
|
+
for (const n of [1, 2]) {
|
|
22
|
+
const start = hhmm(form.get(`${prefix}_${d}_s${n}`));
|
|
23
|
+
const end = hhmm(form.get(`${prefix}_${d}_e${n}`));
|
|
24
|
+
if (start && end && (end > start || end === '00:00')) ranges.push({ start, end });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
out[d] = ranges;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function overridesFromForm(form: FormData): DateOverride[] {
|
|
33
|
+
const out: DateOverride[] = [];
|
|
34
|
+
for (let i = 0; i < 60; i++) {
|
|
35
|
+
const date = String(form.get(`ov_${i}_date`) ?? '');
|
|
36
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
37
|
+
const endDate = String(form.get(`ov_${i}_end`) ?? '');
|
|
38
|
+
const closed = Boolean(form.get(`ov_${i}_closed`));
|
|
39
|
+
const s = hhmm(form.get(`ov_${i}_s`));
|
|
40
|
+
const e = hhmm(form.get(`ov_${i}_e`));
|
|
41
|
+
out.push({
|
|
42
|
+
date,
|
|
43
|
+
endDate: /^\d{4}-\d{2}-\d{2}$/.test(endDate) && endDate > date ? endDate : '',
|
|
44
|
+
ranges: closed || !s || !e ? [] : [{ start: s, end: e }],
|
|
45
|
+
label: String(form.get(`ov_${i}_label`) ?? '').slice(0, 80),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return out.sort((a, b) => a.date.localeCompare(b.date));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function questionsFromForm(form: FormData): BookingQuestion[] {
|
|
52
|
+
const out: BookingQuestion[] = [];
|
|
53
|
+
for (let i = 0; i < 30; i++) {
|
|
54
|
+
const label = String(form.get(`qq_${i}_label`) ?? '').trim().slice(0, 200);
|
|
55
|
+
if (!label) continue;
|
|
56
|
+
const type = (QUESTION_TYPES as readonly string[]).includes(String(form.get(`qq_${i}_type`))) ? (String(form.get(`qq_${i}_type`)) as BookingQuestion['type']) : 'text';
|
|
57
|
+
out.push({
|
|
58
|
+
id: String(form.get(`qq_${i}_id`) || '') || crypto.randomUUID().slice(0, 8),
|
|
59
|
+
label, type,
|
|
60
|
+
required: Boolean(form.get(`qq_${i}_required`)),
|
|
61
|
+
options: type === 'select' ? list(String(form.get(`qq_${i}_options`) ?? '')) : [],
|
|
62
|
+
help: String(form.get(`qq_${i}_help`) ?? '').slice(0, 300),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const list = (s: string) => [...new Set(s.split(/[,\n]/).map((x) => x.trim()).filter(Boolean))];
|
|
69
|
+
export const int = (v: FormDataEntryValue | null, min: number, max: number, fallback: number) => {
|
|
70
|
+
if (v === null || String(v).trim() === '') return fallback;
|
|
71
|
+
const n = Math.round(Number(v));
|
|
72
|
+
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
73
|
+
};
|
|
74
|
+
export const DEFAULT_HOURS: WeeklyHours = {
|
|
75
|
+
su: [], mo: [{ start: '09:00', end: '17:00' }], tu: [{ start: '09:00', end: '17:00' }], we: [{ start: '09:00', end: '17:00' }],
|
|
76
|
+
th: [{ start: '09:00', end: '17:00' }], fr: [{ start: '09:00', end: '17:00' }], sa: [],
|
|
77
|
+
};
|