@acms-subir/core 0.1.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 +106 -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 -315
- 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 +193 -2
- package/ops-check.mjs +27 -0
- package/package.json +35 -12
- package/src/actions/index.ts +744 -38
- 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 +455 -47
- 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 +216 -97
- package/src/components/NavManager.tsx +83 -38
- 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 +826 -271
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +77 -9
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- 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 +331 -57
- 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/analytics.ts +121 -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 +112 -0
- 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 +131 -0
- package/src/lib/dynamicBlocks.ts +79 -7
- 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 +254 -0
- package/src/lib/repo/sql.ts +956 -46
- package/src/lib/repo/types.ts +140 -3
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +362 -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 +10 -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 +14 -4
- package/src/pages/admin/c/[collection]/index.astro +127 -91
- 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 +16 -4
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +237 -87
- 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 +14 -3
- package/src/pages/admin/pages/index.astro +41 -47
- package/src/pages/admin/settings.astro +475 -276
- 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 +140 -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 +9 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +33 -7
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +34 -8
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +928 -105
- package/src/styles/cards.css +101 -60
- package/src/styles/editor.css +532 -102
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -278
- 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
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { ImagePicker } from './editor/ImagePicker';
|
|
3
|
+
|
|
4
|
+
// Pages tab of the Design screen: lay out a page from the active theme's
|
|
5
|
+
// section registry (builtins + the theme's own sections). Edits stay local
|
|
6
|
+
// until "Save section draft" stages them into the design draft through
|
|
7
|
+
// POST /api/design; nothing here publishes, and a page's own content is
|
|
8
|
+
// never touched (switching a page back to "Page content" keeps its layout).
|
|
9
|
+
|
|
10
|
+
type FieldType = 'text' | 'textarea' | 'image' | 'url' | 'select' | 'number';
|
|
11
|
+
interface FieldDef { label: string; type: FieldType; default?: string | number; options?: string[] }
|
|
12
|
+
interface SectionDef { name?: string; label?: string; version?: number; variants?: string[]; fields?: Record<string, FieldDef> }
|
|
13
|
+
type SectionValue = string | number;
|
|
14
|
+
export interface SectionInstance {
|
|
15
|
+
id: string;
|
|
16
|
+
type: string;
|
|
17
|
+
version: number;
|
|
18
|
+
variant: string;
|
|
19
|
+
hidden: boolean;
|
|
20
|
+
data: Record<string, SectionValue>;
|
|
21
|
+
}
|
|
22
|
+
export interface PageLayout { mode: 'sections' | 'content'; sections: SectionInstance[] }
|
|
23
|
+
export type SectionPages = Record<string, PageLayout>;
|
|
24
|
+
interface DesignSide { activeTheme?: string; sectionPages?: SectionPages; [k: string]: unknown }
|
|
25
|
+
export interface DesignApiState {
|
|
26
|
+
revision: number;
|
|
27
|
+
live: DesignSide;
|
|
28
|
+
draft: DesignSide | null;
|
|
29
|
+
changes: { key: string; label: string; from: string; to: string; kind: string }[];
|
|
30
|
+
themes: { slug: string; name: string; version: string | number; sections: Record<string, SectionDef> }[];
|
|
31
|
+
pages: { id: string; title: string; slug: string; isHome: boolean }[];
|
|
32
|
+
versions: { id: string; createdAt: number; author: string; summary: string }[];
|
|
33
|
+
scopes: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const errorText = async (res: Response, fallback: string) => {
|
|
37
|
+
try {
|
|
38
|
+
const body = (await res.json()) as { error?: unknown };
|
|
39
|
+
if (typeof body?.error === 'string' && body.error) return body.error;
|
|
40
|
+
} catch { /* not JSON */ }
|
|
41
|
+
return fallback;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** GET /api/design, shared by the Pages editor and the Review tab */
|
|
45
|
+
export function useDesignApi() {
|
|
46
|
+
const [data, setData] = useState<DesignApiState | null>(null);
|
|
47
|
+
const [error, setError] = useState('');
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const load = useCallback(async () => {
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setError('');
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch('/api/design', { headers: { accept: 'application/json' } });
|
|
54
|
+
if (!res.ok) { setError(await errorText(res, `Couldn't load the design (${res.status}).`)); return; }
|
|
55
|
+
setData((await res.json()) as DesignApiState);
|
|
56
|
+
} catch {
|
|
57
|
+
setError("Couldn't reach the server.");
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
}
|
|
61
|
+
}, []);
|
|
62
|
+
useEffect(() => { void load(); }, [load]);
|
|
63
|
+
return { data, error, loading, reload: load };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stagedPages = (d: DesignApiState): SectionPages =>
|
|
67
|
+
(d.draft?.sectionPages ?? d.live.sectionPages ?? {}) as SectionPages;
|
|
68
|
+
|
|
69
|
+
const sectionName = (def: SectionDef | undefined, type: string) => def?.name || def?.label || type;
|
|
70
|
+
|
|
71
|
+
const newId = (type: string) => {
|
|
72
|
+
const rand = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
73
|
+
? crypto.randomUUID().slice(0, 8)
|
|
74
|
+
: Math.random().toString(36).slice(2, 10);
|
|
75
|
+
return `${type}-${rand}`;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const defaultsFor = (def: SectionDef): Record<string, SectionValue> => {
|
|
79
|
+
const out: Record<string, SectionValue> = {};
|
|
80
|
+
for (const [key, f] of Object.entries(def.fields ?? {})) {
|
|
81
|
+
if (f.default !== undefined) out[key] = f.default;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const pagePath = (p: { slug: string; isHome: boolean }) => (p.isHome ? '/' : `/${p.slug}`);
|
|
87
|
+
|
|
88
|
+
interface EditorProps {
|
|
89
|
+
api: ReturnType<typeof useDesignApi>;
|
|
90
|
+
/** theme picked in the Theme tab (may be ahead of the staged draft) */
|
|
91
|
+
activeTheme: string;
|
|
92
|
+
unsplashEnabled: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled }: EditorProps) {
|
|
96
|
+
const { data, error: loadError, loading, reload } = api;
|
|
97
|
+
const [pages, setPages] = useState<SectionPages>({});
|
|
98
|
+
const [pageId, setPageId] = useState('');
|
|
99
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
100
|
+
const [addType, setAddType] = useState('');
|
|
101
|
+
const [saving, setSaving] = useState(false);
|
|
102
|
+
const [saveError, setSaveError] = useState('');
|
|
103
|
+
const [conflict, setConflict] = useState(false);
|
|
104
|
+
const [imageFor, setImageFor] = useState<{ sectionId: string; key: string } | null>(null);
|
|
105
|
+
|
|
106
|
+
// rehydrate local edits whenever a fresh server state arrives
|
|
107
|
+
const base = useMemo(() => (data ? stagedPages(data) : {}), [data]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!data) return;
|
|
110
|
+
setPages(structuredClone(base));
|
|
111
|
+
setConflict(false);
|
|
112
|
+
setSaveError('');
|
|
113
|
+
setPageId((cur) => (cur && data.pages.some((p) => p.id === cur) ? cur : (data.pages.find((p) => p.isHome) ?? data.pages[0])?.id ?? ''));
|
|
114
|
+
}, [data, base]);
|
|
115
|
+
|
|
116
|
+
const dirty = useMemo(() => JSON.stringify(pages) !== JSON.stringify(base), [pages, base]);
|
|
117
|
+
const dirtyRef = useRef(false);
|
|
118
|
+
dirtyRef.current = dirty;
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const warn = (e: BeforeUnloadEvent) => { if (dirtyRef.current) e.preventDefault(); };
|
|
121
|
+
addEventListener('beforeunload', warn);
|
|
122
|
+
return () => removeEventListener('beforeunload', warn);
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const theme = data?.themes.find((t) => t.slug === activeTheme)
|
|
126
|
+
?? data?.themes.find((t) => t.slug === (data.draft?.activeTheme ?? data.live.activeTheme))
|
|
127
|
+
?? data?.themes[0];
|
|
128
|
+
const defs: Record<string, SectionDef> = theme?.sections ?? {};
|
|
129
|
+
const defTypes = Object.keys(defs);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!defTypes.includes(addType)) setAddType(defTypes[0] ?? '');
|
|
132
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
|
+
}, [defTypes.join('|')]);
|
|
134
|
+
|
|
135
|
+
if (loading && !data) return <p className="te-muted">Loading page layouts…</p>;
|
|
136
|
+
if (!data) {
|
|
137
|
+
return (
|
|
138
|
+
<div className="notice warn" role="alert">
|
|
139
|
+
<span>{loadError || "Couldn't load page layouts."}</span>
|
|
140
|
+
<button className="btn sm" type="button" onClick={() => void reload()}>Try again</button>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (data.pages.length === 0) {
|
|
145
|
+
return (
|
|
146
|
+
<div className="empty-state">
|
|
147
|
+
<b>No pages yet</b>
|
|
148
|
+
<p>Create a page first, then give it a section layout here.</p>
|
|
149
|
+
<a className="btn" href="/admin/pages">Go to pages</a>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const page = data.pages.find((p) => p.id === pageId) ?? data.pages[0];
|
|
155
|
+
const layout: PageLayout | undefined = pages[page.id];
|
|
156
|
+
|
|
157
|
+
const setLayout = (fn: (l: PageLayout) => PageLayout) =>
|
|
158
|
+
setPages((all) => ({ ...all, [page.id]: fn(all[page.id] ?? { mode: 'sections', sections: [] }) }));
|
|
159
|
+
const setSections = (fn: (s: SectionInstance[]) => SectionInstance[]) =>
|
|
160
|
+
setLayout((l) => ({ ...l, sections: fn(l.sections) }));
|
|
161
|
+
const patchSection = (id: string, fn: (s: SectionInstance) => SectionInstance) =>
|
|
162
|
+
setSections((list) => list.map((s) => (s.id === id ? fn(s) : s)));
|
|
163
|
+
|
|
164
|
+
const addSection = () => {
|
|
165
|
+
const def = defs[addType];
|
|
166
|
+
if (!def) return;
|
|
167
|
+
const s: SectionInstance = {
|
|
168
|
+
id: newId(addType),
|
|
169
|
+
type: addType,
|
|
170
|
+
version: def.version ?? 1,
|
|
171
|
+
variant: def.variants?.[0] ?? 'default',
|
|
172
|
+
hidden: false,
|
|
173
|
+
data: defaultsFor(def),
|
|
174
|
+
};
|
|
175
|
+
setLayout((l) => ({ ...l, mode: 'sections', sections: [...l.sections, s] }));
|
|
176
|
+
setOpenId(s.id);
|
|
177
|
+
};
|
|
178
|
+
const move = (i: number, dir: -1 | 1) =>
|
|
179
|
+
setSections((list) => {
|
|
180
|
+
const j = i + dir;
|
|
181
|
+
if (j < 0 || j >= list.length) return list;
|
|
182
|
+
const next = [...list];
|
|
183
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
184
|
+
return next;
|
|
185
|
+
});
|
|
186
|
+
const duplicate = (i: number) =>
|
|
187
|
+
setSections((list) => {
|
|
188
|
+
const copy = { ...structuredClone(list[i]), id: newId(list[i].type) };
|
|
189
|
+
return [...list.slice(0, i + 1), copy, ...list.slice(i + 1)];
|
|
190
|
+
});
|
|
191
|
+
const remove = (s: SectionInstance) => {
|
|
192
|
+
if (!window.confirm(`Delete the “${sectionName(defs[s.type], s.type)}” section from ${page.title}? This happens when you save the section draft.`)) return;
|
|
193
|
+
setSections((list) => list.filter((x) => x.id !== s.id));
|
|
194
|
+
};
|
|
195
|
+
const setField = (id: string, key: string, value: SectionValue | undefined) =>
|
|
196
|
+
patchSection(id, (s) => {
|
|
197
|
+
const d = { ...s.data };
|
|
198
|
+
if (value === undefined) delete d[key]; else d[key] = value;
|
|
199
|
+
return { ...s, data: d };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const save = async () => {
|
|
203
|
+
setSaving(true);
|
|
204
|
+
setSaveError('');
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch('/api/design', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
209
|
+
body: JSON.stringify({ operation: 'stage', revision: data.revision, patch: { sectionPages: { ...base, ...pages } } }),
|
|
210
|
+
});
|
|
211
|
+
if (res.status === 409) { setConflict(true); return; }
|
|
212
|
+
if (!res.ok) { setSaveError(await errorText(res, `Couldn't save the section draft (${res.status}).`)); return; }
|
|
213
|
+
dirtyRef.current = false;
|
|
214
|
+
location.reload(); // refresh the draft status, change list and preview
|
|
215
|
+
} catch {
|
|
216
|
+
setSaveError("Couldn't reach the server. Your edits are still here.");
|
|
217
|
+
} finally {
|
|
218
|
+
setSaving(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const reloadLatest = () => {
|
|
222
|
+
if (dirty && !window.confirm('Load the latest draft? Your unsaved section edits will be lost.')) return;
|
|
223
|
+
void reload();
|
|
224
|
+
};
|
|
225
|
+
const discardEdits = () => {
|
|
226
|
+
if (!window.confirm('Discard your unsaved section edits?')) return;
|
|
227
|
+
setPages(structuredClone(base));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const pageDirty = (id: string) => JSON.stringify(pages[id]) !== JSON.stringify(base[id]);
|
|
231
|
+
const editingSection = imageFor ? layout?.sections.find((s) => s.id === imageFor.sectionId) : undefined;
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className="te">
|
|
235
|
+
<p className="te-intro">
|
|
236
|
+
Build a page from your theme's sections. Edits stay on this screen until you save them to the private design
|
|
237
|
+
draft; visitors see them only after <b>Publish</b>. The page's own content is never changed.
|
|
238
|
+
</p>
|
|
239
|
+
|
|
240
|
+
<div className="te-pagebar">
|
|
241
|
+
<label className="field te-pagepick">
|
|
242
|
+
<span>Page</span>
|
|
243
|
+
<select aria-label="Page" value={page.id} onChange={(e) => { setPageId(e.target.value); setOpenId(null); }}>
|
|
244
|
+
{data.pages.map((p) => (
|
|
245
|
+
<option key={p.id} value={p.id}>
|
|
246
|
+
{p.title || p.slug}{p.isHome ? ' · home' : ''}{pages[p.id]?.mode === 'sections' ? ' · sections' : ''}{pageDirty(p.id) ? ' · edited' : ''}
|
|
247
|
+
</option>
|
|
248
|
+
))}
|
|
249
|
+
</select>
|
|
250
|
+
</label>
|
|
251
|
+
<a className="btn sm ghost" href={`${pagePath(page)}?designPreview=1`} target="_blank" rel="noopener">
|
|
252
|
+
Preview page ↗
|
|
253
|
+
</a>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{!layout ? (
|
|
257
|
+
<div className="te-empty">
|
|
258
|
+
<p><b>{page.title || page.slug}</b> shows its page content.</p>
|
|
259
|
+
<p className="te-muted">Start a layout, then add Existing page content to keep your current text or widgets alongside new sections. You can switch back at any time.</p>
|
|
260
|
+
<button className="btn" type="button" onClick={() => setLayout(() => ({ mode: 'sections', sections: [{ id: newId('page-content'), type: 'page-content', version: 1, variant: 'default', hidden: false, data: {} }] }))}>Keep content and add sections</button>
|
|
261
|
+
</div>
|
|
262
|
+
) : (
|
|
263
|
+
<>
|
|
264
|
+
<div className="te-mode">
|
|
265
|
+
<span className="te-mode-label" id="te-mode-label">Page shows</span>
|
|
266
|
+
<div className="seg" role="radiogroup" aria-labelledby="te-mode-label">
|
|
267
|
+
{(['sections', 'content'] as const).map((m) => (
|
|
268
|
+
<button
|
|
269
|
+
key={m}
|
|
270
|
+
type="button"
|
|
271
|
+
role="radio"
|
|
272
|
+
aria-checked={layout.mode === m}
|
|
273
|
+
className={layout.mode === m ? 'on' : ''}
|
|
274
|
+
onClick={() => setLayout((l) => ({ ...l, mode: m }))}
|
|
275
|
+
>
|
|
276
|
+
{m === 'sections' ? 'Sections' : 'Page content'}
|
|
277
|
+
</button>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
{layout.mode === 'content' && (
|
|
282
|
+
<p className="notice te-note">
|
|
283
|
+
This page shows its own content. The {layout.sections.length} {layout.sections.length === 1 ? 'section is' : 'sections are'} kept, so you can switch back later.
|
|
284
|
+
</p>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
<ol className={`te-list ${layout.mode === 'content' ? 'is-inactive' : ''}`} aria-label={`Sections on ${page.title || page.slug}`}>
|
|
288
|
+
{layout.sections.length === 0 && <li className="te-muted te-none">No sections yet. Add one below.</li>}
|
|
289
|
+
{layout.sections.map((s, i) => {
|
|
290
|
+
const def = defs[s.type];
|
|
291
|
+
const name = sectionName(def, s.type);
|
|
292
|
+
const open = openId === s.id;
|
|
293
|
+
const panelId = `te-sec-${s.id}`;
|
|
294
|
+
return (
|
|
295
|
+
<li key={s.id} className={`te-sec ${s.hidden ? 'is-hidden' : ''} ${open ? 'is-open' : ''}`}>
|
|
296
|
+
<div className="te-sec-head">
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
className="te-sec-toggle"
|
|
300
|
+
aria-expanded={open}
|
|
301
|
+
aria-controls={panelId}
|
|
302
|
+
onClick={() => setOpenId(open ? null : s.id)}
|
|
303
|
+
>
|
|
304
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
|
305
|
+
<span className="te-sec-name">{name}</span>
|
|
306
|
+
<span className="te-sec-meta">
|
|
307
|
+
{s.variant}
|
|
308
|
+
{s.hidden && ' · hidden'}
|
|
309
|
+
{!def && ' · unavailable'}
|
|
310
|
+
</span>
|
|
311
|
+
</button>
|
|
312
|
+
<div className="te-sec-actions">
|
|
313
|
+
<button className="icon-btn" type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label={`Move ${name} up`} title="Move up">
|
|
314
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 15 6-6 6 6" /></svg>
|
|
315
|
+
</button>
|
|
316
|
+
<button className="icon-btn" type="button" onClick={() => move(i, 1)} disabled={i === layout.sections.length - 1} aria-label={`Move ${name} down`} title="Move down">
|
|
317
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
318
|
+
</button>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
{open && (
|
|
323
|
+
<div className="te-sec-body" id={panelId}>
|
|
324
|
+
{!def ? (
|
|
325
|
+
<p className="notice warn te-note">
|
|
326
|
+
{theme?.name ?? 'This theme'} has no “{s.type}” section, so it won't render. Its {Object.keys(s.data).length} saved {Object.keys(s.data).length === 1 ? 'value is' : 'values are'} preserved and come back if you switch to a theme that has it.
|
|
327
|
+
</p>
|
|
328
|
+
) : (
|
|
329
|
+
<>
|
|
330
|
+
{def.version !== undefined && s.version !== def.version && (
|
|
331
|
+
<p className="notice warn te-note">Saved with version {s.version} of this section; the theme ships version {def.version}.</p>
|
|
332
|
+
)}
|
|
333
|
+
{s.type === 'page-content' && <p className="te-muted">This section uses the page's published text and widgets. Edit them in the page editor; any pending content draft stays separate.</p>}
|
|
334
|
+
{(def.variants?.length ?? 0) > 0 && (
|
|
335
|
+
<label className="field">
|
|
336
|
+
<span>Variant</span>
|
|
337
|
+
<select value={s.variant} onChange={(e) => patchSection(s.id, (x) => ({ ...x, variant: e.target.value }))}>
|
|
338
|
+
{!def.variants!.includes(s.variant) && <option value={s.variant}>{s.variant} (not in this theme)</option>}
|
|
339
|
+
{def.variants!.map((v) => <option key={v} value={v}>{v}</option>)}
|
|
340
|
+
</select>
|
|
341
|
+
</label>
|
|
342
|
+
)}
|
|
343
|
+
{Object.entries(def.fields ?? {}).map(([key, f]) => (
|
|
344
|
+
<FieldInput
|
|
345
|
+
key={key}
|
|
346
|
+
id={`${panelId}-${key}`}
|
|
347
|
+
field={f}
|
|
348
|
+
value={s.data[key]}
|
|
349
|
+
onChange={(v) => setField(s.id, key, v)}
|
|
350
|
+
onPickImage={() => setImageFor({ sectionId: s.id, key })}
|
|
351
|
+
/>
|
|
352
|
+
))}
|
|
353
|
+
</>
|
|
354
|
+
)}
|
|
355
|
+
<div className="te-sec-foot">
|
|
356
|
+
<button className="btn sm" type="button" aria-pressed={s.hidden} onClick={() => patchSection(s.id, (x) => ({ ...x, hidden: !x.hidden }))}>
|
|
357
|
+
{s.hidden ? 'Show section' : 'Hide section'}
|
|
358
|
+
</button>
|
|
359
|
+
<button className="btn sm" type="button" onClick={() => duplicate(i)}>Duplicate</button>
|
|
360
|
+
<button className="btn sm ghost danger te-delete" type="button" onClick={() => remove(s)}>Delete section</button>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</li>
|
|
365
|
+
);
|
|
366
|
+
})}
|
|
367
|
+
</ol>
|
|
368
|
+
|
|
369
|
+
<div className="te-add">
|
|
370
|
+
<label className="field">
|
|
371
|
+
<span>Add a section</span>
|
|
372
|
+
<select aria-label="Add a section" value={addType} onChange={(e) => setAddType(e.target.value)} disabled={defTypes.length === 0}>
|
|
373
|
+
{defTypes.length === 0 && <option value="">This theme has no sections</option>}
|
|
374
|
+
{defTypes.map((t) => <option key={t} value={t}>{sectionName(defs[t], t)}</option>)}
|
|
375
|
+
</select>
|
|
376
|
+
</label>
|
|
377
|
+
<button className="btn" type="button" onClick={addSection} disabled={!defs[addType]}>Add</button>
|
|
378
|
+
</div>
|
|
379
|
+
</>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
{conflict && (
|
|
383
|
+
<div className="notice warn te-alert" role="alert">
|
|
384
|
+
<span>The design draft changed somewhere else since this screen loaded. Your edits are still here and weren't saved.</span>
|
|
385
|
+
<button className="btn sm" type="button" onClick={reloadLatest}>Load latest draft</button>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
{saveError && <p className="flash error te-alert" role="alert">{saveError}</p>}
|
|
389
|
+
|
|
390
|
+
<div className="te-savebar">
|
|
391
|
+
<span className={`te-savestate ${dirty ? 'is-dirty' : ''}`} aria-live="polite">
|
|
392
|
+
{dirty ? 'Unsaved section edits' : 'No unsaved section edits'}
|
|
393
|
+
</span>
|
|
394
|
+
{dirty && <button className="btn sm ghost" type="button" onClick={discardEdits} disabled={saving}>Discard edits</button>}
|
|
395
|
+
<button className="btn sm" type="button" onClick={() => void save()} disabled={!dirty || saving || conflict}>
|
|
396
|
+
{saving ? 'Saving…' : 'Save section draft'}
|
|
397
|
+
</button>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
<ImagePicker
|
|
401
|
+
open={Boolean(imageFor && editingSection)}
|
|
402
|
+
unsplashEnabled={unsplashEnabled}
|
|
403
|
+
onClose={() => setImageFor(null)}
|
|
404
|
+
onSelect={(img) => {
|
|
405
|
+
if (imageFor) setField(imageFor.sectionId, imageFor.key, img.src);
|
|
406
|
+
setImageFor(null);
|
|
407
|
+
}}
|
|
408
|
+
/>
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function FieldInput({ id, field, value, onChange, onPickImage }: {
|
|
414
|
+
id: string;
|
|
415
|
+
field: FieldDef;
|
|
416
|
+
value: SectionValue | undefined;
|
|
417
|
+
onChange: (v: SectionValue | undefined) => void;
|
|
418
|
+
onPickImage: () => void;
|
|
419
|
+
}) {
|
|
420
|
+
const shown = value ?? field.default ?? '';
|
|
421
|
+
switch (field.type) {
|
|
422
|
+
case 'textarea':
|
|
423
|
+
return (
|
|
424
|
+
<label className="field" htmlFor={id}>
|
|
425
|
+
<span>{field.label}</span>
|
|
426
|
+
<textarea id={id} rows={4} value={String(shown)} onChange={(e) => onChange(e.target.value)} />
|
|
427
|
+
</label>
|
|
428
|
+
);
|
|
429
|
+
case 'number':
|
|
430
|
+
return (
|
|
431
|
+
<label className="field" htmlFor={id}>
|
|
432
|
+
<span>{field.label}</span>
|
|
433
|
+
<input
|
|
434
|
+
id={id}
|
|
435
|
+
type="number"
|
|
436
|
+
value={shown === '' ? '' : String(shown)}
|
|
437
|
+
onChange={(e) => {
|
|
438
|
+
const n = e.target.valueAsNumber;
|
|
439
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? undefined : n);
|
|
440
|
+
}}
|
|
441
|
+
/>
|
|
442
|
+
</label>
|
|
443
|
+
);
|
|
444
|
+
case 'select': {
|
|
445
|
+
const opts = field.options ?? [];
|
|
446
|
+
return (
|
|
447
|
+
<label className="field" htmlFor={id}>
|
|
448
|
+
<span>{field.label}</span>
|
|
449
|
+
<select id={id} value={String(shown)} onChange={(e) => onChange(e.target.value)}>
|
|
450
|
+
{!opts.includes(String(shown)) && <option value={String(shown)}>{shown === '' ? 'Choose…' : `${shown} (not an option)`}</option>}
|
|
451
|
+
{opts.map((o) => <option key={o} value={o}>{o}</option>)}
|
|
452
|
+
</select>
|
|
453
|
+
</label>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
case 'image':
|
|
457
|
+
return (
|
|
458
|
+
<div className="field te-image">
|
|
459
|
+
<label htmlFor={id}><span>{field.label}</span></label>
|
|
460
|
+
<div className="te-image-row">
|
|
461
|
+
{shown !== '' && <img className="te-thumb" src={String(shown)} alt="" />}
|
|
462
|
+
<input id={id} className="mono" type="url" placeholder="https://" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
|
|
463
|
+
<button className="btn sm" type="button" onClick={onPickImage}>Choose…</button>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
case 'url':
|
|
468
|
+
return (
|
|
469
|
+
<label className="field" htmlFor={id}>
|
|
470
|
+
<span>{field.label}</span>
|
|
471
|
+
<input id={id} className="mono" type="url" placeholder="https:// or /path" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
|
|
472
|
+
</label>
|
|
473
|
+
);
|
|
474
|
+
default:
|
|
475
|
+
return (
|
|
476
|
+
<label className="field" htmlFor={id}>
|
|
477
|
+
<span>{field.label}</span>
|
|
478
|
+
<input id={id} type="text" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
|
|
479
|
+
</label>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Review tab: which pages the staged draft re-lays out, vs the live site */
|
|
485
|
+
export function SectionsReview({ api }: { api: ReturnType<typeof useDesignApi> }) {
|
|
486
|
+
const { data, error } = api;
|
|
487
|
+
if (error && !data) return <p className="te-muted">Page layouts couldn't be loaded: {error}</p>;
|
|
488
|
+
if (!data) return <p className="te-muted">Loading page layouts…</p>;
|
|
489
|
+
const live = (data.live.sectionPages ?? {}) as SectionPages;
|
|
490
|
+
const staged = data.draft?.sectionPages as SectionPages | undefined;
|
|
491
|
+
if (!staged) return <p className="te-muted">No page layout changes are staged.</p>;
|
|
492
|
+
const ids = [...new Set([...Object.keys(live), ...Object.keys(staged)])]
|
|
493
|
+
.filter((id) => JSON.stringify(live[id]) !== JSON.stringify(staged[id]));
|
|
494
|
+
if (ids.length === 0) return <p className="te-muted">No page layout changes are staged.</p>;
|
|
495
|
+
return (
|
|
496
|
+
<ul className="te-review">
|
|
497
|
+
{ids.map((id) => {
|
|
498
|
+
const p = data.pages.find((x) => x.id === id);
|
|
499
|
+
const s = staged[id];
|
|
500
|
+
const shown = s ? s.sections.filter((x) => !x.hidden).length : 0;
|
|
501
|
+
return (
|
|
502
|
+
<li key={id}>
|
|
503
|
+
<span className="te-review-page">{p ? p.title || p.slug : 'Deleted page'}{p?.isHome ? ' · home' : ''}</span>
|
|
504
|
+
<span className="te-review-what">
|
|
505
|
+
{!s ? 'Layout removed' : s.mode === 'content' ? 'Shows page content' : `${shown} visible ${shown === 1 ? 'section' : 'sections'}`}
|
|
506
|
+
</span>
|
|
507
|
+
{p && <a className="btn sm ghost" href={`${pagePath(p)}?designPreview=1`} target="_blank" rel="noopener">Preview ↗</a>}
|
|
508
|
+
</li>
|
|
509
|
+
);
|
|
510
|
+
})}
|
|
511
|
+
</ul>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The booking flow for one link (mockup D + E): length chips (range links),
|
|
3
|
+
// a month of open days, start times that fit, then the questions. Every step
|
|
4
|
+
// is a plain link or form (?date=…&len=…&start=…), so it works without
|
|
5
|
+
// JavaScript and each state is shareable. Used by the page and the embed.
|
|
6
|
+
import type { Booking, BookingLink, Resource, Settings } from '../../lib/schema';
|
|
7
|
+
import { bookingLinkUrl, resourceUrl } from '../../lib/calendar/config';
|
|
8
|
+
import { describeLength, describeLink, linkLengths, openDays, slotsForDay, ymd, type AvailabilityContext } from '../../lib/booking/availability';
|
|
9
|
+
import { addMonths, fmt, monthCells, slotLabel } from '../../lib/booking/view';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
resource: Resource;
|
|
13
|
+
link: BookingLink;
|
|
14
|
+
ctx: AvailabilityContext;
|
|
15
|
+
settings: Settings;
|
|
16
|
+
url: URL;
|
|
17
|
+
error?: string;
|
|
18
|
+
values?: Record<string, string>;
|
|
19
|
+
embed?: boolean;
|
|
20
|
+
reschedule?: Booking | null;
|
|
21
|
+
}
|
|
22
|
+
const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null } = Astro.props;
|
|
23
|
+
const tz = ctx.resource.timezone || settings.timezone || 'UTC';
|
|
24
|
+
const f = fmt(settings, tz);
|
|
25
|
+
const today = ymd(ctx.now, tz);
|
|
26
|
+
const lengths = linkLengths(link);
|
|
27
|
+
const lenParam = Number(url.searchParams.get('len'));
|
|
28
|
+
const len = link.mode === 'range' ? (lengths.includes(lenParam) ? lenParam : lengths[Math.floor(lengths.length / 2)]) : undefined;
|
|
29
|
+
const month = /^\d{4}-\d{2}$/.test(url.searchParams.get('m') ?? '') ? url.searchParams.get('m')! : today.slice(0, 7);
|
|
30
|
+
const horizon = Math.min(link.maxAdvance + 1, 400);
|
|
31
|
+
const open = new Set(openDays(ctx, today, horizon, len));
|
|
32
|
+
const firstOpen = [...open].sort()[0] ?? '';
|
|
33
|
+
const dateParam = url.searchParams.get('date') ?? '';
|
|
34
|
+
const date = open.has(dateParam) ? dateParam : '';
|
|
35
|
+
const shownMonth = date ? date.slice(0, 7) : month;
|
|
36
|
+
const cells = monthCells(shownMonth, open, today);
|
|
37
|
+
const slots = date ? slotsForDay(ctx, date, link.mode === 'day' ? undefined : len) : [];
|
|
38
|
+
const startParam = Number(url.searchParams.get('start'));
|
|
39
|
+
const chosen = slots.find((s) => s.start === startParam && (link.mode !== 'day' || s.minutes === Number(url.searchParams.get('len') || s.minutes)));
|
|
40
|
+
const minMonth = today.slice(0, 7);
|
|
41
|
+
const maxMonth = ymd(ctx.now + link.maxAdvance * 86_400_000, tz).slice(0, 7);
|
|
42
|
+
const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : bookingLinkUrl(resource.slug, link.slug);
|
|
43
|
+
const q = (patch: Record<string, string | number | undefined>) => {
|
|
44
|
+
const p = new URLSearchParams();
|
|
45
|
+
for (const k of ['len', 'm', 'date', 'start', 'reschedule']) {
|
|
46
|
+
const v = k in patch ? patch[k] : url.searchParams.get(k) ?? undefined;
|
|
47
|
+
if (v !== undefined && v !== '' && v !== null) p.set(k, String(v));
|
|
48
|
+
}
|
|
49
|
+
const s = p.toString();
|
|
50
|
+
return s ? `${base}?${s}` : base;
|
|
51
|
+
};
|
|
52
|
+
const rules = [
|
|
53
|
+
describeLink(link),
|
|
54
|
+
link.autoConfirm ? 'Confirmed instantly' : 'Needs approval',
|
|
55
|
+
link.bufferBefore || link.bufferAfter ? `Includes ${[link.bufferBefore && `${link.bufferBefore} min setup`, link.bufferAfter && `${link.bufferAfter} min cleanup`].filter(Boolean).join(' + ')}` : '',
|
|
56
|
+
].filter(Boolean);
|
|
57
|
+
const val = (k: string) => values[k] ?? '';
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
<div class={`pc-bk${embed ? ' is-embed' : ''}`}>
|
|
61
|
+
<aside class="pc-bk-info">
|
|
62
|
+
{!embed && <a class="pc-bk-back" href={resourceUrl(resource.slug)}>‹ {resource.name}</a>}
|
|
63
|
+
{embed && <span class="pc-bk-kicker">{resource.name}</span>}
|
|
64
|
+
<h1 class="pc-bk-title">{link.name}</h1>
|
|
65
|
+
{link.description && <p class="pc-bk-desc">{link.description}</p>}
|
|
66
|
+
<ul class="pc-bk-rules">{rules.map((r) => <li>{r}</li>)}</ul>
|
|
67
|
+
{link.mode === 'range' && lengths.length > 1 && (
|
|
68
|
+
<div class="pc-bk-lens" role="group" aria-label="How long">
|
|
69
|
+
<span class="pc-bk-label">How long?</span>
|
|
70
|
+
<div>{lengths.map((l) => <a class={l === len ? 'is-on' : ''} href={q({ len: l, start: undefined })} aria-current={l === len ? 'true' : undefined}>{describeLength(l)}</a>)}</div>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
<p class="pc-bk-tz">Times in {f.zone}</p>
|
|
74
|
+
{reschedule && <p class="pc-bk-note">Moving your booking from {f.dayLong.format(reschedule.start)}, {f.time.format(reschedule.start)}.</p>}
|
|
75
|
+
</aside>
|
|
76
|
+
|
|
77
|
+
{!chosen ? (
|
|
78
|
+
<>
|
|
79
|
+
<section class="pc-bk-cal" aria-label="Pick a day">
|
|
80
|
+
{error && <p class="pc-bk-error" role="alert">{error}</p>}
|
|
81
|
+
<div class="pc-bk-calhead">
|
|
82
|
+
<b>{f.month.format(Date.UTC(Number(shownMonth.slice(0, 4)), Number(shownMonth.slice(5, 7)) - 1, 15))}</b>
|
|
83
|
+
<span>
|
|
84
|
+
{shownMonth > minMonth ? <a href={q({ m: addMonths(shownMonth, -1), date: undefined, start: undefined })} aria-label="Previous month">‹</a> : <span aria-hidden="true">‹</span>}
|
|
85
|
+
{shownMonth < maxMonth ? <a href={q({ m: addMonths(shownMonth, 1), date: undefined, start: undefined })} aria-label="Next month">›</a> : <span aria-hidden="true">›</span>}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="pc-bk-grid">
|
|
89
|
+
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d) => <span class="dow">{d}</span>)}
|
|
90
|
+
{cells.map((c) => !c.inMonth ? <span class="out" /> : c.open ? (
|
|
91
|
+
<a class={`day open${c.date === date ? ' is-on' : ''}${c.date === today ? ' today' : ''}`} href={q({ date: c.date, m: c.date.slice(0, 7), start: undefined })} aria-label={c.date}>{c.day}</a>
|
|
92
|
+
) : <span class={`day${c.date === today ? ' today' : ''}`}>{c.day}</span>)}
|
|
93
|
+
</div>
|
|
94
|
+
{!open.size && <p class="pc-bk-empty">No openings in the next {horizon} days.</p>}
|
|
95
|
+
{open.size > 0 && !date && <p class="pc-bk-hint">Pick a highlighted day.</p>}
|
|
96
|
+
</section>
|
|
97
|
+
<section class="pc-bk-slots" aria-label="Pick a time">
|
|
98
|
+
{date ? (
|
|
99
|
+
<>
|
|
100
|
+
<b class="pc-bk-slotday">{f.dayLong.format(slots[0]?.start ?? ctx.now)}</b>
|
|
101
|
+
{slots.length ? (
|
|
102
|
+
<div class="pc-bk-slotlist">{slots.map((s) => <a href={q({ start: s.start, len: link.mode === 'day' ? s.minutes : len })} target={embed ? '_top' : undefined}>{slotLabel(s, link, f)}</a>)}</div>
|
|
103
|
+
) : <p class="pc-bk-empty">Fully booked — try another day.</p>}
|
|
104
|
+
</>
|
|
105
|
+
) : <p class="pc-bk-hint">{firstOpen ? <>Next opening: <a href={q({ date: firstOpen, m: firstOpen.slice(0, 7) })}>{f.dayLong.format(Date.UTC(Number(firstOpen.slice(0, 4)), Number(firstOpen.slice(5, 7)) - 1, Number(firstOpen.slice(8, 10)), 12))}</a></> : ''}</p>}
|
|
106
|
+
</section>
|
|
107
|
+
</>
|
|
108
|
+
) : (
|
|
109
|
+
<section class="pc-bk-form">
|
|
110
|
+
<div class="pc-bk-summary">
|
|
111
|
+
<b>{resource.name} · {link.name}</b>
|
|
112
|
+
<span>{f.dayLong.format(chosen.start)} · {slotLabel(chosen, link, f)}</span>
|
|
113
|
+
<a href={q({ start: undefined })}>Change</a>
|
|
114
|
+
</div>
|
|
115
|
+
{error && <p class="pc-bk-error" role="alert">{error}</p>}
|
|
116
|
+
<form method="post">
|
|
117
|
+
<input type="hidden" name="start" value={chosen.start} />
|
|
118
|
+
<input type="hidden" name="end" value={chosen.end} />
|
|
119
|
+
{reschedule ? (
|
|
120
|
+
<input type="hidden" name="reschedule" value={reschedule.manageToken} />
|
|
121
|
+
) : (
|
|
122
|
+
<>
|
|
123
|
+
<label class="pc-bk-field"><span>Your name</span><input name="name" required autocomplete="name" value={val('name')} /></label>
|
|
124
|
+
<label class="pc-bk-field"><span>Email</span><input name="email" type="email" required autocomplete="email" value={val('email')} /></label>
|
|
125
|
+
{link.askPhone && <label class="pc-bk-field"><span>Phone</span><input name="phone" type="tel" autocomplete="tel" value={val('phone')} /></label>}
|
|
126
|
+
{link.questions.map((qn) => (
|
|
127
|
+
qn.type === 'checkbox' ? (
|
|
128
|
+
<label class="pc-bk-check"><input type="checkbox" name={`q_${qn.id}`} value="yes" required={qn.required} checked={val(`q_${qn.id}`) === 'yes'} /> <span>{qn.label}</span></label>
|
|
129
|
+
) : (
|
|
130
|
+
<label class="pc-bk-field">
|
|
131
|
+
<span>{qn.label}{!qn.required && <em> optional</em>}</span>
|
|
132
|
+
{qn.type === 'textarea' ? <textarea name={`q_${qn.id}`} rows="3" required={qn.required}>{val(`q_${qn.id}`)}</textarea>
|
|
133
|
+
: qn.type === 'select' ? (
|
|
134
|
+
<select name={`q_${qn.id}`} required={qn.required}>
|
|
135
|
+
<option value="">Choose…</option>
|
|
136
|
+
{qn.options.map((o) => <option value={o} selected={val(`q_${qn.id}`) === o}>{o}</option>)}
|
|
137
|
+
</select>
|
|
138
|
+
) : <input name={`q_${qn.id}`} type={qn.type === 'phone' ? 'tel' : qn.type} required={qn.required} value={val(`q_${qn.id}`)} />}
|
|
139
|
+
{qn.help && <small>{qn.help}</small>}
|
|
140
|
+
</label>
|
|
141
|
+
)
|
|
142
|
+
))}
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
<button class="pc-bk-submit" type="submit">{reschedule ? 'Move my booking' : link.autoConfirm ? 'Confirm booking' : 'Request booking'}</button>
|
|
146
|
+
{!link.autoConfirm && !reschedule && <p class="pc-bk-fine">We’ll email you once it’s approved. Nothing is final until then.</p>}
|
|
147
|
+
</form>
|
|
148
|
+
</section>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|