@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3
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 +69 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/sections.mjs +186 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +55 -1
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +4 -2
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +19 -171
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/editor/PostEditor.tsx +174 -10
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +6 -1
- package/src/layouts/Site.astro +107 -19
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/content-service.ts +198 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +19 -11
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +8 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +40 -7
- package/src/lib/repo/types.ts +9 -3
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +27 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +217 -0
- package/src/lib/theme.ts +16 -1
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +32 -0
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +69 -0
- package/src/styles/editor.css +21 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/sections.css +7 -1
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +326 -0
- package/src/theme-sdk/index.ts +10 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +1 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { AdminPushState } from '../lib/push/api';
|
|
3
|
+
import type { PushAuto, PushNotification } from '../lib/push/types';
|
|
4
|
+
import '../styles/mobile-app.css';
|
|
5
|
+
|
|
6
|
+
// Admin → Mobile app: send a notification to the site's own app, choose the
|
|
7
|
+
// automatic notices, see who is subscribed and what was delivered. Owner only;
|
|
8
|
+
// the page exists only when acms({ mobile: { push } }) is on.
|
|
9
|
+
|
|
10
|
+
const ENDPOINT = '/api/push/admin';
|
|
11
|
+
const CUSTOM = '__custom';
|
|
12
|
+
const SOON_MINUTES = [10, 15, 30, 60, 120, 1440];
|
|
13
|
+
|
|
14
|
+
async function api<T>(body?: Record<string, unknown>): Promise<T> {
|
|
15
|
+
const res = await fetch(ENDPOINT, {
|
|
16
|
+
method: body ? 'POST' : 'GET', credentials: 'same-origin',
|
|
17
|
+
headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
|
|
18
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
19
|
+
});
|
|
20
|
+
const data = await res.json().catch(() => null);
|
|
21
|
+
if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
|
|
22
|
+
return data as T;
|
|
23
|
+
}
|
|
24
|
+
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
25
|
+
const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
|
|
26
|
+
const minutesLabel = (m: number) => (m >= 1440 ? '1 day' : m >= 60 ? `${m / 60} hour${m === 60 ? '' : 's'}` : `${m} minutes`);
|
|
27
|
+
|
|
28
|
+
function ago(ms: number, now: number) {
|
|
29
|
+
const s = Math.round((now - ms) / 1000);
|
|
30
|
+
if (s < 60) return 'Just now';
|
|
31
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
|
32
|
+
const d = new Date(ms);
|
|
33
|
+
const sameDay = new Date(now).toDateString() === d.toDateString();
|
|
34
|
+
return sameDay ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
35
|
+
: d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon' };
|
|
39
|
+
|
|
40
|
+
export default function MobileApp() {
|
|
41
|
+
const [state, setState] = useState<AdminPushState | null>(null);
|
|
42
|
+
const [loadError, setLoadError] = useState('');
|
|
43
|
+
useEffect(() => { api<AdminPushState>().then(setState, (e) => setLoadError(errText(e))); }, []);
|
|
44
|
+
if (loadError) return <p className="flash error">{loadError}</p>;
|
|
45
|
+
if (!state) return <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /><i /></div>;
|
|
46
|
+
return <MobileAppView state={state} setState={setState} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function MobileAppView({ state, setState }: { state: AdminPushState; setState: (s: AdminPushState) => void }) {
|
|
50
|
+
const topicLabel = useMemo(() => {
|
|
51
|
+
const map = new Map(state.topics.map((t) => [t.id, t.label]));
|
|
52
|
+
for (const c of state.collections) if (!map.has(`collection:${c.key}`)) map.set(`collection:${c.key}`, c.label);
|
|
53
|
+
for (const c of state.calendars) if (!map.has(`calendar:${c.slug}`)) map.set(`calendar:${c.slug}`, c.name);
|
|
54
|
+
map.set('*', 'Everyone');
|
|
55
|
+
return (id: string) => map.get(id) ?? id;
|
|
56
|
+
}, [state]);
|
|
57
|
+
const { stats } = state;
|
|
58
|
+
return (
|
|
59
|
+
<div className="ma">
|
|
60
|
+
<div className="kpis ma-kpis">
|
|
61
|
+
<div className="kpi"><small>Devices</small><b>{stats.devices.toLocaleString()}</b></div>
|
|
62
|
+
<div className="kpi"><small>iPhone and iPad</small><b>{stats.platforms.ios.toLocaleString()}</b></div>
|
|
63
|
+
<div className="kpi"><small>Android</small><b>{stats.platforms.android.toLocaleString()}</b></div>
|
|
64
|
+
<div className="kpi"><small>Opened in the last 30 days</small><b>{stats.active30.toLocaleString()}</b></div>
|
|
65
|
+
</div>
|
|
66
|
+
{stats.devices === 0 && (
|
|
67
|
+
<div className="notice ma-notice">
|
|
68
|
+
<div>
|
|
69
|
+
<b>No app has signed up for notifications yet.</b>
|
|
70
|
+
<p>Add the push recipe to your Expo app, then open it on a phone and turn notifications on. From your site folder: <code>npx acms mobile recipe add push</code></p>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
<Composer state={state} setState={setState} topicLabel={topicLabel} />
|
|
75
|
+
<div className="ma-grid ma-grid-2">
|
|
76
|
+
<Automatic state={state} setState={setState} />
|
|
77
|
+
<Audience state={state} topicLabel={topicLabel} />
|
|
78
|
+
</div>
|
|
79
|
+
<DeliveryLog log={state.log} topicLabel={topicLabel} />
|
|
80
|
+
<p className="hint ma-foot">
|
|
81
|
+
Sent through Expo’s push service{state.expo.customEndpoint ? ' (custom endpoint from ACMS_EXPO_PUSH_URL)' : ''}.{' '}
|
|
82
|
+
{state.expo.accessToken ? 'Expo access token is set.' : 'No Expo access token set; only needed when enhanced push security is on for your Expo project (EXPO_ACCESS_TOKEN).'}{' '}
|
|
83
|
+
Devices that don’t open the app for {state.retentionDays} days are removed.
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- composer -----------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function Composer({ state, setState, topicLabel }: { state: AdminPushState; setState: (s: AdminPushState) => void; topicLabel: (id: string) => string }) {
|
|
92
|
+
const [title, setTitle] = useState('');
|
|
93
|
+
const [body, setBody] = useState('');
|
|
94
|
+
const [linkRef, setLinkRef] = useState('');
|
|
95
|
+
const [customUrl, setCustomUrl] = useState('');
|
|
96
|
+
const [mode, setMode] = useState<'all' | 'topics'>('all');
|
|
97
|
+
const [topics, setTopics] = useState<string[]>([]);
|
|
98
|
+
const [count, setCount] = useState<number | null>(state.stats.devices);
|
|
99
|
+
const [busy, setBusy] = useState(false);
|
|
100
|
+
const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
|
|
101
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
102
|
+
|
|
103
|
+
const audience = mode === 'all' ? ['*'] : topics;
|
|
104
|
+
const target = state.links.find((l) => l.ref === linkRef);
|
|
105
|
+
const url = linkRef === CUSTOM ? customUrl.trim() : target?.url ?? '';
|
|
106
|
+
const groups = [...new Set(state.links.map((l) => l.group))];
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (mode === 'all') { setCount(state.stats.devices); return; }
|
|
110
|
+
if (!topics.length) { setCount(0); return; }
|
|
111
|
+
let live = true;
|
|
112
|
+
api<{ recipients: number }>({ action: 'count', audience: topics }).then((r) => live && setCount(r.recipients), () => live && setCount(null));
|
|
113
|
+
return () => { live = false; };
|
|
114
|
+
}, [mode, topics.join('|'), state.stats.devices]);
|
|
115
|
+
|
|
116
|
+
const toggle = (id: string) => setTopics((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
|
117
|
+
const problem = !title.trim() ? 'Add a title.' : mode === 'topics' && !topics.length ? 'Choose at least one topic.'
|
|
118
|
+
: linkRef === CUSTOM && url && !/^(\/(?!\/)|https?:\/\/)/i.test(url) ? 'Links start with / or https://.' : '';
|
|
119
|
+
|
|
120
|
+
const send = async () => {
|
|
121
|
+
setBusy(true); setMsg({});
|
|
122
|
+
try {
|
|
123
|
+
const r = await api<{ notification: PushNotification; state: AdminPushState }>({ action: 'send', title: title.trim(), body: body.trim(), url, audience });
|
|
124
|
+
dialog.current?.close();
|
|
125
|
+
const n = r.notification;
|
|
126
|
+
setMsg(n.status === 'failed' ? { err: `Not delivered: ${n.error || 'the push service refused it.'}` }
|
|
127
|
+
: { ok: n.recipients ? `Sent to ${plural(n.ok, 'device')}${n.failed ? ` · ${n.failed} failed` : ''}.` : 'Saved, but no device matched this audience.' });
|
|
128
|
+
if (n.status !== 'failed') { setTitle(''); setBody(''); setLinkRef(''); setCustomUrl(''); }
|
|
129
|
+
setState(r.state);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
dialog.current?.close();
|
|
132
|
+
setMsg({ err: errText(e) });
|
|
133
|
+
} finally { setBusy(false); }
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<section className="card card-pad ma-compose" aria-labelledby="ma-compose-h">
|
|
138
|
+
<div className="ma-compose-form">
|
|
139
|
+
<header className="ma-head">
|
|
140
|
+
<h2 id="ma-compose-h">New notification</h2>
|
|
141
|
+
<p>Goes to the lock screen of everyone you choose. Tapping it opens the link in your app.</p>
|
|
142
|
+
</header>
|
|
143
|
+
<label className="field">
|
|
144
|
+
<span>Title <em className="ma-count">{title.length}/80</em></span>
|
|
145
|
+
<input type="text" value={title} maxLength={80} placeholder="Service times changed this Sunday" onChange={(e) => setTitle(e.target.value)} />
|
|
146
|
+
</label>
|
|
147
|
+
<label className="field">
|
|
148
|
+
<span>Message <em className="ma-count">{body.length}/240</em></span>
|
|
149
|
+
<textarea rows={3} value={body} maxLength={240} placeholder="We meet at 10:30 this week only. See you there." onChange={(e) => setBody(e.target.value)} />
|
|
150
|
+
</label>
|
|
151
|
+
<label className="field">
|
|
152
|
+
<span>Opens</span>
|
|
153
|
+
<select value={linkRef} onChange={(e) => setLinkRef(e.target.value)}>
|
|
154
|
+
<option value="">The app’s home screen</option>
|
|
155
|
+
{groups.map((g) => (
|
|
156
|
+
<optgroup key={g} label={g}>
|
|
157
|
+
{state.links.filter((l) => l.group === g).map((l) => <option key={l.ref} value={l.ref}>{l.label}</option>)}
|
|
158
|
+
</optgroup>
|
|
159
|
+
))}
|
|
160
|
+
<option value={CUSTOM}>Custom address…</option>
|
|
161
|
+
</select>
|
|
162
|
+
</label>
|
|
163
|
+
{linkRef === CUSTOM && (
|
|
164
|
+
<label className="field">
|
|
165
|
+
<span>Address</span>
|
|
166
|
+
<input className="mono" type="url" value={customUrl} placeholder="/visit or https://example.com" onChange={(e) => setCustomUrl(e.target.value)} />
|
|
167
|
+
</label>
|
|
168
|
+
)}
|
|
169
|
+
{target && <p className="hint ma-linkhint">Opens {target.url}, in the reader’s language when a translation exists.</p>}
|
|
170
|
+
<fieldset className="ma-audience">
|
|
171
|
+
<legend>Who gets it</legend>
|
|
172
|
+
<div className="seg" role="tablist" aria-label="Audience">
|
|
173
|
+
<button type="button" role="tab" aria-selected={mode === 'all'} onClick={() => setMode('all')}>Everyone</button>
|
|
174
|
+
<button type="button" role="tab" aria-selected={mode === 'topics'} onClick={() => setMode('topics')}>People following…</button>
|
|
175
|
+
</div>
|
|
176
|
+
{mode === 'topics' && (
|
|
177
|
+
<div className="ma-topics">
|
|
178
|
+
{state.topics.map((t) => (
|
|
179
|
+
<label key={t.id} className={`ma-opt${topics.includes(t.id) ? ' on' : ''}`}>
|
|
180
|
+
<input type="checkbox" checked={topics.includes(t.id)} onChange={() => toggle(t.id)} />
|
|
181
|
+
<span>{t.label}</span>
|
|
182
|
+
<em>{(state.stats.topics[t.id] ?? 0).toLocaleString()}</em>
|
|
183
|
+
</label>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</fieldset>
|
|
188
|
+
{msg.ok && <p className="flash" role="status">{msg.ok}</p>}
|
|
189
|
+
{msg.err && <p className="flash error" role="alert">{msg.err}</p>}
|
|
190
|
+
<div className="ma-send">
|
|
191
|
+
<span className="ma-reach">{count === null ? 'Counting…' : count === 0 ? 'No devices match yet' : `Reaches ${plural(count, 'device')}`}</span>
|
|
192
|
+
<button type="button" className="btn primary" disabled={!!problem || busy} title={problem || undefined} onClick={() => dialog.current?.showModal()}>Send…</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<LockScreen state={state} title={title} body={body} />
|
|
196
|
+
<dialog className="modal" ref={dialog} aria-labelledby="ma-confirm-h">
|
|
197
|
+
<div className="modal-head"><span id="ma-confirm-h">Send to {count === null ? 'these devices' : plural(count, 'device')}?</span>
|
|
198
|
+
<button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
|
|
199
|
+
<div className="modal-body">
|
|
200
|
+
<div className="ma-confirm">
|
|
201
|
+
<b>{title || 'Untitled'}</b>
|
|
202
|
+
{body && <p>{body}</p>}
|
|
203
|
+
<small>{mode === 'all' ? 'Everyone with notifications on' : topics.map(topicLabel).join(', ')}{url ? ` · opens ${url}` : ''}</small>
|
|
204
|
+
</div>
|
|
205
|
+
<p className="hint modal-note">It arrives within seconds and can’t be recalled.</p>
|
|
206
|
+
<div className="modal-foot">
|
|
207
|
+
<button type="button" className="btn" onClick={() => dialog.current?.close()}>Cancel</button>
|
|
208
|
+
<button type="button" className="btn primary" disabled={busy} onClick={send}>{busy ? 'Sending…' : 'Send now'}</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</dialog>
|
|
212
|
+
</section>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A phone lock screen with the notification as it will show. Device preview:
|
|
217
|
+
// fixed neutral colors (like the site previews), not admin tokens.
|
|
218
|
+
function LockScreen({ state, title, body }: { state: AdminPushState; title: string; body: string }) {
|
|
219
|
+
const [now, setNow] = useState(() => new Date());
|
|
220
|
+
useEffect(() => { const t = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(t); }, []);
|
|
221
|
+
return (
|
|
222
|
+
<figure className="ma-preview" aria-label="Lock screen preview">
|
|
223
|
+
<div className="ma-phone">
|
|
224
|
+
<div className="ma-phone-screen">
|
|
225
|
+
<div className="ma-clock">
|
|
226
|
+
<small>{now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</small>
|
|
227
|
+
<b>{now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?[AP]M$/i, '')}</b>
|
|
228
|
+
</div>
|
|
229
|
+
<div className="ma-note">
|
|
230
|
+
<div className="ma-note-top">
|
|
231
|
+
<span className="ma-appicon" aria-hidden="true">
|
|
232
|
+
{state.site.icon ? <img src={state.site.icon} alt="" /> : (
|
|
233
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" /></svg>
|
|
234
|
+
)}
|
|
235
|
+
</span>
|
|
236
|
+
<span className="ma-appname">{state.site.name || 'Your app'}</span>
|
|
237
|
+
<span className="ma-now">now</span>
|
|
238
|
+
</div>
|
|
239
|
+
<b className="ma-note-title">{title.trim() || 'Your title'}</b>
|
|
240
|
+
<p className="ma-note-body">{body.trim() || 'Your message appears here.'}</p>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
<figcaption className="hint">Phones shorten long text; keep the key words first.</figcaption>
|
|
245
|
+
</figure>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- automatic ----------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
function Automatic({ state, setState }: { state: AdminPushState; setState: (s: AdminPushState) => void }) {
|
|
252
|
+
const [auto, setAuto] = useState<PushAuto>(state.auto);
|
|
253
|
+
const [busy, setBusy] = useState(false);
|
|
254
|
+
const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
|
|
255
|
+
const dirty = JSON.stringify(auto) !== JSON.stringify(state.auto);
|
|
256
|
+
const flip = (list: string[], id: string) => (list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
|
|
257
|
+
const save = async () => {
|
|
258
|
+
setBusy(true); setMsg({});
|
|
259
|
+
try {
|
|
260
|
+
const r = await api<{ auto: PushAuto; state: AdminPushState }>({ action: 'saveAuto', auto });
|
|
261
|
+
setState(r.state); setAuto(r.auto); setMsg({ ok: 'Saved.' });
|
|
262
|
+
} catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
|
|
263
|
+
};
|
|
264
|
+
const calendarChoices = (key: 'newEvent' | 'startingSoon') => {
|
|
265
|
+
const list = auto[key].calendars;
|
|
266
|
+
const all = list.includes('*');
|
|
267
|
+
const set = (calendars: string[]) => setAuto({ ...auto, [key]: { ...auto[key], calendars } });
|
|
268
|
+
return (
|
|
269
|
+
<div className="ma-choices">
|
|
270
|
+
<label className="ma-check"><input type="checkbox" checked={all} onChange={() => set(all ? [] : ['*'])} /><span>All public calendars</span></label>
|
|
271
|
+
{state.calendars.map((c) => (
|
|
272
|
+
<label key={c.id} className="ma-check"><input type="checkbox" disabled={all} checked={all || list.includes(c.id)} onChange={() => set(flip(list, c.id))} />
|
|
273
|
+
<span><i className="ma-dot" style={{ background: c.color || 'var(--faint)' }} />{c.name}</span></label>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
return (
|
|
279
|
+
<section className="card card-pad" aria-labelledby="ma-auto-h">
|
|
280
|
+
<header className="ma-head">
|
|
281
|
+
<h2 id="ma-auto-h">Automatic notifications</h2>
|
|
282
|
+
<p>Sent once, when something goes live for the first time. Edits never notify again.</p>
|
|
283
|
+
</header>
|
|
284
|
+
<div className="ma-rules">
|
|
285
|
+
<div className="ma-rule">
|
|
286
|
+
<div className="ma-rule-head"><b>New post published</b><small>Title and summary, opens the post.</small></div>
|
|
287
|
+
<div className="ma-choices">
|
|
288
|
+
{state.collections.map((c) => (
|
|
289
|
+
<label key={c.key} className="ma-check"><input type="checkbox" checked={auto.newPost.collections.includes(c.key)}
|
|
290
|
+
onChange={() => setAuto({ ...auto, newPost: { collections: flip(auto.newPost.collections, c.key) } })} /><span>{c.label}</span></label>
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
{state.calendarEnabled && (
|
|
295
|
+
<>
|
|
296
|
+
<div className="ma-rule">
|
|
297
|
+
<div className="ma-rule-head"><b>New event published</b><small>Name, date and place, opens the event.</small></div>
|
|
298
|
+
{calendarChoices('newEvent')}
|
|
299
|
+
</div>
|
|
300
|
+
<div className="ma-rule">
|
|
301
|
+
<div className="ma-rule-head"><b>Starting soon</b><small>A reminder before each event time, once per date.</small></div>
|
|
302
|
+
{calendarChoices('startingSoon')}
|
|
303
|
+
<label className="field ma-lead">
|
|
304
|
+
<span>Send</span>
|
|
305
|
+
<select value={auto.startingSoon.minutes} onChange={(e) => setAuto({ ...auto, startingSoon: { ...auto.startingSoon, minutes: Number(e.target.value) } })}>
|
|
306
|
+
{[...new Set([...SOON_MINUTES, auto.startingSoon.minutes])].sort((a, b) => a - b).map((m) => <option key={m} value={m}>{minutesLabel(m)} before</option>)}
|
|
307
|
+
</select>
|
|
308
|
+
</label>
|
|
309
|
+
</div>
|
|
310
|
+
</>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
{msg.err && <p className="flash error" role="alert">{msg.err}</p>}
|
|
314
|
+
<div className="form-actions">
|
|
315
|
+
<button type="button" className="btn" disabled={!dirty || busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
|
|
316
|
+
{dirty ? <button type="button" className="btn ghost" onClick={() => { setAuto(state.auto); setMsg({}); }}>Discard</button>
|
|
317
|
+
: msg.ok && <span className="ma-saved" role="status">{msg.ok}</span>}
|
|
318
|
+
</div>
|
|
319
|
+
</section>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- audience -----------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function Audience({ state, topicLabel }: { state: AdminPushState; topicLabel: (id: string) => string }) {
|
|
326
|
+
const { stats } = state;
|
|
327
|
+
const rows = [...new Set([...state.topics.map((t) => t.id), ...Object.keys(stats.topics)])]
|
|
328
|
+
.map((id) => ({ id, n: stats.topics[id] ?? 0 }));
|
|
329
|
+
const max = Math.max(1, ...rows.map((r) => r.n));
|
|
330
|
+
return (
|
|
331
|
+
<section className="card card-pad" aria-labelledby="ma-aud-h">
|
|
332
|
+
<header className="ma-head">
|
|
333
|
+
<h2 id="ma-aud-h">Following</h2>
|
|
334
|
+
<p>Topics people turned on in the app. Everyone gets “Everyone” notifications.</p>
|
|
335
|
+
</header>
|
|
336
|
+
<ul className="ma-bars">
|
|
337
|
+
{rows.map((r) => (
|
|
338
|
+
<li key={r.id}>
|
|
339
|
+
<span className="ma-bar-label">{topicLabel(r.id)}<code>{r.id}</code></span>
|
|
340
|
+
<span className="ma-bar" aria-hidden="true"><i style={{ width: `${(r.n / max) * 100}%` }} /></span>
|
|
341
|
+
<b>{r.n.toLocaleString()}</b>
|
|
342
|
+
</li>
|
|
343
|
+
))}
|
|
344
|
+
</ul>
|
|
345
|
+
<p className="hint ma-aud-note">Your app decides which topics to offer; it reads them from <code>/api/v1/push/topics</code>.</p>
|
|
346
|
+
</section>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- delivery log -------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
function DeliveryLog({ log, topicLabel }: { log: PushNotification[]; topicLabel: (id: string) => string }) {
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
return (
|
|
355
|
+
<section className="card ma-log" aria-labelledby="ma-log-h">
|
|
356
|
+
<header className="ma-head card-pad ma-log-head">
|
|
357
|
+
<h2 id="ma-log-h">Delivery log</h2>
|
|
358
|
+
<p>The last 50 notifications. Failures update after Expo reports back (about 15 minutes).</p>
|
|
359
|
+
</header>
|
|
360
|
+
{!log.length ? <p className="ma-empty">Nothing sent yet.</p> : (
|
|
361
|
+
<div className="ma-scroll">
|
|
362
|
+
<table className="list ma-table">
|
|
363
|
+
<thead><tr><th>Sent</th><th>Notification</th><th>Audience</th><th>From</th><th className="num">Delivered</th></tr></thead>
|
|
364
|
+
<tbody>
|
|
365
|
+
{log.map((n) => (
|
|
366
|
+
<tr key={n.id}>
|
|
367
|
+
<td data-l="Sent" className="ma-when">{ago(n.createdAt, now)}</td>
|
|
368
|
+
<td data-l="Notification">
|
|
369
|
+
<div className="t-title">{n.title}</div>
|
|
370
|
+
{n.body && <div className="ma-body">{n.body}</div>}
|
|
371
|
+
{n.url && <div className="t-sub ma-url">{n.url}</div>}
|
|
372
|
+
</td>
|
|
373
|
+
<td data-l="Audience" className="ma-aud">{n.topics.map(topicLabel).join(', ')}</td>
|
|
374
|
+
<td data-l="From">{SOURCE[n.source]}{n.source === 'manual' && n.author ? ` ${n.author}` : n.source === 'manual' ? ' an owner' : ''}</td>
|
|
375
|
+
<td data-l="Delivered" className="num ma-result">
|
|
376
|
+
{n.status === 'sending' ? <span className="ma-st" data-tone="info">Sending…</span>
|
|
377
|
+
: !n.recipients ? <span className="ma-st">No devices</span>
|
|
378
|
+
: <>
|
|
379
|
+
<span className="ma-st" data-tone={n.status === 'failed' ? 'bad' : n.failed ? 'warn' : 'ok'}>{n.ok.toLocaleString()} of {n.recipients.toLocaleString()}</span>
|
|
380
|
+
{(n.failed > 0 || n.removed > 0) && <small>{n.failed ? `${n.failed} failed` : ''}{n.failed && n.removed ? ' · ' : ''}{n.removed ? `${n.removed} removed` : ''}</small>}
|
|
381
|
+
{n.error && <small className="ma-err" title={n.error}>{n.error}</small>}
|
|
382
|
+
</>}
|
|
383
|
+
</td>
|
|
384
|
+
</tr>
|
|
385
|
+
))}
|
|
386
|
+
</tbody>
|
|
387
|
+
</table>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</section>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
@@ -5,49 +5,68 @@ import { getTheme } from '../lib/theme';
|
|
|
5
5
|
import { getRepo } from '../lib/repo';
|
|
6
6
|
import { CONTENT, collectionUrl } from '../lib/content';
|
|
7
7
|
import { sectionIssues } from '../theme-sdk/contract.mjs';
|
|
8
|
+
import { migrateSection, resolveSectionData, wallNow, withExample } from '../lib/sections';
|
|
9
|
+
import { scheduledVisible, localizeData } from '../theme-sdk/fields.mjs';
|
|
10
|
+
import { I18N, requestLocale } from '../lib/i18n';
|
|
11
|
+
import { resolvePreview } from '../lib/designPreview';
|
|
8
12
|
import WidgetRenderer from './WidgetRenderer.astro';
|
|
9
13
|
import { expandDynamicBlocks } from '../lib/dynamicBlocks';
|
|
10
14
|
import { highlightCodeHtml } from '../lib/highlightCode';
|
|
11
15
|
import PostCard from './site/PostCard.astro';
|
|
12
16
|
import '../styles/sections.css';
|
|
13
|
-
interface Props { page: Page; settings: Settings }
|
|
17
|
+
interface Props { page: Page; settings: Settings; editable?: boolean; locale?: string }
|
|
14
18
|
const { page, settings } = Astro.props;
|
|
15
19
|
const theme = getTheme(settings.activeTheme);
|
|
16
20
|
const layout = settings.sectionPages[page.id];
|
|
17
21
|
const repo = await getRepo(Astro.locals);
|
|
18
22
|
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
// admin design preview: sections carry their ids so the Design screen can
|
|
24
|
+
// open the one you click (see Site.astro's preview script)
|
|
25
|
+
const editable = Astro.props.editable ?? (await resolvePreview(Astro.url, Astro.cookies, Astro.locals)).role === 'admin';
|
|
26
|
+
const locale = Astro.props.locale ?? requestLocale(Astro.url, Astro.locals);
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const nowWall = wallNow(settings.timezone || 'UTC', now);
|
|
29
|
+
// another language: dates/times format in it (components read settings.locale)
|
|
30
|
+
const localized = locale !== I18N.defaultLocale && I18N.locales.includes(locale);
|
|
31
|
+
const view = localized ? { ...settings, locale } : settings;
|
|
32
|
+
const sections = await Promise.all(layout.sections.map(async (stored) => {
|
|
33
|
+
// older instances upgrade through the theme's migrations/<type>.mjs, if any
|
|
34
|
+
const migrated = migrateSection(theme, stored);
|
|
35
|
+
const definition = theme.sections[migrated.type];
|
|
36
|
+
// scheduled sections: outside their window they're hidden for visitors;
|
|
37
|
+
// the admin preview still shows them (marked) so they can be edited
|
|
38
|
+
const offSchedule = !scheduledVisible(migrated, nowWall);
|
|
39
|
+
const example = editable && definition ? withExample(definition, migrated.data) : { data: migrated.data, used: false };
|
|
40
|
+
// validity is judged on the main-language data (translations are words only)
|
|
41
|
+
const invalid = migrated.hidden || (offSchedule && !editable) || sectionIssues({ mode: 'sections', sections: [{ ...migrated, data: example.data as typeof migrated.data }] }, theme.sections).length > 0;
|
|
42
|
+
// another language: its translated words over the shared facts
|
|
43
|
+
const words = definition && localized && migrated.translations?.[locale] ? localizeData(definition, example.data, migrated.translations[locale]) : example.data;
|
|
44
|
+
const data = definition && !invalid ? await resolveSectionData(definition, words, { repo, settings: view, now }) : {};
|
|
45
|
+
const section = migrated;
|
|
27
46
|
const collection = String(data.collection || 'posts');
|
|
28
|
-
const posts = section.type === 'posts' && !
|
|
47
|
+
const posts = section.type === 'posts' && !invalid && CONTENT.collections[collection]
|
|
29
48
|
? (await repo.listPublishedPosts(collection)).slice(0, Math.max(1, Math.min(12, Number(data.limit) || 3))) : [];
|
|
30
|
-
|
|
49
|
+
const items = Array.isArray(data.items) ? (data.items as { title?: string; text?: string }[]) : [];
|
|
50
|
+
return { ...section, data, invalid, posts, items, offSchedule, exampleUsed: example.used, Template: theme.sectionTemplates[section.type] as any };
|
|
31
51
|
}));
|
|
52
|
+
const hero = (d: Record<string, unknown>) => ({ image: String(d.image ?? ''), href: String(d.href ?? '') });
|
|
32
53
|
---
|
|
33
54
|
<div class="acms-sections">
|
|
34
|
-
{sections.filter(s => !s.hidden && !s.invalid).map(({ id, type, variant, data, Template, posts }) => (
|
|
35
|
-
<section id={`section-${id}`} class:list={['acms-section', `section-${type}`, `variant-${variant}
|
|
36
|
-
{type === 'live' && !Template ? <LiveCard display={variant} /> : type === 'page-content' ? (page.bodyHtml ? <article class="page-body" set:html={bodyHtml} /> : <WidgetRenderer pageId={page.id} blocks={page.blocks} />) : Template ? <Template data={data} variant={variant} settings={
|
|
55
|
+
{sections.filter(s => !s.hidden && !s.invalid).map(({ id, type, variant, data, Template, posts, items, offSchedule, exampleUsed }) => (
|
|
56
|
+
<section id={`section-${id}`} class:list={['acms-section', `section-${type}`, `variant-${variant}`, { 'acms-off-schedule': editable && offSchedule }]} data-acms-section={editable ? id : undefined} data-acms-page={editable ? page.id : undefined} data-acms-label={editable ? `${theme.sections[type]?.name ?? type}${offSchedule ? ' · not showing now (scheduled)' : exampleUsed ? ' · example text' : ''}` : undefined}>
|
|
57
|
+
{type === 'live' && !Template ? <LiveCard display={variant} /> : type === 'page-content' ? (page.bodyHtml ? <article class="page-body" set:html={bodyHtml} /> : <WidgetRenderer pageId={page.id} blocks={page.blocks} />) : Template ? <Template data={data} variant={variant} settings={view} page={page} /> : (
|
|
37
58
|
<>
|
|
38
59
|
<div class="section-copy">
|
|
39
60
|
{data.heading && <h2>{String(data.heading)}</h2>}
|
|
40
61
|
{data.text && <p class="section-text">{String(data.text)}</p>}
|
|
41
|
-
{data.label &&
|
|
62
|
+
{data.label && hero(data).href && <a class="w-btn primary" href={hero(data).href}>{String(data.label)}</a>}
|
|
42
63
|
</div>
|
|
43
|
-
{type === 'hero' &&
|
|
64
|
+
{type === 'hero' && hero(data).image && <img class="section-image" src={hero(data).image} alt={String(data.alt ?? '')} loading="eager" />}
|
|
44
65
|
{['features', 'testimonials', 'faq'].includes(type) && (
|
|
45
66
|
<div class="section-items">
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
: <article><h3>{title}</h3><p>{body.join('|')}</p></article>;
|
|
50
|
-
})}
|
|
67
|
+
{items.filter(row => row.title || row.text).map((row) => type === 'faq'
|
|
68
|
+
? <details><summary>{row.title}</summary><p>{row.text}</p></details>
|
|
69
|
+
: <article><h3>{row.title}</h3><p>{row.text}</p></article>)}
|
|
51
70
|
</div>
|
|
52
71
|
)}
|
|
53
72
|
{type === 'posts' && <div class="section-items">{posts.map(post => <PostCard variant={variant === 'list' ? 'row' : 'card'} href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} cover={post.cover} coverAlt={post.coverAlt} />)}</div>}
|