@acms-subir/core 0.2.0 → 0.3.0
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 +78 -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/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- 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 +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -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 +324 -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 +52 -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/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 +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- 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 +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -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 +221 -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/links.astro +3 -3
- 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 +64 -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/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/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 +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -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/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 +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -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 +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import PostCard from '../../components/site/PostCard.astro';
|
|
4
|
+
import Pager from '../../components/site/Pager.astro';
|
|
3
5
|
import { getRepo } from '../../lib/repo';
|
|
4
|
-
import { publicTags, slugify } from '../../lib/schema';
|
|
6
|
+
import { publicTags, slugify, authorSlug } from '../../lib/schema';
|
|
5
7
|
import { formatDate } from '../../lib/dates';
|
|
8
|
+
import { resolveDesign } from '../../lib/theme';
|
|
9
|
+
import { effectiveSettings } from '../../lib/designPreview';
|
|
6
10
|
import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
|
|
7
11
|
|
|
8
12
|
// One file serves every collection's index — injectRoute can't pass props,
|
|
@@ -10,42 +14,103 @@ import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
|
|
|
10
14
|
const col = collectionFromPath(Astro.url.pathname);
|
|
11
15
|
if (!col) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
12
16
|
|
|
13
|
-
const PAGE_SIZE =
|
|
17
|
+
const PAGE_SIZE = 12;
|
|
14
18
|
const repo = await getRepo(Astro.locals);
|
|
15
|
-
const [allPosts, tags, users,
|
|
19
|
+
const [allPosts, tags, users, rawSettings] = await Promise.all([repo.listPublishedPosts(col.key), repo.listTags(), repo.listUsers(), repo.getSettings()]);
|
|
20
|
+
const settings = await effectiveSettings(rawSettings, Astro.url, Astro.cookies, Astro.locals);
|
|
21
|
+
const design = resolveDesign(settings);
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
// page 1 leads with the featured post (if any) as a big split card; it's
|
|
24
|
+
// pulled out of the grid so it never shows twice
|
|
25
|
+
const featured = allPosts.find((p) => p.featured) ?? null;
|
|
26
|
+
const rest = featured ? allPosts.filter((p) => p.id !== featured.id) : allPosts;
|
|
27
|
+
const pageCount = Math.max(1, Math.ceil(rest.length / PAGE_SIZE));
|
|
18
28
|
const pageNum = Math.min(pageCount, Math.max(1, Number(Astro.url.searchParams.get('page')) || 1));
|
|
19
|
-
const posts =
|
|
29
|
+
const posts = rest.slice((pageNum - 1) * PAGE_SIZE, pageNum * PAGE_SIZE);
|
|
30
|
+
const lead = pageNum === 1 ? featured : null;
|
|
31
|
+
|
|
32
|
+
// 'auto' picks cards when at least half the posts have covers — a text-first
|
|
33
|
+
// blog gets honest list rows instead of a grid of generated tiles
|
|
34
|
+
const withCover = allPosts.filter((p) => p.cover).length;
|
|
35
|
+
const layout = design.tokens.blogLayout === 'auto'
|
|
36
|
+
? (allPosts.length && withCover / allPosts.length >= 0.5 ? 'cards' : 'list')
|
|
37
|
+
: design.tokens.blogLayout;
|
|
20
38
|
|
|
21
39
|
const tagSlug = (name: string) =>
|
|
22
40
|
tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
41
|
+
const tagLinks = (names: string[]) => publicTags(names).map((n) => ({ name: n, href: tagUrl(tagSlug(n)) }));
|
|
42
|
+
const readMins = (p: (typeof allPosts)[number]) =>
|
|
43
|
+
Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
|
|
44
|
+
const authorOf = (p: (typeof allPosts)[number]) => {
|
|
45
|
+
const id = p.authorIds[0] ?? p.authorId;
|
|
46
|
+
const u = id ? users.find((x) => x.id === id) : undefined;
|
|
47
|
+
return u ? { name: u.name, avatar: u.avatar || undefined, href: `/author/${authorSlug(u)}` } : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// filter tabs: the collection's most-used public tags, with counts
|
|
51
|
+
// (case-insensitive: "Product" and "product" are one tag, first spelling wins)
|
|
52
|
+
const counts = new Map<string, [string, number]>();
|
|
53
|
+
for (const p of allPosts) for (const t of publicTags(p.tags)) {
|
|
54
|
+
const k = t.toLowerCase();
|
|
55
|
+
counts.set(k, [counts.get(k)?.[0] ?? t, (counts.get(k)?.[1] ?? 0) + 1]);
|
|
56
|
+
}
|
|
57
|
+
const topTags = [...counts.values()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 6);
|
|
58
|
+
const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined);
|
|
26
59
|
---
|
|
27
60
|
|
|
28
61
|
<Site title={col.plural}>
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
<div class="wide blog-index">
|
|
63
|
+
<header class="page-head">
|
|
64
|
+
<h1>{col.plural}</h1>
|
|
65
|
+
{settings.tagline && <p class="page-lede">{settings.tagline}</p>}
|
|
66
|
+
</header>
|
|
67
|
+
{topTags.length > 1 && (
|
|
68
|
+
<nav class="filter-tabs" aria-label="Filter by tag">
|
|
69
|
+
<a href={col.base} aria-current="page">All <small>{allPosts.length}</small></a>
|
|
70
|
+
{topTags.map(([name, n]) => <a href={tagUrl(tagSlug(name))}>{name} <small>{n}</small></a>)}
|
|
71
|
+
</nav>
|
|
72
|
+
)}
|
|
73
|
+
{allPosts.length === 0 && (
|
|
74
|
+
<div class="empty-state">
|
|
75
|
+
<span class="empty-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 13 6.5 5h11L20 13v5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18v-5Z" /><path d="M4 13h4.5l1 2h5l1-2H20" /></svg></span>
|
|
76
|
+
<b>No {col.plural.toLowerCase()} yet</b>
|
|
77
|
+
<span>New {col.plural.toLowerCase()} will show up here.</span>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
{lead && (
|
|
81
|
+
<PostCard
|
|
82
|
+
variant="feature"
|
|
83
|
+
headingLevel={2}
|
|
84
|
+
href={collectionUrl(col.key, lead.slug)}
|
|
85
|
+
title={lead.title}
|
|
86
|
+
excerpt={lead.excerpt}
|
|
87
|
+
cover={lead.cover}
|
|
88
|
+
coverAlt={lead.coverAlt}
|
|
89
|
+
date={formatDate(lead.publishedAt, settings, 'short')}
|
|
90
|
+
datetime={iso(lead.publishedAt)}
|
|
91
|
+
readMins={readMins(lead)}
|
|
92
|
+
tags={tagLinks(lead.tags)}
|
|
93
|
+
featured
|
|
94
|
+
author={authorOf(lead)}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
<div class:list={['post-grid', `is-${layout}`]}>
|
|
98
|
+
{posts.map((p) => (
|
|
99
|
+
<PostCard
|
|
100
|
+
variant={layout === 'cards' ? 'card' : 'row'}
|
|
101
|
+
headingLevel={2}
|
|
102
|
+
href={collectionUrl(col.key, p.slug)}
|
|
103
|
+
title={p.title}
|
|
104
|
+
excerpt={p.excerpt}
|
|
105
|
+
cover={p.cover}
|
|
106
|
+
coverAlt={p.coverAlt}
|
|
107
|
+
date={formatDate(p.publishedAt, settings, 'short')}
|
|
108
|
+
datetime={iso(p.publishedAt)}
|
|
109
|
+
readMins={readMins(p)}
|
|
110
|
+
tags={tagLinks(p.tags)}
|
|
111
|
+
/>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
<Pager base={col.base} page={pageNum} count={pageCount} />
|
|
115
|
+
</div>
|
|
51
116
|
</Site>
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../../../layouts/Site.astro';
|
|
3
|
+
import PostCard from '../../../components/site/PostCard.astro';
|
|
4
|
+
import Pager from '../../../components/site/Pager.astro';
|
|
3
5
|
import { getRepo } from '../../../lib/repo';
|
|
4
|
-
import {
|
|
6
|
+
import { tagColor } from '../../../lib/tagColor';
|
|
7
|
+
import { isInternalTag, publicTags, slugify } from '../../../lib/schema';
|
|
5
8
|
import { formatDate } from '../../../lib/dates';
|
|
6
9
|
import { CONTENT, collectionUrl, postsIndexUrl, tagUrl } from '../../../lib/content';
|
|
7
10
|
|
|
8
|
-
const PAGE_SIZE =
|
|
11
|
+
const PAGE_SIZE = 12;
|
|
9
12
|
const { slug } = Astro.params;
|
|
10
13
|
const repo = await getRepo(Astro.locals);
|
|
11
|
-
const [tag, settings] = await Promise.all([slug ? repo.getTagBySlug(slug) : Promise.resolve(null), repo.getSettings()]);
|
|
14
|
+
const [tag, settings, tags] = await Promise.all([slug ? repo.getTagBySlug(slug) : Promise.resolve(null), repo.getSettings(), repo.listTags()]);
|
|
12
15
|
// internal (#-prefixed) tags never get a public archive
|
|
13
16
|
if (!tag || isInternalTag(tag.name)) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
14
17
|
|
|
@@ -40,44 +43,46 @@ const fmt = (ts: number | null) =>
|
|
|
40
43
|
formatDate(ts, settings, 'short');
|
|
41
44
|
const readMins = (p: (typeof posts)[number]) =>
|
|
42
45
|
Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
|
|
46
|
+
const tagSlug = (name: string) =>
|
|
47
|
+
tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
|
|
48
|
+
// the archive's own tag is implied, so cards show the post's other tags
|
|
49
|
+
const tagLinks = (names: string[]) =>
|
|
50
|
+
publicTags(names).filter((n) => n.toLowerCase() !== tag.name.toLowerCase()).map((n) => ({ name: n, href: tagUrl(tagSlug(n)) }));
|
|
51
|
+
const layout = allPosts.length && allPosts.filter((p) => p.cover).length / allPosts.length >= 0.5 ? 'cards' : 'list';
|
|
52
|
+
const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined);
|
|
43
53
|
---
|
|
44
54
|
|
|
45
55
|
<Site title={tag.name} description={tag.description || `Tagged ${tag.name}`}>
|
|
46
|
-
<
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
</
|
|
77
|
-
|
|
78
|
-
<p style="margin-top:28px"><a href={postsIndexUrl()}>← All {CONTENT.posts.plural.toLowerCase()}</a></p>
|
|
56
|
+
<div class="wide archive">
|
|
57
|
+
<header class="page-head">
|
|
58
|
+
<p class="page-kicker"><span class="tag" style={`--tc:${tagColor(tag.name)}`}>Tag</span> <span>{allPosts.length} {countNoun}</span></p>
|
|
59
|
+
<h1>{tag.name}</h1>
|
|
60
|
+
{tag.description && <p class="page-lede">{tag.description}</p>}
|
|
61
|
+
</header>
|
|
62
|
+
{posts.length === 0 && <p class="muted-note">Nothing with this tag yet.</p>}
|
|
63
|
+
{groups.map((g) => (
|
|
64
|
+
<>
|
|
65
|
+
{multi && <h2 class="archive-group">{g.plural}</h2>}
|
|
66
|
+
<div class:list={['post-grid', `is-${layout}`]}>
|
|
67
|
+
{g.posts.map((p) => (
|
|
68
|
+
<PostCard
|
|
69
|
+
variant={layout === 'cards' ? 'card' : 'row'}
|
|
70
|
+
headingLevel={multi ? 3 : 2}
|
|
71
|
+
href={collectionUrl(p.collection, p.slug)}
|
|
72
|
+
title={p.title}
|
|
73
|
+
excerpt={p.excerpt}
|
|
74
|
+
cover={p.cover}
|
|
75
|
+
coverAlt={p.coverAlt}
|
|
76
|
+
date={fmt(p.publishedAt)}
|
|
77
|
+
datetime={iso(p.publishedAt)}
|
|
78
|
+
readMins={readMins(p)}
|
|
79
|
+
tags={tagLinks(p.tags)}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
</>
|
|
84
|
+
))}
|
|
85
|
+
<Pager base={base} page={pageNum} count={pageCount} />
|
|
86
|
+
<p class="archive-back"><a href={postsIndexUrl()}>← All {CONTENT.posts.plural.toLowerCase()}</a></p>
|
|
87
|
+
</div>
|
|
79
88
|
</Site>
|
|
80
|
-
|
|
81
|
-
<style>
|
|
82
|
-
.tag-group { font-size: 1.05rem; margin: 1.6rem 0 0.2rem; color: var(--muted, inherit); }
|
|
83
|
-
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../../layouts/Site.astro';
|
|
3
|
+
import BookingFlow from '../../../components/booking/BookingFlow.astro';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import { bookingsEnabled, manageUrl, resourceUrl } from '../../../lib/calendar/config';
|
|
6
|
+
import { availabilityContext, createResourceBooking, rescheduleBooking, BookingError } from '../../../lib/booking/service';
|
|
7
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
8
|
+
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
9
|
+
|
|
10
|
+
// One booking link: pick → answer → done. POSTs create the booking (or move
|
|
11
|
+
// one when ?reschedule=<token>) and land on the manage page.
|
|
12
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
13
|
+
const repo = await getRepo(Astro.locals);
|
|
14
|
+
const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
|
|
15
|
+
const link = resource ? await repo.getBookingLinkBySlug(resource.id, Astro.params.link ?? '') : null;
|
|
16
|
+
if (!resource || !link || !link.active) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
17
|
+
const settings = await repo.getSettings();
|
|
18
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
19
|
+
const flow = { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) };
|
|
20
|
+
const rescheduleToken = Astro.url.searchParams.get('reschedule') ?? '';
|
|
21
|
+
const reschedule = rescheduleToken ? await repo.getBookingByToken(rescheduleToken) : null;
|
|
22
|
+
if (reschedule && reschedule.linkId !== link.id) return Astro.redirect(manageUrl(reschedule.manageToken));
|
|
23
|
+
|
|
24
|
+
let error = '';
|
|
25
|
+
let values: Record<string, string> = {};
|
|
26
|
+
// after a failed submit, re-open the form on the time that was chosen
|
|
27
|
+
const flowUrl = new URL(Astro.url);
|
|
28
|
+
if (Astro.request.method === 'POST') {
|
|
29
|
+
const form = await Astro.request.formData();
|
|
30
|
+
values = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)]));
|
|
31
|
+
const start = Number(form.get('start'));
|
|
32
|
+
const end = Number(form.get('end'));
|
|
33
|
+
try {
|
|
34
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) throw new BookingError(400, 'Pick a time first.');
|
|
35
|
+
const token = String(form.get('reschedule') || '');
|
|
36
|
+
if (token) {
|
|
37
|
+
const b = await repo.getBookingByToken(token);
|
|
38
|
+
if (!b || b.linkId !== link.id) throw new BookingError(404, 'That booking can’t be found.');
|
|
39
|
+
await rescheduleBooking(repo, b, start, end, flow);
|
|
40
|
+
return Astro.redirect(`${manageUrl(b.manageToken)}?moved=1`);
|
|
41
|
+
}
|
|
42
|
+
const answers: Record<string, string> = {};
|
|
43
|
+
for (const qn of link.questions) answers[qn.id] = String(form.get(`q_${qn.id}`) ?? '');
|
|
44
|
+
const b = await createResourceBooking(repo, {
|
|
45
|
+
resource, link, start, end,
|
|
46
|
+
booker: { name: String(form.get('name') ?? ''), email: String(form.get('email') ?? ''), phone: String(form.get('phone') ?? ''), answers },
|
|
47
|
+
}, flow);
|
|
48
|
+
if (link.redirectUrl && /^https?:\/\//.test(link.redirectUrl)) return Astro.redirect(link.redirectUrl);
|
|
49
|
+
return Astro.redirect(`${manageUrl(b.manageToken)}?new=1`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
error = err instanceof BookingError ? err.message : 'Something went wrong. Please try again.';
|
|
52
|
+
if (Number.isFinite(start)) {
|
|
53
|
+
const tz = resource.timezone || settings.timezone || 'UTC';
|
|
54
|
+
flowUrl.searchParams.set('start', String(start));
|
|
55
|
+
flowUrl.searchParams.set('date', new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(start));
|
|
56
|
+
if (link.mode !== 'slots') flowUrl.searchParams.set('len', String(Math.round((end - start) / 60_000)));
|
|
57
|
+
}
|
|
58
|
+
if (!(err instanceof BookingError)) console.error(err);
|
|
59
|
+
Astro.response.status = err instanceof BookingError ? err.status : 500;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const ctx = await availabilityContext(repo, resource, link, settings, Date.now(), reschedule?.id);
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
<Site title={`${link.name} · ${resource.name}`} description={link.description || `Book ${resource.name}.`} noindex={link.visibility === 'unlisted' || resource.visibility === 'unlisted'}>
|
|
66
|
+
<div class="pc-bkpage">
|
|
67
|
+
<BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={flowUrl} error={error} values={values} reschedule={reschedule} />
|
|
68
|
+
<p class="pc-bk-foot"><a href={resourceUrl(resource.slug)}>Other ways to book {resource.name}</a></p>
|
|
69
|
+
</div>
|
|
70
|
+
</Site>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../../lib/repo';
|
|
4
|
+
import { bookingLinkUrl, bookingsEnabled, CALENDAR } from '../../../lib/calendar/config';
|
|
5
|
+
import { availabilityContext } from '../../../lib/booking/service';
|
|
6
|
+
import { describeLink, nextOpenings } from '../../../lib/booking/availability';
|
|
7
|
+
import { fmt } from '../../../lib/booking/view';
|
|
8
|
+
|
|
9
|
+
// A room (or person, or van) and its booking links (mockup C): each link is a
|
|
10
|
+
// card with its rules, an Instant / Approval badge, and its next openings —
|
|
11
|
+
// tap one to jump into the flow with the time filled in.
|
|
12
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
13
|
+
const repo = await getRepo(Astro.locals);
|
|
14
|
+
const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
|
|
15
|
+
if (!resource) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
16
|
+
const settings = await repo.getSettings();
|
|
17
|
+
const links = (await repo.listBookingLinks(resource.id)).filter((l) => l.active && l.visibility === 'public');
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const tz = resource.timezone || settings.timezone || 'UTC';
|
|
20
|
+
const f = fmt(settings, tz);
|
|
21
|
+
const cards = await Promise.all(links.map(async (link) => {
|
|
22
|
+
const ctx = await availabilityContext(repo, resource, link, settings, now);
|
|
23
|
+
return { link, next: nextOpenings(ctx, 3) };
|
|
24
|
+
}));
|
|
25
|
+
const chip = (start: number, link: (typeof links)[number]) => {
|
|
26
|
+
const d = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(start);
|
|
27
|
+
const today = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
|
28
|
+
const tomorrow = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now + 86_400_000);
|
|
29
|
+
const day = d === today ? 'Today' : d === tomorrow ? 'Tomorrow' : f.day.format(start);
|
|
30
|
+
return link.mode === 'slots' ? `${day} ${f.time.format(start)}` : day;
|
|
31
|
+
};
|
|
32
|
+
const facts = [resource.capacity ? `Up to ${resource.capacity}` : '', ...resource.amenities].filter(Boolean);
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<Site title={resource.name} description={resource.description || `Book ${resource.name}.`} image={resource.photo || undefined} noindex={resource.visibility === 'unlisted'}>
|
|
36
|
+
<div class="pc-res">
|
|
37
|
+
<div class="pc-res-about">
|
|
38
|
+
{resource.photo ? <img class="pc-res-photo" src={resource.photo} alt="" /> : null}
|
|
39
|
+
<span class="pc-res-kind">{resource.kind}</span>
|
|
40
|
+
<h1 class="pc-res-title">{resource.name}</h1>
|
|
41
|
+
{facts.length > 0 && <ul class="pc-res-facts">{facts.map((x) => <li>{x}</li>)}</ul>}
|
|
42
|
+
{resource.description && <p class="pc-res-desc">{resource.description}</p>}
|
|
43
|
+
</div>
|
|
44
|
+
<div class="pc-res-links">
|
|
45
|
+
{cards.length === 0 && <p class="pc-bk-empty">Online booking isn’t open for {resource.name} right now.</p>}
|
|
46
|
+
{cards.map(({ link, next }) => (
|
|
47
|
+
<article class="pc-res-link">
|
|
48
|
+
<div class="pc-res-linkhead">
|
|
49
|
+
<h2><a href={bookingLinkUrl(resource.slug, link.slug)}>{link.name}</a></h2>
|
|
50
|
+
<span class={`pc-res-badge${link.autoConfirm ? ' is-instant' : ''}`}>{link.autoConfirm ? 'Instant' : 'Approval'}</span>
|
|
51
|
+
</div>
|
|
52
|
+
<p>{[describeLink(link), link.description].filter(Boolean).join(' · ')}</p>
|
|
53
|
+
<div class="pc-res-next">
|
|
54
|
+
{next.map((s) => <a href={`${bookingLinkUrl(resource.slug, link.slug)}?date=${new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(s.start)}&start=${s.start}&len=${s.minutes}`}>{chip(s.start, link)}</a>)}
|
|
55
|
+
{next.length === 0 && <span class="pc-res-none">No openings soon</span>}
|
|
56
|
+
<a class="pc-res-more" href={bookingLinkUrl(resource.slug, link.slug)}>{next.length ? 'More times ›' : 'See calendar ›'}</a>
|
|
57
|
+
</div>
|
|
58
|
+
</article>
|
|
59
|
+
))}
|
|
60
|
+
<p class="pc-bk-foot"><a href={CALENDAR.bookBase}>All rooms and people</a></p>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</Site>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { bookingsEnabled } from '../../lib/calendar/config';
|
|
5
|
+
import { siteOrigin } from '../../lib/seo';
|
|
6
|
+
import { escapeText, foldLine } from '../../lib/calendar/ics-write';
|
|
7
|
+
|
|
8
|
+
// Staff feed of a resource's bookings (names included), for Google/Apple/
|
|
9
|
+
// Outlook subscriptions: /book/<slug>.ics?t=<secret from the admin>.
|
|
10
|
+
const same = (a: string, b: string) => a.length === b.length && a.length > 0 && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
11
|
+
const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
12
|
+
|
|
13
|
+
export const GET: APIRoute = async ({ params, url, locals }) => {
|
|
14
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
15
|
+
const repo = await getRepo(locals);
|
|
16
|
+
const resource = await repo.getResourceBySlug(params.resource ?? '');
|
|
17
|
+
if (!resource || !same(url.searchParams.get('t') ?? '', resource.feedToken)) return new Response(null, { status: 404 });
|
|
18
|
+
const settings = await repo.getSettings();
|
|
19
|
+
const origin = siteOrigin(settings, url);
|
|
20
|
+
const host = new URL(origin).host;
|
|
21
|
+
const [bookings, links] = await Promise.all([
|
|
22
|
+
repo.listBookings({ resourceId: resource.id, from: Date.now() - 60 * 86_400_000, statuses: ['pending', 'confirmed'] }),
|
|
23
|
+
repo.listBookingLinks(resource.id),
|
|
24
|
+
]);
|
|
25
|
+
const linkName = new Map(links.map((l) => [l.id, l.name]));
|
|
26
|
+
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//acms-subir//Bookings//EN', 'METHOD:PUBLISH',
|
|
27
|
+
`X-WR-CALNAME:${escapeText(`${resource.name} bookings`)}`, 'REFRESH-INTERVAL;VALUE=DURATION:PT15M'];
|
|
28
|
+
for (const b of bookings) {
|
|
29
|
+
const title = b.kind === 'block' ? `Blocked${b.notes ? ` · ${b.notes}` : ''}` : `${b.status === 'pending' ? '[Pending] ' : ''}${b.name}${b.linkId ? ` · ${linkName.get(b.linkId) ?? ''}` : ''}`;
|
|
30
|
+
const desc = [b.email, b.phone, ...Object.entries(b.answers).map(([k, v]) => `${k}: ${v}`), `${origin}/admin/calendar/bookings?b=${b.id}`].filter(Boolean).join('\n');
|
|
31
|
+
lines.push('BEGIN:VEVENT', `UID:booking-${b.id}@${host}`, `DTSTAMP:${utc(Date.now())}`, `DTSTART:${utc(b.start)}`, `DTEND:${utc(b.end)}`,
|
|
32
|
+
`SUMMARY:${escapeText(title)}`, `DESCRIPTION:${escapeText(desc)}`, `STATUS:${b.status === 'pending' ? 'TENTATIVE' : 'CONFIRMED'}`, 'END:VEVENT');
|
|
33
|
+
}
|
|
34
|
+
lines.push('END:VCALENDAR');
|
|
35
|
+
return new Response(lines.map(foldLine).join('\r\n') + '\r\n', {
|
|
36
|
+
headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'Cache-Control': 'private, no-store' },
|
|
37
|
+
});
|
|
38
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl } from '../../lib/calendar/config';
|
|
5
|
+
|
|
6
|
+
// Directory of everything bookable: public resources grouped by kind, each
|
|
7
|
+
// with its public booking links.
|
|
8
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
9
|
+
const repo = await getRepo(Astro.locals);
|
|
10
|
+
const [settings, resources, links] = await Promise.all([repo.getSettings(), repo.listResources(), repo.listBookingLinks()]);
|
|
11
|
+
const listed = resources.filter((r) => r.visibility === 'public');
|
|
12
|
+
const byKind = new Map<string, typeof listed>();
|
|
13
|
+
for (const r of listed) byKind.set(r.kind, [...(byKind.get(r.kind) ?? []), r]);
|
|
14
|
+
const linksOf = (id: string) => links.filter((l) => l.resourceId === id && l.active && l.visibility === 'public');
|
|
15
|
+
const plural = (k: string) => (k.endsWith('s') ? k : k.endsWith('y') ? `${k.slice(0, -1)}ies` : `${k}s`);
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<Site title="Book" description={`Book rooms and time at ${settings.siteTitle}.`}>
|
|
19
|
+
<div class="pc-dir">
|
|
20
|
+
<header class="pc-calpage-head"><h1>Book</h1><p>Reserve a room or time with {settings.siteTitle}.</p></header>
|
|
21
|
+
{listed.length === 0 && (
|
|
22
|
+
<div class="empty-state">
|
|
23
|
+
<span class="empty-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3.5" y="5" width="17" height="15" rx="3" /><path d="M3.5 10h17M8 3v4M16 3v4" /></svg></span>
|
|
24
|
+
<b>Nothing to book yet</b>
|
|
25
|
+
<span>When rooms or appointments open up, they'll show here.</span>
|
|
26
|
+
{CALENDAR.enabled && <a class="btn secondary sm" href={CALENDAR.base}>See the calendar</a>}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
{[...byKind.entries()].map(([kind, list]) => (
|
|
30
|
+
<section class="pc-dir-group">
|
|
31
|
+
{byKind.size > 1 && <h2>{plural(kind)}</h2>}
|
|
32
|
+
<div class="pc-dir-grid">
|
|
33
|
+
{list.map((r) => (
|
|
34
|
+
<article class="pc-dir-card">
|
|
35
|
+
<a class="pc-dir-media" href={resourceUrl(r.slug)} tabindex="-1" aria-hidden="true">{r.photo ? <img src={r.photo} alt="" loading="lazy" /> : <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V9l9-5 9 5v12M9 21v-6h6v6" /></svg>}</a>
|
|
36
|
+
<div class="pc-dir-body">
|
|
37
|
+
<h3><a href={resourceUrl(r.slug)}>{r.name}</a></h3>
|
|
38
|
+
<p>{[r.capacity ? `Up to ${r.capacity}` : '', ...r.amenities.slice(0, 3)].filter(Boolean).join(' · ')}</p>
|
|
39
|
+
<div class="pc-dir-links">{linksOf(r.id).map((l) => <a href={bookingLinkUrl(r.slug, l.slug)}>{l.name}</a>)}</div>
|
|
40
|
+
</div>
|
|
41
|
+
</article>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
</Site>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../../lib/repo';
|
|
4
|
+
import { bookingLinkUrl, bookingsEnabled, eventUrl, resourceUrl } from '../../../lib/calendar/config';
|
|
5
|
+
import { setBookingStatus, BookingError } from '../../../lib/booking/service';
|
|
6
|
+
import { bookingIcs, whenText } from '../../../lib/booking/emails';
|
|
7
|
+
import { resourceZone } from '../../../lib/booking/availability';
|
|
8
|
+
import { eventTimeZone, locationLine } from '../../../lib/calendar/events';
|
|
9
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
10
|
+
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
11
|
+
|
|
12
|
+
// The booker's own page, reached from the confirmation and every email: see
|
|
13
|
+
// the status, add it to a calendar, move it or cancel it. The token in the
|
|
14
|
+
// URL is the only credential — it's long and random, and never listed.
|
|
15
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const booking = await repo.getBookingByToken(Astro.params.token ?? '');
|
|
18
|
+
if (!booking) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
|
+
const settings = await repo.getSettings();
|
|
20
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
21
|
+
const [resource, link, event] = await Promise.all([
|
|
22
|
+
booking.resourceId ? repo.getResource(booking.resourceId) : Promise.resolve(null),
|
|
23
|
+
booking.linkId ? repo.getBookingLink(booking.linkId) : Promise.resolve(null),
|
|
24
|
+
booking.eventId ? repo.getEvent(booking.eventId) : Promise.resolve(null),
|
|
25
|
+
]);
|
|
26
|
+
const title = event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking';
|
|
27
|
+
const location = event ? locationLine(event.location) : resource?.name ?? '';
|
|
28
|
+
const url = origin + Astro.url.pathname;
|
|
29
|
+
|
|
30
|
+
if (Astro.url.searchParams.get('ics')) {
|
|
31
|
+
return new Response(bookingIcs(booking, title, location, url, new URL(origin).host), {
|
|
32
|
+
headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'inline; filename="booking.ics"', 'Cache-Control': 'private, no-store' },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
let error = '';
|
|
36
|
+
let current = booking;
|
|
37
|
+
if (Astro.request.method === 'POST') {
|
|
38
|
+
const form = await Astro.request.formData();
|
|
39
|
+
if (form.get('op') === 'cancel' && (booking.status === 'pending' || booking.status === 'confirmed')) {
|
|
40
|
+
try {
|
|
41
|
+
current = await setBookingStatus(repo, booking, 'cancelled', { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
error = err instanceof BookingError ? err.message : 'Couldn’t cancel — please try again.';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const tz = event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
|
|
48
|
+
const when = whenText(current, tz, settings, link?.mode === 'day' || event?.allDay);
|
|
49
|
+
const active = current.status === 'pending' || current.status === 'confirmed';
|
|
50
|
+
const upcoming = current.end > Date.now();
|
|
51
|
+
const isNew = Astro.url.searchParams.has('new');
|
|
52
|
+
const moved = Astro.url.searchParams.has('moved');
|
|
53
|
+
const heading = current.status === 'cancelled' ? 'Booking cancelled'
|
|
54
|
+
: current.status === 'declined' ? 'Not available'
|
|
55
|
+
: current.status === 'pending' ? (isNew ? 'Request sent' : 'Waiting for approval')
|
|
56
|
+
: moved ? 'Booking moved' : isNew ? 'You’re booked' : 'Your booking';
|
|
57
|
+
const lede = current.status === 'pending' ? `We’ll email ${current.email} once it’s approved.`
|
|
58
|
+
: current.status === 'confirmed' ? (link?.confirmMessage || `A confirmation is on its way to ${current.email}.`)
|
|
59
|
+
: current.status === 'declined' ? 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.'
|
|
60
|
+
: 'This booking is cancelled.';
|
|
61
|
+
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 })}`;
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
<Site title={heading} noindex>
|
|
65
|
+
<div class="pc-manage">
|
|
66
|
+
<div class={`pc-manage-icon is-${current.status}`} aria-hidden="true">
|
|
67
|
+
{current.status === 'confirmed' ? <svg viewBox="0 0 24 24"><path d="M5 12l5 5L19 7" /></svg>
|
|
68
|
+
: current.status === 'pending' ? <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" /><path d="M12 8v4l3 2" /></svg>
|
|
69
|
+
: <svg viewBox="0 0 24 24"><path d="M7 7l10 10M17 7 7 17" /></svg>}
|
|
70
|
+
</div>
|
|
71
|
+
<h1>{heading}</h1>
|
|
72
|
+
<p class="pc-manage-lede">{lede}</p>
|
|
73
|
+
{error && <p class="pc-bk-error">{error}</p>}
|
|
74
|
+
<dl class="pc-manage-card">
|
|
75
|
+
<div><dt>What</dt><dd>{title}</dd></div>
|
|
76
|
+
<div><dt>When</dt><dd>{when}</dd></div>
|
|
77
|
+
{location && <div><dt>Where</dt><dd>{location}</dd></div>}
|
|
78
|
+
{current.seats > 1 && <div><dt>Seats</dt><dd>{current.seats}</dd></div>}
|
|
79
|
+
<div><dt>Status</dt><dd class={`pc-manage-status is-${current.status}`}>{current.status[0].toUpperCase() + current.status.slice(1)}</dd></div>
|
|
80
|
+
<div><dt>Reference</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
|
|
81
|
+
</dl>
|
|
82
|
+
{active && upcoming && (
|
|
83
|
+
<div class="pc-manage-actions">
|
|
84
|
+
{current.status === 'confirmed' && (
|
|
85
|
+
<details class="pc-ev-add">
|
|
86
|
+
<summary>Add to calendar</summary>
|
|
87
|
+
<div class="pc-ev-add-menu">
|
|
88
|
+
<a href={googleUrl} target="_blank" rel="noopener">Google Calendar</a>
|
|
89
|
+
<a href="?ics=1">Apple Calendar</a>
|
|
90
|
+
<a href="?ics=1" download="booking.ics">Download .ics</a>
|
|
91
|
+
</div>
|
|
92
|
+
</details>
|
|
93
|
+
)}
|
|
94
|
+
{resource && link && current.kind === 'booking' && (
|
|
95
|
+
<a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>Reschedule</a>
|
|
96
|
+
)}
|
|
97
|
+
<form method="post" onsubmit="return confirm('Cancel this booking?')">
|
|
98
|
+
<input type="hidden" name="op" value="cancel" />
|
|
99
|
+
<button class="pc-manage-btn is-danger" type="submit">Cancel {current.kind === 'rsvp' ? 'RSVP' : 'booking'}</button>
|
|
100
|
+
</form>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
<p class="pc-bk-foot">
|
|
104
|
+
{event ? <a href={eventUrl(event.slug)}>Back to {event.title}</a> : resource ? <a href={resourceUrl(resource.slug)}>Back to {resource.name}</a> : null}
|
|
105
|
+
</p>
|
|
106
|
+
<p class="pc-manage-note">Keep this link — anyone with it can change this booking.</p>
|
|
107
|
+
</div>
|
|
108
|
+
</Site>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { siteOrigin } from '../../lib/seo';
|
|
5
|
+
import { CALENDAR } from '../../lib/calendar/config';
|
|
6
|
+
import { feedFor, icsResponse } from '../../lib/calendar/feeds';
|
|
7
|
+
|
|
8
|
+
// One calendar's subscribe feed. Public and unlisted calendars are open;
|
|
9
|
+
// private ones need the secret ?t= token from Calendars → Copy link (staff
|
|
10
|
+
// subscriptions). A wrong token looks exactly like a missing calendar.
|
|
11
|
+
const sameToken = (a: string, b: string) =>
|
|
12
|
+
a.length === b.length && a.length > 0 && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
13
|
+
|
|
14
|
+
export const GET: APIRoute = async ({ locals, url, params }) => {
|
|
15
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
16
|
+
const repo = await getRepo(locals);
|
|
17
|
+
const calendar = await repo.getCalendarBySlug(params.slug ?? '');
|
|
18
|
+
if (!calendar || calendar.kind !== 'native') return new Response(null, { status: 404 });
|
|
19
|
+
const isPrivate = calendar.visibility === 'private';
|
|
20
|
+
if (isPrivate && !sameToken(url.searchParams.get('t') ?? '', calendar.feedToken)) return new Response(null, { status: 404 });
|
|
21
|
+
const settings = await repo.getSettings();
|
|
22
|
+
const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { calendar, allowPrivate: isPrivate });
|
|
23
|
+
// private feeds must never sit in shared caches
|
|
24
|
+
const res = icsResponse(body, `${calendar.slug}.ics`, isPrivate ? 0 : 300);
|
|
25
|
+
if (isPrivate) res.headers.set('Cache-Control', 'private, no-store');
|
|
26
|
+
return res;
|
|
27
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../lib/repo';
|
|
3
|
+
import { siteOrigin } from '../../lib/seo';
|
|
4
|
+
import { CALENDAR } from '../../lib/calendar/config';
|
|
5
|
+
import { feedFor, icsResponse } from '../../lib/calendar/feeds';
|
|
6
|
+
|
|
7
|
+
// Every public calendar in one subscribe feed (/calendar.ics). Feed
|
|
8
|
+
// calendars are not re-published — subscribers can add those at the source.
|
|
9
|
+
export const GET: APIRoute = async ({ locals, url }) => {
|
|
10
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
11
|
+
const repo = await getRepo(locals);
|
|
12
|
+
const settings = await repo.getSettings();
|
|
13
|
+
const { body } = await feedFor(repo, settings, siteOrigin(settings, url), {});
|
|
14
|
+
return icsResponse(body, 'calendar.ics');
|
|
15
|
+
};
|