@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/cli/remote.mjs +1 -1
  3. package/docs/theme-authoring.md +5 -2
  4. package/integration.mjs +38 -2
  5. package/package.json +1 -1
  6. package/src/actions/index.ts +148 -2
  7. package/src/components/DesignManager.tsx +34 -3
  8. package/src/components/EventPage.astro +25 -5
  9. package/src/components/calendar/BookingsHead.astro +4 -3
  10. package/src/components/calendar/RoomHolds.tsx +13 -8
  11. package/src/components/editor/EventPanel.tsx +9 -383
  12. package/src/components/editor/LivePane.tsx +16 -3
  13. package/src/components/editor/PostEditor.tsx +59 -46
  14. package/src/components/editor/PreviewOverlay.tsx +7 -1
  15. package/src/components/editor/event/EventSheet.tsx +136 -0
  16. package/src/components/editor/event/LocationField.tsx +266 -0
  17. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  18. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  19. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  20. package/src/components/editor/event/Section.tsx +27 -0
  21. package/src/components/editor/event/WhenCard.tsx +217 -0
  22. package/src/components/editor/event/dates.ts +28 -0
  23. package/src/components/editor/event/registration.ts +39 -0
  24. package/src/components/editor/extensions/form.tsx +60 -0
  25. package/src/components/editor/extensions/slash.tsx +5 -0
  26. package/src/components/editor/frameNav.ts +20 -0
  27. package/src/components/forms/Canvas.tsx +149 -0
  28. package/src/components/forms/FormBuildPage.tsx +155 -0
  29. package/src/components/forms/FormBuilder.tsx +283 -0
  30. package/src/components/forms/FormHeader.astro +31 -0
  31. package/src/components/forms/InsertMenu.tsx +77 -0
  32. package/src/components/forms/Inspector.tsx +232 -0
  33. package/src/components/forms/Outline.tsx +93 -0
  34. package/src/components/forms/ResponsesTable.tsx +123 -0
  35. package/src/components/forms/TypeIcon.tsx +24 -0
  36. package/src/components/forms/dnd.ts +3 -0
  37. package/src/components/forms/meta.ts +84 -0
  38. package/src/components/forms/useFormHistory.ts +41 -0
  39. package/src/components/import/ImportExport.tsx +1 -1
  40. package/src/components/site/SiteLogo.astro +23 -0
  41. package/src/layouts/Admin.astro +3 -2
  42. package/src/layouts/Site.astro +5 -4
  43. package/src/lib/backup/content.ts +10 -5
  44. package/src/lib/booking/service.ts +6 -2
  45. package/src/lib/calendar/agent-schema.ts +7 -1
  46. package/src/lib/calendar/agent.ts +28 -3
  47. package/src/lib/calendar/api.ts +2 -0
  48. package/src/lib/calendar/ics-write.ts +2 -0
  49. package/src/lib/calendar/ics.ts +4 -0
  50. package/src/lib/calendar/import.ts +3 -3
  51. package/src/lib/calendar/schedule.ts +6 -2
  52. package/src/lib/calendar/seo.ts +4 -1
  53. package/src/lib/content-service.ts +30 -4
  54. package/src/lib/design-service.ts +2 -2
  55. package/src/lib/designDiff.ts +2 -2
  56. package/src/lib/drafts.ts +11 -2
  57. package/src/lib/dynamicBlocks.ts +14 -3
  58. package/src/lib/editor/documents.ts +12 -3
  59. package/src/lib/editor/schemas.ts +4 -3
  60. package/src/lib/forms/events.ts +40 -0
  61. package/src/lib/forms/export.ts +62 -0
  62. package/src/lib/forms/fields.ts +50 -0
  63. package/src/lib/forms/http.ts +29 -0
  64. package/src/lib/forms/render.ts +11 -0
  65. package/src/lib/forms/schema.ts +56 -0
  66. package/src/lib/forms/service.ts +38 -0
  67. package/src/lib/forms/store.ts +78 -0
  68. package/src/lib/forms/validate.ts +71 -0
  69. package/src/lib/import/bundle.ts +1 -0
  70. package/src/lib/import/export.ts +1 -1
  71. package/src/lib/maps/config.ts +64 -0
  72. package/src/lib/maps/migration.ts +12 -0
  73. package/src/lib/maps/parse.ts +180 -0
  74. package/src/lib/maps/places.ts +109 -0
  75. package/src/lib/maps/providers/geoapify.ts +36 -0
  76. package/src/lib/maps/providers/google.ts +54 -0
  77. package/src/lib/maps/providers/http.ts +96 -0
  78. package/src/lib/maps/providers/index.ts +37 -0
  79. package/src/lib/maps/providers/locationiq.ts +35 -0
  80. package/src/lib/maps/providers/mapbox.ts +38 -0
  81. package/src/lib/maps/providers/osm.ts +59 -0
  82. package/src/lib/maps/providers/pelias.ts +51 -0
  83. package/src/lib/maps/service.ts +281 -0
  84. package/src/lib/maps/types.ts +35 -0
  85. package/src/lib/mobile/bookings.ts +1 -1
  86. package/src/lib/mobile/schemas.ts +3 -3
  87. package/src/lib/mobile/site.ts +1 -1
  88. package/src/lib/repo/migrations.ts +11 -0
  89. package/src/lib/repo/sql.ts +104 -14
  90. package/src/lib/repo/types.ts +22 -2
  91. package/src/lib/schema.ts +39 -0
  92. package/src/lib/site-chrome.ts +1 -0
  93. package/src/lib/umami.ts +8 -1
  94. package/src/pages/admin/calendar/bookings.astro +12 -7
  95. package/src/pages/admin/calendar/events/[id].astro +5 -2
  96. package/src/pages/admin/calendar/events/index.astro +3 -0
  97. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  98. package/src/pages/admin/calendar/settings.astro +140 -3
  99. package/src/pages/admin/forms/[id]/share.astro +121 -0
  100. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  101. package/src/pages/admin/forms/[id].astro +27 -0
  102. package/src/pages/admin/forms/index.astro +198 -0
  103. package/src/pages/admin/integrations.astro +57 -0
  104. package/src/pages/api/agent/index.ts +1 -1
  105. package/src/pages/api/calendar/docs.ts +2 -0
  106. package/src/pages/api/calendar/events/[id].ts +1 -1
  107. package/src/pages/api/calendar/events/index.ts +1 -1
  108. package/src/pages/api/mcp.ts +1 -1
  109. package/src/pages/book/manage/[token].astro +4 -4
  110. package/src/pages/events/[slug].astro +24 -10
  111. package/src/pages/forms/[id].astro +27 -0
  112. package/src/styles/admin.css +2 -2
  113. package/src/styles/editor.css +275 -34
  114. package/src/styles/form-block.css +38 -0
  115. package/src/styles/forms-admin.css +184 -0
  116. package/src/styles/forms.css +281 -0
  117. package/src/styles/site.css +43 -0
  118. 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>
@@ -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 = bookingsEnabled() ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'] })).length : 0;
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
- ...(bookingsEnabled()
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
  ],
@@ -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.siteLogo ? <img class="brand-logo" src={settings.siteLogo} alt={settings.siteTitle} /> : <span class="brand-name">{settings.siteTitle}</span>}
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="/">{settings.siteLogo ? <img class="brand-logo" src={settings.siteLogo} alt={settings.siteTitle} /> : <span class="brand-name">{settings.siteTitle}</span>}</a>
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 { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking } from '../schema';
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(5), exportedAt: z.string().optional(),
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: 5, exportedAt: new Date(now).toISOString(),
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
- location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(1000).default('') }).strict().optional(),
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 ?? '', location: a.location, cta: a.cta, tags: a.tags,
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 = '';
@@ -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));
@@ -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 type { EventLocation } from '../schema';
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), address: '', url: '' },
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: { name: q.location.trim().slice(0, 300), address: '', url: '' },
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)
@@ -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 type { CalEvent, Page, Post, WidgetInstance } from './schema';
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
- export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt'>>;
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<EventSave> {
111
- const out: EventSave = { ...data };
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
  }
@@ -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
- return pick(draft, EVENT_DRAFT_KEYS) as Partial<Pick<CalEvent, EventDraftKey>>;
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 })),
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -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
- return repo.createEvent({ title: input.title, slug, start: times.start, end: times.end, allDay: input.allDay ?? !input.start.includes('T') });
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({ name: text(300), address: text(500), url: text(1000) }).strict(),
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