@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,400 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import type { Booking, BookingStatus } from '../../../lib/schema';
|
|
6
|
+
import { bookingsEnabled, eventUrl } from '../../../lib/calendar/config';
|
|
7
|
+
import { createBlock, createResourceBooking, rescheduleBooking, setBookingStatus, BookingError } from '../../../lib/booking/service';
|
|
8
|
+
import { addDaysYmd, localMidnight, ymd } from '../../../lib/booking/availability';
|
|
9
|
+
import { fmt } from '../../../lib/booking/view';
|
|
10
|
+
import { emailProvider } from '../../../lib/email';
|
|
11
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
12
|
+
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
13
|
+
|
|
14
|
+
// Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
|
|
15
|
+
// requests first, a Day view with one column per resource, a details panel
|
|
16
|
+
// (?b=<id>) with private notes, plus Block time and Add booking.
|
|
17
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
18
|
+
const repo = await getRepo(Astro.locals);
|
|
19
|
+
const settings = await repo.getSettings();
|
|
20
|
+
const tz = settings.timezone || 'UTC';
|
|
21
|
+
const f = fmt(settings, tz);
|
|
22
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
23
|
+
const flow = { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) };
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const [resources, links, events] = await Promise.all([repo.listResources(), repo.listBookingLinks(), repo.listEvents()]);
|
|
26
|
+
const resById = new Map(resources.map((r) => [r.id, r]));
|
|
27
|
+
const linkById = new Map(links.map((l) => [l.id, l]));
|
|
28
|
+
const evById = new Map(events.map((e) => [e.id, e]));
|
|
29
|
+
const toMs = (date: string, time: string) => {
|
|
30
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(time)) return NaN;
|
|
31
|
+
const [h, m] = time.split(':').map(Number);
|
|
32
|
+
return localMidnight(date, tz) + (h * 60 + m) * 60_000;
|
|
33
|
+
};
|
|
34
|
+
const keep = (patch: Record<string, string | null>) => {
|
|
35
|
+
const p = new URLSearchParams(Astro.url.search);
|
|
36
|
+
for (const [k, v] of Object.entries(patch)) v === null || v === '' ? p.delete(k) : p.set(k, v);
|
|
37
|
+
const s = p.toString();
|
|
38
|
+
return `/admin/calendar/bookings${s ? `?${s}` : ''}`;
|
|
39
|
+
};
|
|
40
|
+
let notice = Astro.url.searchParams.get('notice') ?? '';
|
|
41
|
+
let error = '';
|
|
42
|
+
|
|
43
|
+
if (Astro.request.method === 'POST') {
|
|
44
|
+
const form = await Astro.request.formData();
|
|
45
|
+
const op = String(form.get('op'));
|
|
46
|
+
const id = String(form.get('id') || '');
|
|
47
|
+
try {
|
|
48
|
+
if (op === 'status') {
|
|
49
|
+
const b = await repo.getBooking(id);
|
|
50
|
+
if (!b) throw new BookingError(404, 'Booking not found.');
|
|
51
|
+
await setBookingStatus(repo, b, String(form.get('to')) as BookingStatus, flow, String(form.get('message') || '') || undefined);
|
|
52
|
+
return Astro.redirect(keep({ notice: form.get('to') === 'confirmed' ? 'Confirmed — it’s on the calendar and the guest was emailed.' : 'Updated.' }));
|
|
53
|
+
}
|
|
54
|
+
if (op === 'notes') {
|
|
55
|
+
await repo.updateBooking(id, { notes: String(form.get('notes') || '').slice(0, 4000) });
|
|
56
|
+
return Astro.redirect(keep({ notice: 'Notes saved.' }));
|
|
57
|
+
}
|
|
58
|
+
if (op === 'delete') {
|
|
59
|
+
await repo.deleteBooking(id);
|
|
60
|
+
return Astro.redirect(keep({ b: null, notice: 'Removed.' }));
|
|
61
|
+
}
|
|
62
|
+
if (op === 'reschedule') {
|
|
63
|
+
const b = await repo.getBooking(id);
|
|
64
|
+
if (!b) throw new BookingError(404, 'Booking not found.');
|
|
65
|
+
const start = toMs(String(form.get('date')), String(form.get('time')));
|
|
66
|
+
if (Number.isNaN(start)) throw new BookingError(400, 'Pick a date and time.');
|
|
67
|
+
await rescheduleBooking(repo, b, start, start + (b.end - b.start), { ...flow, skipRules: true });
|
|
68
|
+
return Astro.redirect(keep({ notice: 'Moved, and the guest was emailed.' }));
|
|
69
|
+
}
|
|
70
|
+
if (op === 'block') {
|
|
71
|
+
const r = resById.get(String(form.get('resourceId')));
|
|
72
|
+
const date = String(form.get('date'));
|
|
73
|
+
const allDay = Boolean(form.get('allDay'));
|
|
74
|
+
const start = allDay ? localMidnight(date, tz) : toMs(date, String(form.get('start')));
|
|
75
|
+
const end = allDay ? localMidnight(addDaysYmd(date, 1), tz) : toMs(date, String(form.get('end')));
|
|
76
|
+
if (!r || Number.isNaN(start) || Number.isNaN(end)) throw new BookingError(400, 'Choose a resource, date and times.');
|
|
77
|
+
const b = await createBlock(repo, r, start, end, String(form.get('note') || ''));
|
|
78
|
+
if (!b) throw new BookingError(409, 'That time already has a booking — cancel or move it first.');
|
|
79
|
+
return Astro.redirect(keep({ notice: `${r.name} is blocked.` }));
|
|
80
|
+
}
|
|
81
|
+
if (op === 'add') {
|
|
82
|
+
const link = linkById.get(String(form.get('linkId')));
|
|
83
|
+
const r = link ? resById.get(link.resourceId) : undefined;
|
|
84
|
+
if (!link || !r) throw new BookingError(400, 'Choose a booking link.');
|
|
85
|
+
const start = toMs(String(form.get('date')), String(form.get('time')));
|
|
86
|
+
if (Number.isNaN(start)) throw new BookingError(400, 'Pick a date and time.');
|
|
87
|
+
const len = link.mode === 'range' && link.lengths.length ? Number(form.get('len')) || link.lengths[0] : link.mode === 'day' ? 1440 : link.duration;
|
|
88
|
+
const b = await createResourceBooking(repo, {
|
|
89
|
+
resource: r, link, start, end: start + len * 60_000, source: 'admin', status: 'confirmed',
|
|
90
|
+
booker: { name: String(form.get('name') || ''), email: String(form.get('email') || ''), phone: String(form.get('phone') || '') },
|
|
91
|
+
}, flow);
|
|
92
|
+
return Astro.redirect(keep({ b: b.id, notice: 'Booking added and confirmed.' }));
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
error = err instanceof Error ? err.message : 'Something went wrong.';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- data ----
|
|
100
|
+
const tab = (['upcoming', 'unconfirmed', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
|
|
101
|
+
const view = Astro.url.searchParams.get('view') === 'day' ? 'day' : 'agenda';
|
|
102
|
+
const resFilter = Astro.url.searchParams.get('r') ?? '';
|
|
103
|
+
const q = (Astro.url.searchParams.get('q') ?? '').trim().toLowerCase();
|
|
104
|
+
const all = await repo.listBookings({ from: now - 400 * 86_400_000 });
|
|
105
|
+
const titleOf = (b: Booking) =>
|
|
106
|
+
b.kind === 'block' ? `Blocked${b.notes ? ` · ${b.notes}` : ''}`
|
|
107
|
+
: b.kind === 'rsvp' ? b.name
|
|
108
|
+
: b.name || 'Booking';
|
|
109
|
+
const contextOf = (b: Booking) =>
|
|
110
|
+
b.kind === 'rsvp' ? `RSVP · ${evById.get(b.eventId)?.title ?? 'event'}${b.seats > 1 ? ` · ${b.seats} seats` : ''}`
|
|
111
|
+
: [linkById.get(b.linkId)?.name, ...Object.values(b.answers).slice(0, 2)].filter(Boolean).join(' · ');
|
|
112
|
+
const where = (b: Booking) => (b.kind === 'rsvp' ? evById.get(b.eventId)?.location.name || 'Event' : resById.get(b.resourceId)?.name ?? '—');
|
|
113
|
+
const matches = (b: Booking) =>
|
|
114
|
+
(!resFilter || b.resourceId === resFilter) &&
|
|
115
|
+
(!q || `${b.name} ${b.email} ${b.phone} ${where(b)} ${contextOf(b)}`.toLowerCase().includes(q));
|
|
116
|
+
const active = (b: Booking) => b.status === 'pending' || b.status === 'confirmed';
|
|
117
|
+
const counts = {
|
|
118
|
+
upcoming: all.filter((b) => active(b) && b.end > now && matches(b)).length,
|
|
119
|
+
unconfirmed: all.filter((b) => b.status === 'pending' && b.end > now && matches(b)).length,
|
|
120
|
+
past: all.filter((b) => active(b) && b.end <= now && matches(b)).length,
|
|
121
|
+
cancelled: all.filter((b) => !active(b) && matches(b)).length,
|
|
122
|
+
};
|
|
123
|
+
const list = all
|
|
124
|
+
.filter(matches)
|
|
125
|
+
.filter((b) => tab === 'upcoming' ? active(b) && b.end > now : tab === 'unconfirmed' ? b.status === 'pending' && b.end > now : tab === 'past' ? active(b) && b.end <= now : !active(b))
|
|
126
|
+
.sort((a, b) => (tab === 'past' || tab === 'cancelled' ? b.start - a.start : a.start - b.start))
|
|
127
|
+
.slice(0, 300);
|
|
128
|
+
if (Astro.url.searchParams.get('format') === 'csv') {
|
|
129
|
+
const cell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
|
|
130
|
+
const iso = (ms: number) => new Date(ms).toISOString();
|
|
131
|
+
const keys = [...new Set(list.flatMap((b) => Object.keys(b.answers)))];
|
|
132
|
+
const rows = [
|
|
133
|
+
['Start', 'End', 'Status', 'Kind', 'Resource / event', 'Link', 'Name', 'Email', 'Phone', 'Seats', ...keys, 'Notes', 'Booked'],
|
|
134
|
+
...list.map((b) => [iso(b.start), iso(b.end), b.status, b.kind, where(b), linkById.get(b.linkId)?.name ?? '', b.name, b.email, b.phone, String(b.seats), ...keys.map((k) => b.answers[k] ?? ''), b.notes, iso(b.createdAt)]),
|
|
135
|
+
];
|
|
136
|
+
return new Response(rows.map((r) => r.map(cell).join(',')).join('\n'), {
|
|
137
|
+
headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="bookings-${tab}.csv"` },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const pending = tab === 'upcoming' ? list.filter((b) => b.status === 'pending') : [];
|
|
141
|
+
const agendaItems = tab === 'upcoming' ? list.filter((b) => b.status !== 'pending') : list;
|
|
142
|
+
const days = new Map<string, Booking[]>();
|
|
143
|
+
for (const b of agendaItems) {
|
|
144
|
+
const k = ymd(b.start, tz);
|
|
145
|
+
days.set(k, [...(days.get(k) ?? []), b]);
|
|
146
|
+
}
|
|
147
|
+
const week = all.filter((b) => active(b) && b.start >= now && b.start < now + 7 * 86_400_000).length;
|
|
148
|
+
const cancelled30 = all.filter((b) => b.status === 'cancelled' && b.updatedAt > now - 30 * 86_400_000).length;
|
|
149
|
+
|
|
150
|
+
// day view
|
|
151
|
+
const day = /^\d{4}-\d{2}-\d{2}$/.test(Astro.url.searchParams.get('d') ?? '') ? Astro.url.searchParams.get('d')! : ymd(now, tz);
|
|
152
|
+
const dayStart = localMidnight(day, tz);
|
|
153
|
+
const dayEnd = localMidnight(addDaysYmd(day, 1), tz);
|
|
154
|
+
const dayCols = resources.filter((r) => !resFilter || r.id === resFilter).map((r) => ({
|
|
155
|
+
r, items: all.filter((b) => b.resourceId === r.id && active(b) && b.start < dayEnd && b.end > dayStart),
|
|
156
|
+
}));
|
|
157
|
+
const H0 = 7, H1 = 22;
|
|
158
|
+
const pos = (b: Booking) => {
|
|
159
|
+
const s = Math.max(b.start, dayStart), e = Math.min(b.end, dayEnd);
|
|
160
|
+
const top = ((s - dayStart) / 3_600_000 - H0) / (H1 - H0) * 100;
|
|
161
|
+
const h = ((e - s) / 3_600_000) / (H1 - H0) * 100;
|
|
162
|
+
return `top:${Math.max(0, top)}%;height:${Math.max(3, Math.min(100 - Math.max(0, top), h))}%`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const selected = Astro.url.searchParams.get('b') ? await repo.getBooking(Astro.url.searchParams.get('b')!) : null;
|
|
166
|
+
const provider = emailProvider(Astro.locals);
|
|
167
|
+
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Unconfirmed'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
168
|
+
const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
|
|
169
|
+
const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
|
|
170
|
+
? `${f.day.format(b.start)} – ${f.day.format(b.end - 1)}`
|
|
171
|
+
: b.end - b.start >= 86_400_000 - 3_600_000 ? 'All day' : `${f.time.format(b.start)} – ${f.time.format(b.end)}`);
|
|
172
|
+
const today = ymd(now, tz);
|
|
173
|
+
const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
<Admin title="Bookings" section="calendar:bookings" wide={view === 'day'}>
|
|
177
|
+
<div class="page-head">
|
|
178
|
+
<div>
|
|
179
|
+
<h1>Bookings</h1>
|
|
180
|
+
<p class="sub">{counts.unconfirmed ? `${counts.unconfirmed} waiting for approval · ` : ''}{week} in the next 7 days</p>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="page-actions">
|
|
183
|
+
<nav class="seg" aria-label="View">
|
|
184
|
+
<a class={view === 'agenda' ? 'on' : ''} href={keep({ view: null })}>Agenda</a>
|
|
185
|
+
<a class={view === 'day' ? 'on' : ''} href={keep({ view: 'day' })}>Day</a>
|
|
186
|
+
</nav>
|
|
187
|
+
<a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
|
|
188
|
+
<button class="btn" type="button" data-modal="bk-block" disabled={!resources.length}>Block time</button>
|
|
189
|
+
<button class="btn primary" type="button" data-modal="bk-add" disabled={!activeLinks.length}>Add booking</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
193
|
+
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
194
|
+
{provider === 'none' && <p class="notice"><span>Emails are off — set <code>EMAIL_FROM</code> plus <code>RESEND_API_KEY</code> (or a Cloudflare <code>send_email</code> binding named EMAIL) so guests get confirmations and reminders.</span></p>}
|
|
195
|
+
|
|
196
|
+
{resources.length === 0 && !all.length ? (
|
|
197
|
+
<div class="empty-state">
|
|
198
|
+
<b>No bookings yet</b>
|
|
199
|
+
<p>Add a room or a person under Resources, share its booking link, and bookings land here.</p>
|
|
200
|
+
<a class="btn primary" href="/admin/calendar/resources?new=1">New resource</a>
|
|
201
|
+
</div>
|
|
202
|
+
) : (
|
|
203
|
+
<>
|
|
204
|
+
<div class="bk-kpis kpis">
|
|
205
|
+
<div class="kpi"><small>Waiting for approval</small><b>{counts.unconfirmed}</b></div>
|
|
206
|
+
<div class="kpi"><small>Next 7 days</small><b>{week}</b></div>
|
|
207
|
+
<div class="kpi"><small>Today</small><b>{all.filter((b) => active(b) && ymd(b.start, tz) === today).length}</b></div>
|
|
208
|
+
<div class="kpi"><small>Cancelled · 30 days</small><b>{cancelled30}</b></div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div class="list-tools">
|
|
212
|
+
<div class="list-lead">
|
|
213
|
+
{view === 'agenda' ? (
|
|
214
|
+
<nav class="seg" aria-label="Show">
|
|
215
|
+
{TABS.map(([k, l]) => <a class={tab === k ? 'on' : ''} href={keep({ tab: k === 'upcoming' ? null : k, b: null })}>{l}{counts[k] > 0 && k === 'unconfirmed' ? ` · ${counts[k]}` : ''}</a>)}
|
|
216
|
+
</nav>
|
|
217
|
+
) : (
|
|
218
|
+
<nav class="seg" aria-label="Day">
|
|
219
|
+
<a href={keep({ d: addDaysYmd(day, -1) })} aria-label="Previous day">‹</a>
|
|
220
|
+
<a class="on" href={keep({ d: today })}>{day === today ? 'Today' : f.day.format(dayStart + 12 * 3_600_000)}</a>
|
|
221
|
+
<a href={keep({ d: addDaysYmd(day, 1) })} aria-label="Next day">›</a>
|
|
222
|
+
</nav>
|
|
223
|
+
)}
|
|
224
|
+
<form method="get" class="list-search" role="search">
|
|
225
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg>
|
|
226
|
+
{[...Astro.url.searchParams.entries()].filter(([k]) => !['q', 'notice', 'b'].includes(k)).map(([k, v]) => <input type="hidden" name={k} value={v} />)}
|
|
227
|
+
<input type="search" name="q" value={q} placeholder="Name, email, room…" aria-label="Search bookings" />
|
|
228
|
+
</form>
|
|
229
|
+
</div>
|
|
230
|
+
{resources.length > 1 && (
|
|
231
|
+
<details class={`fmenu ${resFilter ? 'is-set' : ''}`}>
|
|
232
|
+
<summary>{resFilter ? resById.get(resFilter)?.name : 'All resources'} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 9l6 6 6-6" /></svg></summary>
|
|
233
|
+
<div class="fmenu-pop">
|
|
234
|
+
<a class={!resFilter ? 'on' : ''} href={keep({ r: null })}>All resources</a>
|
|
235
|
+
{resources.map((r) => <a class={resFilter === r.id ? 'on' : ''} href={keep({ r: r.id })}>{r.name}</a>)}
|
|
236
|
+
</div>
|
|
237
|
+
</details>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div class={`bk-layout${selected ? ' has-panel' : ''}`}>
|
|
242
|
+
<div class="bk-main">
|
|
243
|
+
{view === 'day' ? (
|
|
244
|
+
<div class="bk-day" style={`--cols:${Math.max(1, dayCols.length)}`}>
|
|
245
|
+
<div class="bk-day-head"><span />{dayCols.map((c) => <b>{c.r.name}</b>)}</div>
|
|
246
|
+
<div class="bk-day-body">
|
|
247
|
+
<div class="bk-day-hours">{Array.from({ length: H1 - H0 }, (_, i) => <span>{f.time.format(dayStart + (H0 + i) * 3_600_000).replace(':00', '')}</span>)}</div>
|
|
248
|
+
{dayCols.map((c) => (
|
|
249
|
+
<div class="bk-day-col">
|
|
250
|
+
{c.items.map((b) => (
|
|
251
|
+
<a class={`bk-blk is-${b.kind === 'block' ? 'block' : b.status}`} style={pos(b)} href={keep({ b: b.id })}>
|
|
252
|
+
<b>{titleOf(b)}</b><small>{whenOf(b)}</small>
|
|
253
|
+
</a>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
) : (
|
|
260
|
+
<>
|
|
261
|
+
{pending.length > 0 && (
|
|
262
|
+
<section class="card bk-group">
|
|
263
|
+
<div class="bk-dayh"><b>Needs a decision</b><span>{pending.length}</span></div>
|
|
264
|
+
{pending.map((b) => (
|
|
265
|
+
<div class={`bk-row${selected?.id === b.id ? ' is-sel' : ''}`}>
|
|
266
|
+
<a class="bk-when" href={keep({ b: b.id })}>{f.day.format(b.start)}<small>{whenOf(b)}</small></a>
|
|
267
|
+
<a class="bk-who" href={keep({ b: b.id })}><b>{titleOf(b)}</b><small>{contextOf(b)}</small></a>
|
|
268
|
+
<span class="bk-res">{where(b)}</span>
|
|
269
|
+
<form method="post" class="bk-acts">
|
|
270
|
+
<input type="hidden" name="op" value="status" /><input type="hidden" name="id" value={b.id} />
|
|
271
|
+
<button class="btn sm" name="to" value="declined">Decline</button>
|
|
272
|
+
<button class="btn sm primary" name="to" value="confirmed">Confirm</button>
|
|
273
|
+
</form>
|
|
274
|
+
</div>
|
|
275
|
+
))}
|
|
276
|
+
</section>
|
|
277
|
+
)}
|
|
278
|
+
{[...days.entries()].map(([k, items]) => (
|
|
279
|
+
<section class="card bk-group">
|
|
280
|
+
<div class="bk-dayh"><b>{k === today ? 'Today · ' : ''}{f.dayLong.format(items[0].start)}</b><span>{items.length}</span></div>
|
|
281
|
+
{items.map((b) => (
|
|
282
|
+
<a class={`bk-row${selected?.id === b.id ? ' is-sel' : ''}`} href={keep({ b: b.id })}>
|
|
283
|
+
<span class="bk-when">{whenOf(b)}</span>
|
|
284
|
+
<span class="bk-who"><b>{titleOf(b)}</b><small>{contextOf(b)}</small></span>
|
|
285
|
+
<span class="bk-res">{where(b)}</span>
|
|
286
|
+
<span class={`status bk-st is-${b.kind === 'block' ? 'block' : b.status}`}>{statusLabel(b)}</span>
|
|
287
|
+
</a>
|
|
288
|
+
))}
|
|
289
|
+
</section>
|
|
290
|
+
))}
|
|
291
|
+
{!list.length && <p class="hint" style="padding:24px 8px">{tab === 'unconfirmed' ? 'Nothing waiting — auto-confirm links never land here.' : 'No bookings here.'}</p>}
|
|
292
|
+
</>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
{selected && (() => {
|
|
297
|
+
const b = selected;
|
|
298
|
+
const r = resById.get(b.resourceId);
|
|
299
|
+
const l = linkById.get(b.linkId);
|
|
300
|
+
const ev = evById.get(b.eventId);
|
|
301
|
+
return (
|
|
302
|
+
<aside class="card bk-panel" aria-label="Booking details">
|
|
303
|
+
<div class="bk-panel-head"><span class={`status bk-st is-${b.kind === 'block' ? 'block' : b.status}`}>{statusLabel(b)}</span><a class="icon-btn" href={keep({ b: null })} aria-label="Close">✕</a></div>
|
|
304
|
+
<h2>{titleOf(b)}</h2>
|
|
305
|
+
<p class="bk-panel-when">{f.dayLong.format(b.start)} · {whenOf(b)}</p>
|
|
306
|
+
<dl class="bk-dl">
|
|
307
|
+
{b.kind !== 'block' && b.email && <div><dt>Email</dt><dd><a href={`mailto:${b.email}`}>{b.email}</a></dd></div>}
|
|
308
|
+
{b.phone && <div><dt>Phone</dt><dd><a href={`tel:${b.phone}`}>{b.phone}</a></dd></div>}
|
|
309
|
+
<div><dt>{ev ? 'Event' : 'Where'}</dt><dd>{ev ? <a href={eventUrl(ev.slug)} target="_blank" rel="noopener">{ev.title}</a> : r?.name ?? '—'}{l ? ` · ${l.name}` : ''}</dd></div>
|
|
310
|
+
{b.seats > 1 && <div><dt>Seats</dt><dd>{b.seats}</dd></div>}
|
|
311
|
+
{Object.entries(b.answers).map(([k, v]) => <div><dt>{k}</dt><dd>{v}</dd></div>)}
|
|
312
|
+
<div><dt>Booked</dt><dd>{f.day.format(b.createdAt)} · {b.source}</dd></div>
|
|
313
|
+
</dl>
|
|
314
|
+
<form method="post" class="bk-notes">
|
|
315
|
+
<input type="hidden" name="op" value="notes" /><input type="hidden" name="id" value={b.id} />
|
|
316
|
+
<label class="field"><span>Private notes</span><textarea name="notes" rows="3" placeholder="Only staff see these">{b.notes}</textarea></label>
|
|
317
|
+
<button class="btn sm" type="submit">Save notes</button>
|
|
318
|
+
</form>
|
|
319
|
+
{b.kind !== 'block' && (b.status === 'pending' || b.status === 'confirmed') && b.end > now && (
|
|
320
|
+
<>
|
|
321
|
+
{b.status === 'pending' && (
|
|
322
|
+
<form method="post" class="bk-panel-acts">
|
|
323
|
+
<input type="hidden" name="op" value="status" /><input type="hidden" name="id" value={b.id} />
|
|
324
|
+
<label class="field"><span>Message to the guest <em class="hint">optional</em></span><input type="text" name="message" placeholder="See you then! / Sorry, the hall is taken that day." /></label>
|
|
325
|
+
<div><button class="btn" name="to" value="declined">Decline</button> <button class="btn primary" name="to" value="confirmed">Confirm</button></div>
|
|
326
|
+
</form>
|
|
327
|
+
)}
|
|
328
|
+
{b.kind === 'booking' && (
|
|
329
|
+
<details class="bk-resched">
|
|
330
|
+
<summary>Reschedule</summary>
|
|
331
|
+
<form method="post">
|
|
332
|
+
<input type="hidden" name="op" value="reschedule" /><input type="hidden" name="id" value={b.id} />
|
|
333
|
+
<div class="field-row"><input type="date" name="date" value={ymd(b.start, tz)} required /><input type="time" name="time" value={new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz }).format(b.start)} required /></div>
|
|
334
|
+
<button class="btn sm" type="submit">Move and email the guest</button>
|
|
335
|
+
</form>
|
|
336
|
+
</details>
|
|
337
|
+
)}
|
|
338
|
+
<form method="post">
|
|
339
|
+
<input type="hidden" name="op" value="status" /><input type="hidden" name="id" value={b.id} /><input type="hidden" name="to" value="cancelled" />
|
|
340
|
+
<button class="btn sm danger ghost" type="submit" data-confirm="Cancel this booking and email the guest?">Cancel booking</button>
|
|
341
|
+
</form>
|
|
342
|
+
</>
|
|
343
|
+
)}
|
|
344
|
+
{(b.kind === 'block' || !(b.status === 'pending' || b.status === 'confirmed')) && (
|
|
345
|
+
<form method="post">
|
|
346
|
+
<input type="hidden" name="op" value="delete" /><input type="hidden" name="id" value={b.id} />
|
|
347
|
+
<button class="btn sm danger ghost" type="submit" data-confirm="Remove this entry for good?">{b.kind === 'block' ? 'Remove block' : 'Delete record'}</button>
|
|
348
|
+
</form>
|
|
349
|
+
)}
|
|
350
|
+
</aside>
|
|
351
|
+
);
|
|
352
|
+
})()}
|
|
353
|
+
</div>
|
|
354
|
+
</>
|
|
355
|
+
)}
|
|
356
|
+
|
|
357
|
+
<Modal id="bk-block" title="Block time">
|
|
358
|
+
<form method="post">
|
|
359
|
+
<input type="hidden" name="op" value="block" />
|
|
360
|
+
<label class="field"><span>Resource</span><select name="resourceId">{resources.map((r) => <option value={r.id} selected={r.id === resFilter}>{r.name}</option>)}</select></label>
|
|
361
|
+
<div class="field-row three">
|
|
362
|
+
<label class="field"><span>Date</span><input type="date" name="date" value={day} required /></label>
|
|
363
|
+
<label class="field"><span>From</span><input type="time" name="start" value="09:00" /></label>
|
|
364
|
+
<label class="field"><span>To</span><input type="time" name="end" value="12:00" /></label>
|
|
365
|
+
</div>
|
|
366
|
+
<label class="check-row"><input type="checkbox" name="allDay" /> Whole day</label>
|
|
367
|
+
<label class="field"><span>Note</span><input type="text" name="note" placeholder="Painting, staff retreat…" /></label>
|
|
368
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Block</button></div>
|
|
369
|
+
</form>
|
|
370
|
+
</Modal>
|
|
371
|
+
|
|
372
|
+
<Modal id="bk-add" title="Add booking">
|
|
373
|
+
<form method="post">
|
|
374
|
+
<input type="hidden" name="op" value="add" />
|
|
375
|
+
<label class="field"><span>Booking link</span><select name="linkId">{activeLinks.map((l) => <option value={l.id}>{resById.get(l.resourceId)?.name} · {l.name}</option>)}</select></label>
|
|
376
|
+
<div class="field-row three">
|
|
377
|
+
<label class="field"><span>Date</span><input type="date" name="date" value={day} required /></label>
|
|
378
|
+
<label class="field"><span>Time</span><input type="time" name="time" value="10:00" required /></label>
|
|
379
|
+
<label class="field"><span>Length (min) <em class="hint">range links</em></span><input type="number" name="len" min="15" step="15" placeholder="auto" /></label>
|
|
380
|
+
</div>
|
|
381
|
+
<div class="field-row">
|
|
382
|
+
<label class="field"><span>Name</span><input type="text" name="name" required /></label>
|
|
383
|
+
<label class="field"><span>Email</span><input type="email" name="email" required /></label>
|
|
384
|
+
</div>
|
|
385
|
+
<label class="field"><span>Phone <em class="hint">optional</em></span><input type="tel" name="phone" /></label>
|
|
386
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add and confirm</button></div>
|
|
387
|
+
<p class="hint modal-note">Staff bookings skip notice windows but can never overlap another booking.</p>
|
|
388
|
+
</form>
|
|
389
|
+
</Modal>
|
|
390
|
+
</Admin>
|
|
391
|
+
|
|
392
|
+
<script>
|
|
393
|
+
document.addEventListener('click', (e) => {
|
|
394
|
+
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
395
|
+
if (c && !confirm(c.dataset.confirm)) e.preventDefault();
|
|
396
|
+
document.querySelectorAll<HTMLDetailsElement>('details.fmenu[open]').forEach((d) => {
|
|
397
|
+
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
</script>
|