@acms-subir/core 0.3.5 → 0.3.7
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 +23 -0
- package/cli/remote.mjs +1 -1
- package/docs/theme-authoring.md +5 -2
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +148 -2
- package/src/components/DesignManager.tsx +34 -3
- package/src/components/EventPage.astro +25 -5
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/components/import/ImportExport.tsx +1 -1
- package/src/components/site/SiteLogo.astro +23 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/layouts/Site.astro +5 -4
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/design-service.ts +2 -2
- package/src/lib/designDiff.ts +2 -2
- package/src/lib/drafts.ts +11 -2
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/import/bundle.ts +1 -0
- package/src/lib/import/export.ts +1 -1
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +3 -3
- package/src/lib/mobile/site.ts +1 -1
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +104 -14
- package/src/lib/repo/types.ts +22 -2
- package/src/lib/schema.ts +39 -0
- package/src/lib/site-chrome.ts +1 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +12 -7
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- package/src/virtual.d.ts +2 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
title: string;
|
|
4
|
+
logo: string;
|
|
5
|
+
logoDark?: string;
|
|
6
|
+
}
|
|
7
|
+
const { title, logo, logoDark = '' } = Astro.props;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
{logo
|
|
11
|
+
? <img class:list={['brand-logo', { 'logo-light': !!logoDark }]} src={logo} alt={title} />
|
|
12
|
+
: <span class:list={['brand-name', { 'logo-light': !!logoDark }]}>{title}</span>}
|
|
13
|
+
{logoDark && <img class="brand-logo logo-dark" src={logoDark} alt={title} />}
|
|
14
|
+
|
|
15
|
+
<style>
|
|
16
|
+
.logo-dark { display: none; }
|
|
17
|
+
:global(:root[data-scheme='dark']) .logo-light { display: none; }
|
|
18
|
+
:global(:root[data-scheme='dark']) .logo-dark { display: block; }
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:global(:root:not([data-scheme='light'])) .logo-light { display: none; }
|
|
21
|
+
:global(:root:not([data-scheme='light'])) .logo-dark { display: block; }
|
|
22
|
+
}
|
|
23
|
+
</style>
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -30,7 +30,7 @@ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
|
|
|
30
30
|
sessionUserId && sessionUserId !== OWNER_ENV ? repo.getUser(sessionUserId) : Promise.resolve(null),
|
|
31
31
|
]);
|
|
32
32
|
// pending booking requests, shown as a count on the Bookings nav item
|
|
33
|
-
const pendingBookings =
|
|
33
|
+
const pendingBookings = CALENDAR.enabled ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'], ...(!bookingsEnabled() ? { kinds: ['rsvp'] as const as ['rsvp'] } : {}) })).length : 0;
|
|
34
34
|
const me = {
|
|
35
35
|
name: sessionUser?.name || 'Admin',
|
|
36
36
|
role: roleLabels[sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'],
|
|
@@ -101,6 +101,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
101
101
|
items: [
|
|
102
102
|
...collectionItems,
|
|
103
103
|
{ key: 'pages', label: 'Pages', href: '/admin/pages', icon: icons.pages, count: pages.length },
|
|
104
|
+
{ key: 'forms', label: 'Forms', href: '/admin/forms', icon: icons.pages, count: null },
|
|
104
105
|
{ key: 'tags', label: 'Tags', href: '/admin/tags', icon: icons.tags, count: null },
|
|
105
106
|
{ key: 'media', label: 'Media', href: '/admin/media', icon: icons.media, count: null },
|
|
106
107
|
],
|
|
@@ -114,7 +115,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
114
115
|
// people, booking links). Sub-pages highlight their home.
|
|
115
116
|
items: [
|
|
116
117
|
{ key: 'calendar:schedule', label: 'Schedule', href: '/admin/calendar', icon: icons.calendar, count: null },
|
|
117
|
-
...(
|
|
118
|
+
...(CALENDAR.enabled
|
|
118
119
|
? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
|
|
119
120
|
: []),
|
|
120
121
|
],
|
package/src/layouts/Site.astro
CHANGED
|
@@ -15,6 +15,7 @@ import { getTheme } from '../lib/theme';
|
|
|
15
15
|
import { analyticsHead, analyticsBody } from '../lib/analytics';
|
|
16
16
|
import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
|
|
17
17
|
import type { SiteChrome } from '../lib/site-chrome';
|
|
18
|
+
import SiteLogo from '../components/site/SiteLogo.astro';
|
|
18
19
|
import '../styles/site.css';
|
|
19
20
|
import '../styles/cards.css';
|
|
20
21
|
import '../styles/live.css';
|
|
@@ -144,7 +145,7 @@ const langName = (l: string) => {
|
|
|
144
145
|
};
|
|
145
146
|
const chromeLink = (n: { label: string; url: string }) => ({ label: n.label, url: lnk(n.url), current: isCurrent(lnk(n.url)), external: isExternal(n.url) });
|
|
146
147
|
const chrome: SiteChrome = {
|
|
147
|
-
title: settings.siteTitle, tagline: settings.tagline, logo: settings.siteLogo,
|
|
148
|
+
title: settings.siteTitle, tagline: settings.tagline, logo: settings.siteLogo, logoDark: settings.siteLogoDark,
|
|
148
149
|
homeUrl: reqLocale ? localeHomeUrl(reqLocale) : '/',
|
|
149
150
|
links: headerLinks.map(chromeLink),
|
|
150
151
|
footerLinks: footerNav.map(chromeLink),
|
|
@@ -186,7 +187,7 @@ const homeJsonLd =
|
|
|
186
187
|
---
|
|
187
188
|
|
|
188
189
|
<!doctype html>
|
|
189
|
-
<html lang={htmlLang}>
|
|
190
|
+
<html lang={htmlLang} data-scheme={tokens.scheme === 'auto' ? undefined : tokens.scheme}>
|
|
190
191
|
<head>
|
|
191
192
|
<meta charset="utf-8" />
|
|
192
193
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -276,7 +277,7 @@ const homeJsonLd =
|
|
|
276
277
|
<header class="site-header" data-style={tokens.header}>
|
|
277
278
|
<div class="site-header-in">
|
|
278
279
|
<a class="brand" href={reqLocale ? localeHomeUrl(reqLocale) : '/'}>
|
|
279
|
-
{settings.
|
|
280
|
+
<SiteLogo title={settings.siteTitle} logo={settings.siteLogo} logoDark={settings.siteLogoDark} />
|
|
280
281
|
</a>
|
|
281
282
|
<nav class="site-nav" aria-label="Main">
|
|
282
283
|
{headerLinks.map((n) => (
|
|
@@ -677,7 +678,7 @@ const homeJsonLd =
|
|
|
677
678
|
{tokens.footer === 'columns' ? (
|
|
678
679
|
<div class="footer-cols">
|
|
679
680
|
<div class="footer-brand">
|
|
680
|
-
<a class="brand" href="/"
|
|
681
|
+
<a class="brand" href="/"><SiteLogo title={settings.siteTitle} logo={settings.siteLogo} logoDark={settings.siteLogoDark} /></a>
|
|
681
682
|
{settings.tagline && <p class="footer-blurb">{settings.tagline}</p>}
|
|
682
683
|
{socialNav.length > 0 && (
|
|
683
684
|
<nav class="social-nav" aria-label="Social">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { SavedForm, FormSubmission } from '../forms/schema';
|
|
3
|
+
import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking, Place } from '../schema';
|
|
3
4
|
import type { ContentRepository } from '../repo/types';
|
|
4
5
|
|
|
5
6
|
// backups from before booking approval modes: a link that didn't confirm on
|
|
@@ -10,12 +11,15 @@ const legacyApproval = (l: unknown) =>
|
|
|
10
11
|
export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
|
|
11
12
|
export type BookingRequest = z.infer<typeof BookingRequest>;
|
|
12
13
|
export const BackupContent = z.object({
|
|
13
|
-
format: z.literal('acms-backup'), version: z.number().int().min(1).max(
|
|
14
|
+
format: z.literal('acms-backup'), version: z.number().int().min(1).max(6), exportedAt: z.string().optional(),
|
|
14
15
|
settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
|
|
15
16
|
tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
|
|
16
17
|
calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
|
|
17
18
|
bookingLinks: z.array(z.preprocess(legacyApproval, BookingLink)).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
|
|
19
|
+
places: z.array(Place).default([]),
|
|
20
|
+
forms: z.array(SavedForm).optional(), formSubmissions: z.array(FormSubmission).optional(),
|
|
18
21
|
}).superRefine((data, ctx) => {
|
|
22
|
+
if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
|
|
19
23
|
if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
|
|
20
24
|
const keys = new Set<string>();
|
|
21
25
|
for (const r of data.bookingRequests ?? []) {
|
|
@@ -27,15 +31,16 @@ export const BackupContent = z.object({
|
|
|
27
31
|
}
|
|
28
32
|
});
|
|
29
33
|
export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
30
|
-
const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState] = await Promise.all([
|
|
34
|
+
const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState, places, forms, formSubmissions] = await Promise.all([
|
|
31
35
|
repo.getSettings(), repo.listPages(), repo.listPosts(), repo.listMedia(), repo.listLinks(), repo.listTags(), repo.listNavItems(),
|
|
32
36
|
repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
|
|
37
|
+
repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
|
|
33
38
|
]);
|
|
34
39
|
return {
|
|
35
|
-
format: 'acms-backup' as const, version:
|
|
40
|
+
format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
|
|
36
41
|
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
|
|
37
42
|
pages, posts, media, links, tags, navItems, users, redirects,
|
|
38
43
|
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
39
|
-
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState,
|
|
44
|
+
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions,
|
|
40
45
|
};
|
|
41
46
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectResponse, displayAnswers } from '../forms/service';
|
|
1
2
|
// Booking workflows shared by the public booking pages, the admin, the REST
|
|
2
3
|
// API and agent tools, so every path validates, notifies and fires webhooks
|
|
3
4
|
// the same way. Auto-confirm links confirm on the spot (and show on the
|
|
@@ -101,7 +102,7 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
|
|
|
101
102
|
id: b.id, kind: b.kind, status: b.status,
|
|
102
103
|
start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(),
|
|
103
104
|
resourceId: b.resourceId || null, linkId: b.linkId || null, eventId: b.eventId || null,
|
|
104
|
-
name: b.name, email: b.email, phone: b.phone, seats: b.seats, answers: b.answers,
|
|
105
|
+
name: b.name, email: b.email, phone: b.phone, seats: b.seats, answers: b.answers, formResponse: b.formResponse,
|
|
105
106
|
source: b.source, createdAt: new Date(b.createdAt).toISOString(), ...extra,
|
|
106
107
|
};
|
|
107
108
|
}
|
|
@@ -140,6 +141,7 @@ export interface BookerInput {
|
|
|
140
141
|
email: string;
|
|
141
142
|
phone?: string;
|
|
142
143
|
answers?: Record<string, string>;
|
|
144
|
+
formVersion?: string;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'> | null, input: BookerInput): { name: string; email: string; phone: string; answers: Record<string, string> } {
|
|
@@ -217,10 +219,12 @@ export async function createRsvp(
|
|
|
217
219
|
if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
|
|
218
220
|
const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
|
|
219
221
|
const booker = validateBooker(null, args.booker);
|
|
222
|
+
const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
|
|
223
|
+
if (formResponse) booker.answers = displayAnswers(formResponse);
|
|
220
224
|
const b = await repo.createBooking({
|
|
221
225
|
kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
|
|
222
226
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
223
|
-
notes: '', manageToken: token(), source: args.source ?? 'web',
|
|
227
|
+
formResponse, notes: '', manageToken: token(), source: args.source ?? 'web',
|
|
224
228
|
}, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
|
|
225
229
|
if (!b) throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
|
|
226
230
|
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
|
|
@@ -12,7 +12,13 @@ const eventFields = {
|
|
|
12
12
|
allDay: z.boolean().optional(),
|
|
13
13
|
timezone: z.string().max(80).optional(),
|
|
14
14
|
rrule: z.string().max(500).optional(),
|
|
15
|
-
|
|
15
|
+
// lat/lng pin the map image; placeId links a saved place (its details are
|
|
16
|
+
// copied in); showMap: false hides the map image on the event page
|
|
17
|
+
location: z.object({
|
|
18
|
+
name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(1000).default(''),
|
|
19
|
+
lat: z.number().min(-90).max(90).nullable().optional(), lng: z.number().min(-180).max(180).nullable().optional(),
|
|
20
|
+
placeId: z.string().max(100).optional(), showMap: z.boolean().optional(),
|
|
21
|
+
}).strict().refine((l) => (l.lat === undefined || l.lat === null) === (l.lng === undefined || l.lng === null), { message: 'Give both lat and lng, or neither.' }).optional(),
|
|
16
22
|
cta: z.object({ label: z.string().max(120).default(''), href: z.string().max(1000).default(''), style: z.enum(['primary', 'outline']).default('primary') }).strict().optional(),
|
|
17
23
|
calendars: z.array(z.string().max(100)).max(20).optional(),
|
|
18
24
|
// bookable rooms/people (slugs from the booking resources) this event uses:
|
|
@@ -14,12 +14,35 @@ import { mergeEventDraft } from '../drafts';
|
|
|
14
14
|
import { eventChanged } from './hooks';
|
|
15
15
|
import { eventToJson } from './api';
|
|
16
16
|
import { siteLocale } from '../dates';
|
|
17
|
+
import { EventLocation } from '../schema';
|
|
18
|
+
import { ensureMapImages, mergeLocationInput } from '../maps/service';
|
|
17
19
|
|
|
18
20
|
export type EventToolInput = z.infer<(typeof EventToolInputs)[number]>;
|
|
19
21
|
|
|
20
22
|
export interface AgentContext {
|
|
21
23
|
origin: string;
|
|
22
24
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
25
|
+
// request locals (maps provider + media storage for map images)
|
|
26
|
+
locals?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type LocationArg = NonNullable<Extract<EventToolInput, { tool: 'create_event' }>['arguments']['location']>;
|
|
30
|
+
|
|
31
|
+
// A location from the API/agent: a known placeId copies the saved place's
|
|
32
|
+
// details in (typed fields still win), a pin gets its map image. On update,
|
|
33
|
+
// unchanged text keeps the existing pin (mergeLocationInput).
|
|
34
|
+
async function locationFrom(repo: ContentRepository, a: LocationArg, prev: EventLocation, ctx: AgentContext): Promise<EventLocation> {
|
|
35
|
+
let input: Partial<EventLocation> = { ...a };
|
|
36
|
+
if (a.placeId) {
|
|
37
|
+
const place = await repo.getPlace(a.placeId);
|
|
38
|
+
if (!place) throw new ApiError(400, `Unknown place "${a.placeId}".`);
|
|
39
|
+
input = {
|
|
40
|
+
name: a.name || place.name, address: a.address || place.address, url: a.url || place.url,
|
|
41
|
+
lat: a.lat ?? place.lat, lng: a.lng ?? place.lng, placeId: place.id, showMap: a.showMap,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const loc = mergeLocationInput(prev, input);
|
|
45
|
+
return ensureMapImages(repo, loc, { locals: ctx.locals, origin: ctx.origin });
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
const editUrl = (id: string) => `/admin/calendar/events/${id}`;
|
|
@@ -100,7 +123,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
|
|
|
100
123
|
for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
|
|
101
124
|
const e = await repo.createEvent({
|
|
102
125
|
title: a.title, slug, ...times, allDay: a.allDay ?? a.start.length === 10, timezone: a.timezone ?? '',
|
|
103
|
-
rrule: a.rrule ?? '', excerpt: a.excerpt ?? '',
|
|
126
|
+
rrule: a.rrule ?? '', excerpt: a.excerpt ?? '', cta: a.cta, tags: a.tags,
|
|
127
|
+
...(a.location ? { location: await locationFrom(repo, a.location, EventLocation.parse({}), ctx) } : {}),
|
|
104
128
|
cancelled: a.cancelled ?? false, seoTitle: a.seoTitle, seoDesc: a.seoDesc,
|
|
105
129
|
calendarIds: (await resolveCalendars(repo, a.calendars)) ?? [],
|
|
106
130
|
...(a.rooms ? { roomHold: await resolveRooms(repo, a.rooms) } : {}),
|
|
@@ -110,11 +134,12 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
|
|
|
110
134
|
return { ...summary(e) };
|
|
111
135
|
}
|
|
112
136
|
case 'update_event': {
|
|
113
|
-
const { id, content, calendars: slugs, rooms, ...rest } = input.arguments;
|
|
114
|
-
checkLinks(rest);
|
|
137
|
+
const { id, content, calendars: slugs, rooms, location, ...rest } = input.arguments;
|
|
138
|
+
checkLinks({ ...rest, location });
|
|
115
139
|
const before = await repo.getEvent(id);
|
|
116
140
|
if (!before) throw new ApiError(404, 'Event not found.');
|
|
117
141
|
const data: Partial<CalEvent> = { ...rest };
|
|
142
|
+
if (location) data.location = await locationFrom(repo, location, mergeEventDraft(before).location, ctx);
|
|
118
143
|
if (content !== undefined) {
|
|
119
144
|
data.bodyHtml = renderAgentMarkdown(content);
|
|
120
145
|
data.bodyJson = '';
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface EventJson {
|
|
|
25
25
|
exdates: string[];
|
|
26
26
|
location: CalEvent['location'];
|
|
27
27
|
cta: CalEvent['cta'];
|
|
28
|
+
rsvp?: CalEvent['rsvp'];
|
|
28
29
|
image: string;
|
|
29
30
|
imageAlt: string;
|
|
30
31
|
tags: string[];
|
|
@@ -55,6 +56,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
55
56
|
exdates: e.exdates,
|
|
56
57
|
location: e.location,
|
|
57
58
|
cta: e.cta,
|
|
59
|
+
rsvp: e.rsvp,
|
|
58
60
|
image: e.cover ? abs(origin, e.cover) : '',
|
|
59
61
|
imageAlt: e.coverAlt,
|
|
60
62
|
tags: e.tags.filter((t) => !t.startsWith('#')),
|
|
@@ -92,6 +92,8 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
|
|
|
92
92
|
lines.push(`SUMMARY:${escapeText(e.title || 'Untitled event')}`);
|
|
93
93
|
const loc = locationLine(e.location);
|
|
94
94
|
if (loc) lines.push(`LOCATION:${escapeText(loc)}`);
|
|
95
|
+
// RFC 5545 GEO: latitude;longitude
|
|
96
|
+
if (typeof e.location.lat === 'number' && typeof e.location.lng === 'number') lines.push(`GEO:${e.location.lat.toFixed(6)};${e.location.lng.toFixed(6)}`);
|
|
95
97
|
const desc = [e.excerpt, e.location.url && e.location.url !== loc ? e.location.url : '', url].filter(Boolean).join('\n\n');
|
|
96
98
|
lines.push(`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`);
|
|
97
99
|
const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
|
package/src/lib/calendar/ics.ts
CHANGED
|
@@ -55,6 +55,7 @@ const DAY = 86_400_000;
|
|
|
55
55
|
/* ---------------- timezone math (no dependencies) ---------------- */
|
|
56
56
|
|
|
57
57
|
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
|
58
|
+
const FORMATTER_CACHE_MAX = 64;
|
|
58
59
|
function tzFormatter(tz: string): Intl.DateTimeFormat | null {
|
|
59
60
|
let f = fmtCache.get(tz);
|
|
60
61
|
if (f) return f;
|
|
@@ -68,6 +69,9 @@ function tzFormatter(tz: string): Intl.DateTimeFormat | null {
|
|
|
68
69
|
return null; // unknown TZID (Outlook's "Customized Time Zone" etc.)
|
|
69
70
|
}
|
|
70
71
|
fmtCache.set(tz, f);
|
|
72
|
+
// Intl formatters retain native ICU data; timezone aliases/case variants
|
|
73
|
+
// must not accumulate for the lifetime of a self-hosted server.
|
|
74
|
+
if (fmtCache.size > FORMATTER_CACHE_MAX) fmtCache.delete(fmtCache.keys().next().value!);
|
|
71
75
|
return f;
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// import action saves as DRAFTS — nothing goes live without a review.
|
|
4
4
|
// Recurring ICS events keep their RRULE/EXDATEs; per-instance overrides
|
|
5
5
|
// (RECURRENCE-ID) are skipped, since a native event can't hold them yet.
|
|
6
|
-
import
|
|
6
|
+
import { EventLocation } from '../schema';
|
|
7
7
|
import { parseICS, tzParts, type IcsTime, type VEvent } from './ics';
|
|
8
8
|
import { isValidTimeZone, normalizeEventTimes, parseWall, addWallDays, formatWall } from './events';
|
|
9
9
|
import { cleanRrule } from './rrule';
|
|
@@ -78,7 +78,7 @@ export function eventsFromVEvents(vevents: VEvent[], calTz: string, siteTz: stri
|
|
|
78
78
|
exdates: v.exdates.map((t) => wallOf(t, siteTz).wall),
|
|
79
79
|
excerpt: v.description.slice(0, 300),
|
|
80
80
|
bodyHtml: paragraphs(v.description),
|
|
81
|
-
location: { name: v.location.slice(0, 300)
|
|
81
|
+
location: EventLocation.parse({ name: v.location.slice(0, 300) }),
|
|
82
82
|
cancelled: v.cancelled,
|
|
83
83
|
sourceUid: v.uid,
|
|
84
84
|
});
|
|
@@ -192,7 +192,7 @@ export function eventsFromCsv(text: string): { events: ImportedEvent[]; skipped:
|
|
|
192
192
|
events.push({
|
|
193
193
|
title: title.slice(0, 300), ...times, allDay, timezone: '', rrule: '', exdates: [],
|
|
194
194
|
excerpt: desc.slice(0, 300), bodyHtml: paragraphs(desc),
|
|
195
|
-
location: { name: get(r, 'location').slice(0, 300), address: get(r, 'address').slice(0, 300), url: get(r, 'url').slice(0, 500) },
|
|
195
|
+
location: EventLocation.parse({ name: get(r, 'location').slice(0, 300), address: get(r, 'address').slice(0, 300), url: get(r, 'url').slice(0, 500) }),
|
|
196
196
|
cancelled: false, sourceUid: '',
|
|
197
197
|
});
|
|
198
198
|
} catch {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// the editor's "stage until Update" flow is for content edits, not dates.
|
|
6
6
|
import type { ContentRepository } from '../repo/types';
|
|
7
7
|
import type { CalEvent } from '../schema';
|
|
8
|
-
import { slugify } from '../schema';
|
|
8
|
+
import { EventLocation, slugify } from '../schema';
|
|
9
|
+
import { defaultPlaceLocation } from '../maps/places';
|
|
9
10
|
import { ApiError } from '../api';
|
|
10
11
|
import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
|
|
11
12
|
import { addWallDays, formatWall, parseWall } from './events';
|
|
@@ -49,10 +50,13 @@ export interface QuickEvent {
|
|
|
49
50
|
export async function quickCreate(repo: ContentRepository, q: QuickEvent, hooks: SaveHooks): Promise<CalEvent> {
|
|
50
51
|
const title = q.title.trim().slice(0, 300) || 'Untitled event';
|
|
51
52
|
const cal = q.calendarId ? await repo.getCalendar(q.calendarId) : null;
|
|
53
|
+
// no place typed → the site's default place (if one is set)
|
|
54
|
+
const typed = q.location.trim().slice(0, 300);
|
|
55
|
+
const location = typed ? EventLocation.parse({ name: typed }) : (await defaultPlaceLocation(repo)) ?? EventLocation.parse({});
|
|
52
56
|
const created = await repo.createEvent({
|
|
53
57
|
title, slug: await uniqueSlug(repo, title), start: q.start, allDay: q.allDay, status: 'draft',
|
|
54
58
|
calendarIds: cal?.kind === 'native' ? [cal.id] : [],
|
|
55
|
-
location
|
|
59
|
+
location,
|
|
56
60
|
});
|
|
57
61
|
if (q.roomIds?.length) await saveEvent(repo, created.id, { roomHold: { resourceIds: q.roomIds, span: 'event', before: 0, after: 0, whileDraft: false, keep: [] } }, hooks);
|
|
58
62
|
// times + repeat go through the editor's validation (end defaults, pairs)
|
package/src/lib/calendar/seo.ts
CHANGED
|
@@ -24,8 +24,11 @@ export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | '
|
|
|
24
24
|
return `${p.y}-${pad(p.m)}-${pad(p.d)}`;
|
|
25
25
|
};
|
|
26
26
|
const online = Boolean(e.location.url && isMeetingUrl(e.location.url));
|
|
27
|
+
const pin = typeof e.location.lat === 'number' && typeof e.location.lng === 'number'
|
|
28
|
+
? { geo: { '@type': 'GeoCoordinates', latitude: e.location.lat, longitude: e.location.lng } }
|
|
29
|
+
: {};
|
|
27
30
|
const place = e.location.name || e.location.address
|
|
28
|
-
? { '@type': 'Place', name: e.location.name || e.location.address, ...(e.location.address ? { address: e.location.address } : {}) }
|
|
31
|
+
? { '@type': 'Place', name: e.location.name || e.location.address, ...(e.location.address ? { address: e.location.address } : {}), ...pin }
|
|
29
32
|
: null;
|
|
30
33
|
const virtual = online ? { '@type': 'VirtualLocation', url: e.location.url } : null;
|
|
31
34
|
const location = place && virtual ? [place, virtual] : place ?? virtual;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContentRepository } from './repo/types';
|
|
2
|
-
import
|
|
2
|
+
import { FormDefinition } from './forms/schema';
|
|
3
|
+
import type { CalEvent, EventLocation, Page, Post, WidgetInstance } from './schema';
|
|
3
4
|
import { slugify } from './schema';
|
|
4
5
|
import { ApiError } from './api';
|
|
5
6
|
import { CONTENT, collectionUrl } from './content';
|
|
@@ -9,6 +10,8 @@ import { eventChanged } from './calendar/hooks';
|
|
|
9
10
|
import { normalizePageLocale } from './i18n';
|
|
10
11
|
import { siteOrigin } from './seo';
|
|
11
12
|
import { waitUntilFrom } from './webhooks';
|
|
13
|
+
import { mergeEventDraft } from './drafts';
|
|
14
|
+
import { ensureMapImages, mergeLocationInput } from './maps/service';
|
|
12
15
|
|
|
13
16
|
// Saving posts, pages and events: the one implementation behind the admin
|
|
14
17
|
// editor (astro:actions) and the editor-app API (/api/v1/admin/documents).
|
|
@@ -27,7 +30,12 @@ export interface SaveHooks {
|
|
|
27
30
|
|
|
28
31
|
export type PostSave = Parameters<ContentRepository['updatePost']>[1];
|
|
29
32
|
export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blocks'>;
|
|
30
|
-
|
|
33
|
+
// location may be partial: missing pin fields are filled from the current
|
|
34
|
+
// location (see mergeLocationInput) — older clients send name/address/url only
|
|
35
|
+
export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location'>> & {
|
|
36
|
+
location?: Partial<EventLocation>;
|
|
37
|
+
};
|
|
38
|
+
type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
|
|
31
39
|
|
|
32
40
|
const live = (status: string) => status === 'published' || status === 'scheduled';
|
|
33
41
|
|
|
@@ -107,8 +115,21 @@ export async function applyPageUpdate(repo: ContentRepository, id: string, hooks
|
|
|
107
115
|
|
|
108
116
|
// Validate + normalize an event save. Times are checked as a PAIR against the
|
|
109
117
|
// merged (staged) state so a half-edited range can't slip through.
|
|
110
|
-
export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<
|
|
111
|
-
const
|
|
118
|
+
export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<EventClean> {
|
|
119
|
+
const { location, ...rest } = data;
|
|
120
|
+
const out: EventClean = { ...rest };
|
|
121
|
+
// Older editor clients resend the original RSVP shape. Omission must not
|
|
122
|
+
// erase questions that were authored in a newer editor.
|
|
123
|
+
if (out.rsvp && !Object.hasOwn(out.rsvp, 'form')) {
|
|
124
|
+
const previous = mergeEventDraft(before).rsvp.form;
|
|
125
|
+
if (previous) out.rsvp = { ...out.rsvp, form: previous };
|
|
126
|
+
}
|
|
127
|
+
if (out.rsvp?.form !== undefined) {
|
|
128
|
+
const form = FormDefinition.safeParse(out.rsvp.form);
|
|
129
|
+
if (!form.success) throw new ApiError(400, form.error.issues[0]?.message || 'Invalid registration form.');
|
|
130
|
+
out.rsvp = { ...out.rsvp, form: form.data };
|
|
131
|
+
}
|
|
132
|
+
if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
|
|
112
133
|
if (out.slug !== undefined) {
|
|
113
134
|
const base = slugify(out.slug || out.title || before.title);
|
|
114
135
|
let slug = base;
|
|
@@ -178,6 +199,11 @@ export async function saveEvent(repo: ContentRepository, id: string, data: Event
|
|
|
178
199
|
const before = await repo.getEvent(id);
|
|
179
200
|
if (!before) throw new ApiError(404, 'Event not found.');
|
|
180
201
|
const clean = await cleanEventInput(repo, data, before);
|
|
202
|
+
// static map images follow the pin (fetched once per pin, never blocking)
|
|
203
|
+
if (clean.location) {
|
|
204
|
+
const settings = await repo.getSettings();
|
|
205
|
+
clean.location = await ensureMapImages(repo, clean.location, { locals: hooks.locals, origin: siteOrigin(settings, hooks.url) });
|
|
206
|
+
}
|
|
181
207
|
// live events stage edits until Update, exactly like posts
|
|
182
208
|
const transitions = clean.status !== undefined && clean.status !== before.status;
|
|
183
209
|
if (live(before.status) && !transitions) {
|
|
@@ -15,7 +15,7 @@ import { ThemeTokens } from '../theme-sdk/contract.mjs';
|
|
|
15
15
|
export const DESIGN_SCOPES = ['design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write'] as const;
|
|
16
16
|
export const DesignPatch = z.object({
|
|
17
17
|
activeTheme: z.string().max(50).optional(), designOverrides: z.record(z.union([z.string().max(2000), z.number().finite()])).optional(),
|
|
18
|
-
siteLogo: z.string().max(2000).optional(), siteFavicon: z.string().max(2000).optional(), siteCover: z.string().max(2000).optional(),
|
|
18
|
+
siteLogo: z.string().max(2000).optional(), siteLogoDark: z.string().max(2000).optional(), siteFavicon: z.string().max(2000).optional(), siteCover: z.string().max(2000).optional(),
|
|
19
19
|
sectionPages: SectionPages.optional(),
|
|
20
20
|
navItems: z.array(z.object({ id:z.string().max(100),label:z.string().max(200),url:z.string().max(2000),area:z.enum(['header','footer','social']) }).strict()).max(200).optional(),
|
|
21
21
|
}).strict();
|
|
@@ -93,7 +93,7 @@ export function validateDesignValues(patch: z.infer<typeof DesignPatch>) {
|
|
|
93
93
|
if(key==='homeMode'&&!['auto','content',''].includes(String(value)))throw new ApiError(400,'Invalid home mode.');
|
|
94
94
|
if (typeof value === 'string' && /[;{}<>]|url\s*\(|@import/i.test(value)) throw new ApiError(400,'Design overrides accept token values, not CSS or HTML.');
|
|
95
95
|
}
|
|
96
|
-
for (const value of [patch.siteLogo,patch.siteFavicon,patch.siteCover,...(patch.navItems??[]).map(n=>n.url)]) {
|
|
96
|
+
for (const value of [patch.siteLogo,patch.siteLogoDark,patch.siteFavicon,patch.siteCover,...(patch.navItems??[]).map(n=>n.url)]) {
|
|
97
97
|
if(value && !/^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(value.trim())) throw new ApiError(400,'Use an HTTP(S) or site-relative URL.');
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/lib/designDiff.ts
CHANGED
|
@@ -39,7 +39,7 @@ const VALUE_LABELS: Record<string, string> = {
|
|
|
39
39
|
content: 'Content page',
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const IMAGE_LABELS: Record<string, string> = { siteLogo: 'Logo', siteFavicon: 'Favicon', siteCover: 'Share image' };
|
|
42
|
+
const IMAGE_LABELS: Record<string, string> = { siteLogo: 'Logo', siteLogoDark: 'Logo (dark mode)', siteFavicon: 'Favicon', siteCover: 'Share image' };
|
|
43
43
|
const AREA_LABELS: Record<NavItem['area'], string> = { header: 'Header menu', footer: 'Footer menu', social: 'Social links' };
|
|
44
44
|
|
|
45
45
|
const show = (v: unknown, key?: string): string => {
|
|
@@ -97,7 +97,7 @@ export function diffDesign(
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
for (const k of ['siteLogo', 'siteFavicon', 'siteCover'] as const) {
|
|
100
|
+
for (const k of ['siteLogo', 'siteLogoDark', 'siteFavicon', 'siteCover'] as const) {
|
|
101
101
|
if (staged[k] !== undefined && staged[k] !== live[k]) {
|
|
102
102
|
out.push({ key: k, label: IMAGE_LABELS[k], kind: 'image', from: live[k] || '', to: staged[k] || '' });
|
|
103
103
|
}
|
package/src/lib/drafts.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CalEvent, NavItem, Page, Post, Settings } from './schema';
|
|
2
|
+
import { EventLocation } from './schema';
|
|
2
3
|
|
|
3
4
|
// Pending-edit ("draft") snapshots for live documents. A published or
|
|
4
5
|
// scheduled post/page never receives content edits directly: they accumulate
|
|
@@ -44,7 +45,14 @@ export function pickPostDraft(draft: Record<string, unknown>): Partial<Pick<Post
|
|
|
44
45
|
return pick(draft, POST_DRAFT_KEYS) as Partial<Pick<Post, PostDraftKey>>;
|
|
45
46
|
}
|
|
46
47
|
export function pickEventDraft(draft: Record<string, unknown>): Partial<Pick<CalEvent, EventDraftKey>> {
|
|
47
|
-
|
|
48
|
+
const out = pick(draft, EVENT_DRAFT_KEYS) as Partial<Pick<CalEvent, EventDraftKey>>;
|
|
49
|
+
// snapshots staged before maps support lack the pin fields: fill defaults
|
|
50
|
+
// so merged events always carry a complete location
|
|
51
|
+
if (out.location !== undefined) {
|
|
52
|
+
const loc = EventLocation.safeParse(out.location);
|
|
53
|
+
if (loc.success) out.location = loc.data; else delete out.location;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
48
56
|
}
|
|
49
57
|
export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
|
|
50
58
|
return pick(draft, PAGE_DRAFT_KEYS) as Partial<Pick<Page, PageDraftKey>>;
|
|
@@ -79,7 +87,7 @@ export function draftSavedAt(doc: Pick<Post | Page | CalEvent, 'draft'>): number
|
|
|
79
87
|
// ordered navigation list (navItems) — menus stage with the design so one
|
|
80
88
|
// Publish changes the whole look at once — and the share-link epoch.
|
|
81
89
|
export const DESIGN_DRAFT_KEYS = [
|
|
82
|
-
'activeTheme', 'designOverrides', 'siteLogo', 'siteFavicon', 'siteCover', 'sectionPages',
|
|
90
|
+
'activeTheme', 'designOverrides', 'siteLogo', 'siteLogoDark', 'siteFavicon', 'siteCover', 'sectionPages',
|
|
83
91
|
] as const;
|
|
84
92
|
|
|
85
93
|
type DesignDraftKey = (typeof DESIGN_DRAFT_KEYS)[number];
|
|
@@ -124,6 +132,7 @@ export function designSnapshot(settings: Settings, navItems: NavItem[]): Record<
|
|
|
124
132
|
designOverrides: settings.designOverrides,
|
|
125
133
|
sectionPages: settings.sectionPages,
|
|
126
134
|
siteLogo: settings.siteLogo,
|
|
135
|
+
siteLogoDark: settings.siteLogoDark,
|
|
127
136
|
siteFavicon: settings.siteFavicon,
|
|
128
137
|
siteCover: settings.siteCover,
|
|
129
138
|
navItems: navItems.map(({ id, label, url, area }) => ({ id, label, url, area })),
|
package/src/lib/dynamicBlocks.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { renderEventCards, type EventCardAttrs } from './calendar/cards';
|
|
|
19
19
|
// <div data-dyn="events" data-mode="upcoming" data-calendar="youth"
|
|
20
20
|
// data-event="" data-limit="3" data-layout="grid" data-show-image="1"></div>
|
|
21
21
|
|
|
22
|
-
const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc|events)"[^>]*>\s*<\/div>/g;
|
|
22
|
+
const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc|events|form)"[^>]*>\s*<\/div>/g;
|
|
23
23
|
|
|
24
24
|
const esc = (s: string) =>
|
|
25
25
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
@@ -133,11 +133,12 @@ function eventCardAttrs(tag: string): EventCardAttrs {
|
|
|
133
133
|
|
|
134
134
|
export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
|
|
135
135
|
html = stampHeadingAnchors(html);
|
|
136
|
+
const hasForms = html.includes('data-dyn="form"');
|
|
136
137
|
const hasPostList = html.includes('data-dyn="post-list"');
|
|
137
138
|
const hasCalendar = html.includes('data-dyn="calendar"');
|
|
138
139
|
const hasToc = html.includes('data-dyn="toc"');
|
|
139
140
|
const hasEvents = html.includes('data-dyn="events"');
|
|
140
|
-
if (!hasPostList && !hasCalendar && !hasToc && !hasEvents) return html;
|
|
141
|
+
if (!hasPostList && !hasCalendar && !hasToc && !hasEvents && !hasForms) return html;
|
|
141
142
|
const [posts, settings] = await Promise.all([
|
|
142
143
|
hasPostList ? repo.listPublishedPosts() : Promise.resolve([] as Post[]), // newest first; shared by all blocks on the page
|
|
143
144
|
repo.getSettings(),
|
|
@@ -160,9 +161,19 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
|
|
|
160
161
|
await Promise.all(tags.map(async (tag) => calendarHtml.set(tag, await renderEventCards(eventCardAttrs(tag), repo, settings, now))));
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
if (hasForms) {
|
|
165
|
+
const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter(t => t.includes('data-dyn="form"')))];
|
|
166
|
+
await Promise.all(tags.map(async tag => {
|
|
167
|
+
const form = await repo.forms.get(attr(tag, 'form'));
|
|
168
|
+
// A same-origin frame gives each visitor an uncached form/receipt even
|
|
169
|
+
// when the containing content page is served from a shared cache.
|
|
170
|
+
calendarHtml.set(tag, form?.kind === 'form' && form.purpose === 'general' && form.status === 'published'
|
|
171
|
+
? `<iframe class="pc-form-embed" src="/forms/${encodeURIComponent(form.id)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
163
174
|
const anchored = html;
|
|
164
175
|
return html.replace(PLACEHOLDER, (tag) => {
|
|
165
|
-
if (tag.includes('data-dyn="calendar"') || tag.includes('data-dyn="events"')) return calendarHtml.get(tag) ?? '';
|
|
176
|
+
if (tag.includes('data-dyn="form"') || tag.includes('data-dyn="calendar"') || tag.includes('data-dyn="events"')) return calendarHtml.get(tag) ?? '';
|
|
166
177
|
if (tag.includes('data-dyn="toc"')) return renderToc(anchored, attr(tag, 'title'));
|
|
167
178
|
const source = attr(tag, 'source') || 'latest';
|
|
168
179
|
const tagName = attr(tag, 'tag');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormDefinition } from '../forms/schema';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import type { ContentRepository } from '../repo/types';
|
|
3
4
|
import type { CalEvent, DocTranslations, Page, Post } from '../schema';
|
|
@@ -10,6 +11,7 @@ import { draftSavedAt, mergeEventDraft, mergePageDraft, mergePostDraft, pickEven
|
|
|
10
11
|
import { localeHomeUrl } from '../i18n';
|
|
11
12
|
import { pageQuery } from '../mobile/http';
|
|
12
13
|
import { applyEventUpdate, applyPageUpdate, applyPostUpdate, saveEvent, savePage, savePost, type SaveHooks } from '../content-service';
|
|
14
|
+
import { defaultPlaceLocation } from '../maps/places';
|
|
13
15
|
|
|
14
16
|
// Posts, pages and events for editor apps. Reads show the document as it
|
|
15
17
|
// will look after Update (staged edits merged); writes go through the same
|
|
@@ -134,7 +136,9 @@ export async function createDoc(repo: ContentRepository, input: z.infer<typeof C
|
|
|
134
136
|
catch (err) { throw new ApiError(400, err instanceof Error ? err.message : 'Invalid start time.'); }
|
|
135
137
|
let slug = base;
|
|
136
138
|
for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
|
|
137
|
-
|
|
139
|
+
// new events start at the site's default place, like the admin
|
|
140
|
+
const place = await defaultPlaceLocation(repo);
|
|
141
|
+
return repo.createEvent({ title: input.title, slug, start: times.start, end: times.end, allDay: input.allDay ?? !input.start.includes('T'), ...(place ? { location: place } : {}) });
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
// ---- save ----------------------------------------------------------------
|
|
@@ -148,10 +152,15 @@ export const PostFields = z.object({ ...common, excerpt: text(5000), tags: z.arr
|
|
|
148
152
|
export const PageFields = z.object({ ...common, isHome: z.boolean(), locale: text(20) }).partial().strict();
|
|
149
153
|
export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.array(text(100)).max(100), cover: text(2000), coverAlt: text(1000), featured: z.boolean(),
|
|
150
154
|
start: text(40), end: text(40), allDay: z.boolean(), timezone: text(100), rrule: text(500), exdates: z.array(text(40)).max(500), cancelled: z.boolean(),
|
|
151
|
-
location: z.object({
|
|
155
|
+
location: z.object({
|
|
156
|
+
name: text(300), address: text(500), url: text(1000),
|
|
157
|
+
// optional pin fields; omitted = kept while the text is unchanged
|
|
158
|
+
lat: z.number().min(-90).max(90).nullable(), lng: z.number().min(-180).max(180).nullable(),
|
|
159
|
+
placeId: text(100), showMap: z.boolean(), mapImage: text(500), mapImageDark: text(500),
|
|
160
|
+
}).partial({ lat: true, lng: true, placeId: true, showMap: true, mapImage: true, mapImageDark: true }).strict(),
|
|
152
161
|
cta: z.object({ label: text(120), href: text(1000), style: z.enum(['primary', 'outline']) }).strict(),
|
|
153
162
|
calendarIds: z.array(text(100)).max(50), fields: z.record(text(2000)),
|
|
154
|
-
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50) }).strict(),
|
|
163
|
+
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50), form: FormDefinition.optional() }).strict(),
|
|
155
164
|
}).partial().strict();
|
|
156
165
|
export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
|
|
157
166
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { FormDefinition } from '../forms/schema';
|
|
2
3
|
import { Booking } from '../schema';
|
|
3
4
|
|
|
4
5
|
// Response shapes of the editor-app API (OpenAPI components, client types).
|
|
@@ -40,8 +41,8 @@ export const EditorDocument = EditorDocumentSummary.extend({
|
|
|
40
41
|
authorIds: z.array(str), locale: str.nullable(), isHome: bool, publishedAt: date.nullable(), scheduledAt: date.nullable(), createdAt: date,
|
|
41
42
|
event: z.object({
|
|
42
43
|
start: str, end: str, allDay: bool, timezone: str, rrule: str, exdates: z.array(str), cancelled: bool,
|
|
43
|
-
location: z.object({ name: str, address: str, url: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
|
|
44
|
-
calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num }),
|
|
44
|
+
location: z.object({ name: str, address: str, url: str, lat: num.nullable(), lng: num.nullable(), placeId: str, showMap: bool, mapImage: str, mapImageDark: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
|
|
45
|
+
calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, form: FormDefinition.optional() }),
|
|
45
46
|
}).nullable(),
|
|
46
47
|
languages: z.array(str),
|
|
47
48
|
});
|
|
@@ -79,7 +80,7 @@ export const EditorMedia = z.object({
|
|
|
79
80
|
});
|
|
80
81
|
export const EditorMediaList = z.object({ items: z.array(EditorMedia), pagination: Pagination });
|
|
81
82
|
|
|
82
|
-
export const EditorBooking = Booking.pick({ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, source: true, notes: true })
|
|
83
|
+
export const EditorBooking = Booking.pick({ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, formResponse: true, source: true, notes: true })
|
|
83
84
|
.extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date, updatedAt: date, title: str });
|
|
84
85
|
export const EditorBookingList = z.object({ pending: num.int(), items: z.array(EditorBooking), pagination: Pagination });
|
|
85
86
|
|