@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,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
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { effectiveSettings } from '../../lib/designPreview';
|
|
5
|
+
import { siteOrigin } from '../../lib/seo';
|
|
6
|
+
import { siteLocale } from '../../lib/dates';
|
|
7
|
+
import { CALENDAR, calendarFeedUrl, calendarPageUrl } from '../../lib/calendar/config';
|
|
8
|
+
import { calendarWindow, renderCalendarView } from '../../lib/calendar/render';
|
|
9
|
+
import { collectViewEvents, type ViewEvent } from '../../lib/calendar/data';
|
|
10
|
+
import { eventCardHtml } from '../../lib/calendar/cards';
|
|
11
|
+
import { safeColor } from '../../lib/calendar/events';
|
|
12
|
+
import { tzParts } from '../../lib/calendar/ics';
|
|
13
|
+
|
|
14
|
+
// The main calendar page: filter chips per public calendar, Month / Week /
|
|
15
|
+
// List views (the same renderer as the calendar card), image cards for
|
|
16
|
+
// what's coming up, and subscribe links for every calendar app. On phones the
|
|
17
|
+
// month grid gives way to a two-week day strip over the agenda.
|
|
18
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
|
+
|
|
20
|
+
const repo = await getRepo(Astro.locals);
|
|
21
|
+
const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const tz = settings.timezone || 'UTC';
|
|
24
|
+
const locale = siteLocale(settings);
|
|
25
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
26
|
+
|
|
27
|
+
const all = await repo.listCalendars();
|
|
28
|
+
const listed = all.filter((c) => c.visibility === 'public');
|
|
29
|
+
const calSlug = Astro.url.searchParams.get('cal') ?? '';
|
|
30
|
+
// unlisted calendars work when linked directly, they just aren't offered
|
|
31
|
+
const active = calSlug ? all.find((c) => c.slug === calSlug && c.visibility !== 'private') ?? null : null;
|
|
32
|
+
const viewParam = Astro.url.searchParams.get('view');
|
|
33
|
+
const view = (['month', 'week', 'list'] as const).find((v) => v === viewParam) ?? CALENDAR.defaultView;
|
|
34
|
+
const explicitView = Boolean(viewParam);
|
|
35
|
+
const monthParam = /^(\d{4})-(\d{2})$/.exec(Astro.url.searchParams.get('m') ?? '');
|
|
36
|
+
|
|
37
|
+
const win = calendarWindow(settings, now);
|
|
38
|
+
const { events, calendars, errors } = await collectViewEvents(repo, settings, win, {
|
|
39
|
+
audience: 'public',
|
|
40
|
+
...(active ? { calendarIds: [active.id] } : {}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const calendarHtml = renderCalendarView(events, settings, now, {
|
|
44
|
+
view: view === 'list' ? 'upcoming' : view,
|
|
45
|
+
limit: 50,
|
|
46
|
+
multi: !active && calendars.length > 1,
|
|
47
|
+
emptyText: 'Nothing on the calendar yet.',
|
|
48
|
+
focus: monthParam ? { y: Number(monthParam[1]), m: Number(monthParam[2]) } : undefined,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// coming up: the next occurrence of each distinct native event
|
|
52
|
+
const seen = new Set<string>();
|
|
53
|
+
const comingUp: ViewEvent[] = [];
|
|
54
|
+
for (const v of events) {
|
|
55
|
+
if (!v.event || v.end <= now || seen.has(v.event.id)) continue;
|
|
56
|
+
seen.add(v.event.id);
|
|
57
|
+
comingUp.push(v);
|
|
58
|
+
if (comingUp.length >= 6) break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// agenda (list view + phones): next 60 days grouped by local day
|
|
62
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
63
|
+
const dayKey = (ms: number) => { const p = tzParts(ms, tz); return `${p.y}-${pad(p.m)}-${pad(p.d)}`; };
|
|
64
|
+
const horizon = now + 60 * 86_400_000;
|
|
65
|
+
const agenda = new Map<string, ViewEvent[]>();
|
|
66
|
+
for (const v of events) {
|
|
67
|
+
if (v.end <= now || v.start > horizon) continue;
|
|
68
|
+
// events already underway are listed under today
|
|
69
|
+
const k = dayKey(Math.max(v.start, now));
|
|
70
|
+
if (!agenda.has(k)) agenda.set(k, []);
|
|
71
|
+
agenda.get(k)!.push(v);
|
|
72
|
+
}
|
|
73
|
+
const fmt = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: tz });
|
|
74
|
+
const dayLabel = fmt({ weekday: 'long', month: 'long', day: 'numeric' });
|
|
75
|
+
const timeLabel = fmt({ hour: 'numeric', minute: '2-digit' });
|
|
76
|
+
const stripDays = Array.from({ length: 14 }, (_, i) => {
|
|
77
|
+
const ms = now + i * 86_400_000;
|
|
78
|
+
const k = dayKey(ms);
|
|
79
|
+
return { k, dow: fmt({ weekday: 'narrow' }).format(ms), d: tzParts(ms, tz).d, has: agenda.has(k), today: i === 0 };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const feed = origin + calendarFeedUrl(active?.slug);
|
|
83
|
+
const webcal = feed.replace(/^https?:\/\//, 'webcal://');
|
|
84
|
+
const googleAdd = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`;
|
|
85
|
+
const outlookAdd = `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(feed)}&name=${encodeURIComponent(active?.name ?? settings.siteTitle)}`;
|
|
86
|
+
const viewHref = (v: string) => {
|
|
87
|
+
const p = new URLSearchParams(Astro.url.search);
|
|
88
|
+
p.set('view', v);
|
|
89
|
+
p.delete('m');
|
|
90
|
+
return `${CALENDAR.base}?${p}`;
|
|
91
|
+
};
|
|
92
|
+
const title = active ? active.name : 'Calendar';
|
|
93
|
+
const lede = active?.description || `What’s happening at ${settings.siteTitle}. Subscribe to keep it in your own calendar.`;
|
|
94
|
+
const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.type === 'timeZoneName')?.value ?? tz;
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
<Site title={title} description={lede}>
|
|
98
|
+
<div class={`pc-calpage view-${view}${explicitView ? ' is-explicit' : ''}`}>
|
|
99
|
+
<header class="pc-calpage-head">
|
|
100
|
+
<h1>{title}</h1>
|
|
101
|
+
<p>{lede}</p>
|
|
102
|
+
</header>
|
|
103
|
+
|
|
104
|
+
<div class="pc-calpage-bar">
|
|
105
|
+
{(listed.length > 1 || active) && (
|
|
106
|
+
<nav class="pc-calpage-chips" aria-label="Calendars">
|
|
107
|
+
<a class={!active ? 'is-on' : ''} href={CALENDAR.base} aria-current={!active ? 'page' : undefined}>All</a>
|
|
108
|
+
{listed.map((c) => (
|
|
109
|
+
<a class={active?.id === c.id ? 'is-on' : ''} href={calendarPageUrl(c.slug)} aria-current={active?.id === c.id ? 'page' : undefined}>
|
|
110
|
+
<i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}
|
|
111
|
+
</a>
|
|
112
|
+
))}
|
|
113
|
+
{active && active.visibility !== 'public' && <a class="is-on" href={calendarPageUrl(active.slug)}><i style={`background:${safeColor(active.color)}`} aria-hidden="true" />{active.name}</a>}
|
|
114
|
+
</nav>
|
|
115
|
+
)}
|
|
116
|
+
<div class="pc-calpage-tools">
|
|
117
|
+
<nav class="pc-calpage-views" aria-label="View">
|
|
118
|
+
<a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>Month</a>
|
|
119
|
+
<a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>Week</a>
|
|
120
|
+
<a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>List</a>
|
|
121
|
+
</nav>
|
|
122
|
+
<details class="pc-calpage-sub">
|
|
123
|
+
<summary>
|
|
124
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg>
|
|
125
|
+
Subscribe
|
|
126
|
+
</summary>
|
|
127
|
+
<div class="pc-calpage-sub-menu">
|
|
128
|
+
<p>Add {active ? `“${active.name}”` : 'this calendar'} to your calendar app. New events show up on their own.</p>
|
|
129
|
+
<a href={googleAdd} target="_blank" rel="noopener">Google Calendar</a>
|
|
130
|
+
<a href={webcal}>Apple Calendar</a>
|
|
131
|
+
<a href={outlookAdd} target="_blank" rel="noopener">Outlook</a>
|
|
132
|
+
<button type="button" data-copy={feed}>Copy link</button>
|
|
133
|
+
</div>
|
|
134
|
+
</details>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div class="pc-calpage-strip" aria-label="Next two weeks">
|
|
139
|
+
{stripDays.map((d) => (
|
|
140
|
+
<a class={`${d.has ? 'has' : ''}${d.today ? ' today' : ''}`} href={d.has ? `#d-${d.k}` : undefined} aria-label={d.k}>
|
|
141
|
+
<small>{d.dow}</small><b>{d.d}</b><i aria-hidden="true" />
|
|
142
|
+
</a>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="pc-calpage-view" set:html={calendarHtml} />
|
|
147
|
+
|
|
148
|
+
<section class="pc-calpage-agenda" aria-label="Agenda">
|
|
149
|
+
{agenda.size === 0 ? <p class="pc-cal-empty">Nothing in the next two months.</p> : [...agenda.entries()].map(([k, list]) => (
|
|
150
|
+
<div class="pc-agenda-day" id={`d-${k}`}>
|
|
151
|
+
<h3>{dayLabel.format(list[0].start < now ? now : list[0].start)}</h3>
|
|
152
|
+
{list.map((v) => (
|
|
153
|
+
<a class={`pc-agenda-row${v.cancelled ? ' is-cancelled' : ''}`} href={v.url ?? undefined}>
|
|
154
|
+
<span class="t">{v.allDay ? 'All day' : timeLabel.format(v.start)}</span>
|
|
155
|
+
<span class="ti">
|
|
156
|
+
{v.color && <i style={`background:${v.color}`} aria-hidden="true" />}
|
|
157
|
+
<b>{v.title}</b>
|
|
158
|
+
{v.location && <small>{v.location}</small>}
|
|
159
|
+
</span>
|
|
160
|
+
</a>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
</section>
|
|
165
|
+
|
|
166
|
+
{view !== 'list' && comingUp.length > 0 && (
|
|
167
|
+
<section class="pc-calpage-up">
|
|
168
|
+
<h2>Coming up</h2>
|
|
169
|
+
<div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days }, settings, { showImage: true })).join('')} />
|
|
170
|
+
</section>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
<footer class="pc-calpage-foot">
|
|
174
|
+
<span>Times in {zoneName}</span>
|
|
175
|
+
{errors.length > 0 && <span>Some linked calendars couldn’t be loaded right now.</span>}
|
|
176
|
+
</footer>
|
|
177
|
+
</div>
|
|
178
|
+
<script>
|
|
179
|
+
document.addEventListener('click', async (e) => {
|
|
180
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
181
|
+
if (b) {
|
|
182
|
+
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
183
|
+
b.textContent = 'Link copied';
|
|
184
|
+
setTimeout(() => (b.textContent = 'Copy link'), 1400);
|
|
185
|
+
}
|
|
186
|
+
document.querySelectorAll<HTMLDetailsElement>('details.pc-calpage-sub[open]').forEach((d) => {
|
|
187
|
+
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
</script>
|
|
191
|
+
</Site>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../../../layouts/Site.astro';
|
|
3
|
+
import BookingFlow from '../../../../components/booking/BookingFlow.astro';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { bookingLinkUrl, bookingsEnabled } from '../../../../lib/calendar/config';
|
|
6
|
+
import { availabilityContext } from '../../../../lib/booking/service';
|
|
7
|
+
|
|
8
|
+
// Embeddable picker for other sites: day + time selection happens inside the
|
|
9
|
+
// iframe; choosing a time opens the full booking page (top window) to finish.
|
|
10
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
11
|
+
const repo = await getRepo(Astro.locals);
|
|
12
|
+
const resource = await repo.getResourceBySlug(Astro.params.resource ?? '');
|
|
13
|
+
const link = resource ? await repo.getBookingLinkBySlug(resource.id, Astro.params.link ?? '') : null;
|
|
14
|
+
if (!resource || !link || !link.active) return new Response(null, { status: 404 });
|
|
15
|
+
const settings = await repo.getSettings();
|
|
16
|
+
// a chosen time hands off to the real page, where the form lives
|
|
17
|
+
if (Astro.url.searchParams.get('start')) {
|
|
18
|
+
return Astro.redirect(`${bookingLinkUrl(resource.slug, link.slug)}${Astro.url.search}`);
|
|
19
|
+
}
|
|
20
|
+
const ctx = await availabilityContext(repo, resource, link, settings);
|
|
21
|
+
Astro.response.headers.set('Content-Security-Policy', 'frame-ancestors *');
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<Site title={link.name} bare noindex>
|
|
25
|
+
<BookingFlow resource={resource} link={link} ctx={ctx} settings={settings} url={Astro.url} embed />
|
|
26
|
+
<script is:inline>
|
|
27
|
+
(function () {
|
|
28
|
+
var send = function () { parent.postMessage({ acmsBookingEmbed: true, height: document.documentElement.scrollHeight }, '*'); };
|
|
29
|
+
window.addEventListener('load', send);
|
|
30
|
+
if (window.ResizeObserver) new ResizeObserver(send).observe(document.documentElement);
|
|
31
|
+
})();
|
|
32
|
+
</script>
|
|
33
|
+
</Site>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../../lib/repo';
|
|
4
|
+
import { CALENDAR } from '../../../lib/calendar/config';
|
|
5
|
+
import { calendarWindow, renderCalendarView } from '../../../lib/calendar/render';
|
|
6
|
+
import { collectViewEvents } from '../../../lib/calendar/data';
|
|
7
|
+
import { upcomingEvents, eventCardHtml } from '../../../lib/calendar/cards';
|
|
8
|
+
|
|
9
|
+
// Embeddable calendar for other websites:
|
|
10
|
+
// <iframe src="https://site/embed/calendar/all?view=month" …>
|
|
11
|
+
// slug 'all' = every public calendar. view: month | week | list | cards.
|
|
12
|
+
// Links open in the parent window; height is posted to the parent so a
|
|
13
|
+
// small script can auto-size the iframe.
|
|
14
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
15
|
+
const repo = await getRepo(Astro.locals);
|
|
16
|
+
const settings = await repo.getSettings();
|
|
17
|
+
const slug = Astro.params.slug ?? 'all';
|
|
18
|
+
const cal = slug === 'all' ? null : await repo.getCalendarBySlug(slug);
|
|
19
|
+
if (slug !== 'all' && (!cal || cal.visibility === 'private')) return new Response(null, { status: 404 });
|
|
20
|
+
const view = (['month', 'week', 'list', 'cards'] as const).find((v) => v === Astro.url.searchParams.get('view')) ?? 'month';
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
let html: string;
|
|
23
|
+
if (view === 'cards') {
|
|
24
|
+
const items = await upcomingEvents(repo, settings, now, { calendarIds: cal ? [cal.id] : undefined, limit: Math.min(12, Number(Astro.url.searchParams.get('limit')) || 6) });
|
|
25
|
+
html = `<section class="pc-ev-cards" data-layout="grid">${items.map((o) => eventCardHtml(o, settings, { showImage: true })).join('') || '<p class="pc-ev-empty">No upcoming events.</p>'}</section>`;
|
|
26
|
+
} else {
|
|
27
|
+
const { events, calendars } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public', ...(cal ? { calendarIds: [cal.id] } : {}) });
|
|
28
|
+
html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi: !cal && calendars.length > 1 });
|
|
29
|
+
}
|
|
30
|
+
Astro.response.headers.set('Content-Security-Policy', "frame-ancestors *");
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<Site title={cal?.name ?? 'Calendar'} bare noindex>
|
|
34
|
+
<base target="_top" />
|
|
35
|
+
<div class="pc-embed" set:html={html} />
|
|
36
|
+
<script is:inline>
|
|
37
|
+
(function () {
|
|
38
|
+
var send = function () { parent.postMessage({ acmsCalendarEmbed: true, height: document.documentElement.scrollHeight }, '*'); };
|
|
39
|
+
window.addEventListener('load', send);
|
|
40
|
+
if (window.ResizeObserver) new ResizeObserver(send).observe(document.documentElement);
|
|
41
|
+
})();
|
|
42
|
+
</script>
|
|
43
|
+
</Site>
|