@acms-subir/core 0.3.3 → 0.3.5

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 (69) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/cli/preview.mjs +8 -4
  3. package/cli/sections.mjs +4 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/theme-authoring.md +19 -3
  6. package/integration.mjs +21 -4
  7. package/package.json +14 -12
  8. package/src/actions/index.ts +186 -3
  9. package/src/components/booking/BookingFlow.astro +6 -3
  10. package/src/components/calendar/BookingsHead.astro +51 -0
  11. package/src/components/calendar/CalendarBoard.tsx +618 -0
  12. package/src/components/calendar/MiniMonth.tsx +54 -0
  13. package/src/components/calendar/NewEventDialog.tsx +44 -0
  14. package/src/components/calendar/QuickAdd.tsx +263 -0
  15. package/src/components/calendar/RoomHolds.tsx +203 -0
  16. package/src/components/calendar/WhereItLands.tsx +51 -0
  17. package/src/components/editor/EventPanel.tsx +23 -2
  18. package/src/components/editor/PostEditor.tsx +32 -4
  19. package/src/components/import/ImportExport.tsx +4 -4
  20. package/src/components/import/runner.ts +20 -3
  21. package/src/layouts/Admin.astro +5 -6
  22. package/src/layouts/Site.astro +29 -2
  23. package/src/lib/backup/content.ts +6 -1
  24. package/src/lib/booking/agent.ts +3 -2
  25. package/src/lib/booking/approval.ts +70 -0
  26. package/src/lib/booking/holds.ts +120 -0
  27. package/src/lib/booking/service.ts +37 -3
  28. package/src/lib/calendar/agent-schema.ts +9 -1
  29. package/src/lib/calendar/agent.ts +16 -1
  30. package/src/lib/calendar/board.ts +171 -0
  31. package/src/lib/calendar/config.ts +3 -0
  32. package/src/lib/calendar/grid.ts +169 -0
  33. package/src/lib/calendar/migration.ts +16 -0
  34. package/src/lib/calendar/render.ts +9 -7
  35. package/src/lib/calendar/schedule.ts +166 -0
  36. package/src/lib/content-service.ts +5 -0
  37. package/src/lib/drafts.ts +1 -1
  38. package/src/lib/import/bundle.ts +2 -1
  39. package/src/lib/mobile/calendar.ts +10 -6
  40. package/src/lib/mobile/http.ts +6 -0
  41. package/src/lib/repo/migrations.ts +3 -0
  42. package/src/lib/repo/sql.ts +28 -16
  43. package/src/lib/repo/types.ts +2 -2
  44. package/src/lib/scheduler.ts +3 -1
  45. package/src/lib/schema.ts +49 -0
  46. package/src/lib/sections.ts +33 -8
  47. package/src/lib/site-chrome.ts +34 -0
  48. package/src/lib/theme-assets.ts +16 -0
  49. package/src/lib/theme.ts +17 -3
  50. package/src/middleware.ts +6 -2
  51. package/src/pages/admin/calendar/bookings.astro +32 -26
  52. package/src/pages/admin/calendar/calendars.astro +4 -263
  53. package/src/pages/admin/calendar/events/index.astro +41 -83
  54. package/src/pages/admin/calendar/index.astro +43 -0
  55. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  56. package/src/pages/admin/calendar/resources/index.astro +16 -17
  57. package/src/pages/admin/calendar/resources/link.astro +52 -12
  58. package/src/pages/admin/calendar/settings.astro +458 -0
  59. package/src/pages/admin/integrations.astro +5 -11
  60. package/src/pages/book/[resource].ics.ts +7 -1
  61. package/src/styles/admin.css +48 -0
  62. package/src/styles/cards.css +5 -1
  63. package/src/styles/editor.css +36 -0
  64. package/src/styles/schedule.css +325 -0
  65. package/src/styles/sections.css +19 -16
  66. package/src/theme-sdk/fields.mjs +5 -1
  67. package/src/theme-sdk/index.ts +5 -1
  68. package/src/virtual.d.ts +3 -0
  69. package/src/pages/admin/calendar/index.ts +0 -4
@@ -1,6 +1,8 @@
1
1
  import { useMemo, useState } from 'react';
2
2
  import type { EventCta, EventLocation } from '../../lib/schema';
3
3
  import type { EventFieldConfig } from '../../lib/calendar/config';
4
+ import WhereItLands from '../calendar/WhereItLands';
5
+ import RoomHolds, { type RoomHoldValue } from '../calendar/RoomHolds';
4
6
  import { buildRepeat, describeRepeat, parseRepeat, WEEKDAY_CODES, weekdayOf, type RepeatParts } from '../../lib/calendar/rrule';
5
7
 
6
8
  // The event half of the editor sidebar: When · Repeats · Where · Button ·
@@ -21,6 +23,7 @@ export interface EventMeta {
21
23
  calendarIds: string[];
22
24
  fields: Record<string, string>;
23
25
  rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number };
26
+ roomHold: RoomHoldValue;
24
27
  }
25
28
 
26
29
  export interface CalendarOption {
@@ -35,6 +38,11 @@ interface Props {
35
38
  calendars: CalendarOption[];
36
39
  fields: EventFieldConfig[];
37
40
  siteTimezone: string;
41
+ // shows the "where it lands" mini month (Schedule data, minus this event)
42
+ eventId?: string;
43
+ // for the Rooms section (conflict wording, "Hold now" on drafts)
44
+ title?: string;
45
+ status?: string;
38
46
  }
39
47
 
40
48
  const DAY_LETTERS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
@@ -64,7 +72,7 @@ function zoneList(): string[] {
64
72
  }
65
73
  }
66
74
 
67
- export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }: Props) {
75
+ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone, eventId, title = '', status = 'draft' }: Props) {
68
76
  const repeat = useMemo(() => parseRepeat(meta.rrule, meta.start), [meta.rrule, meta.start]);
69
77
  const [skipDraft, setSkipDraft] = useState('');
70
78
  const zones = useMemo(zoneList, []);
@@ -136,6 +144,10 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
136
144
  {!meta.allDay && !showEndDate && (
137
145
  <button type="button" className="pe-ev-link" onClick={() => setShowEndDate(true)}>Ends on another day</button>
138
146
  )}
147
+ {eventId && datePart(meta.start) && (
148
+ <WhereItLands date={datePart(meta.start)} eventId={eventId}
149
+ onPick={(d) => setStart(meta.allDay ? d : `${d}T${timePart(meta.start) || '10:00'}`)} />
150
+ )}
139
151
  <label className="pe-switch-row">
140
152
  <span><b>All day</b><small>No start or end time</small></span>
141
153
  <input type="checkbox" role="switch" className="pe-switch" checked={meta.allDay} onChange={(e) => toggleAllDay(e.target.checked)} />
@@ -277,6 +289,15 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
277
289
  </label>
278
290
  </section>
279
291
 
292
+ <RoomHolds
293
+ times={{ eventId: eventId ?? '', title, start: meta.start, end: meta.end, allDay: meta.allDay, timezone: meta.timezone, rrule: meta.rrule, exdates: meta.exdates }}
294
+ hold={meta.roomHold}
295
+ status={status}
296
+ locationName={meta.location.name}
297
+ onChange={(roomHold) => onChange({ roomHold })}
298
+ onLocation={(name) => onChange({ location: { ...meta.location, name } })}
299
+ />
300
+
280
301
  <section className="pe-sb-section pe-ev">
281
302
  <h3 className="pe-sb-title">Button</h3>
282
303
  <div className="pe-ev-row">
@@ -327,7 +348,7 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
327
348
  <section className="pe-sb-section pe-ev">
328
349
  <h3 className="pe-sb-title">Calendars</h3>
329
350
  {calendars.length === 0 ? (
330
- <p className="pe-ev-note">No calendars yet — the event shows on the main calendar. <a href="/admin/calendar/calendars" target="_blank" rel="noopener">Create one</a> to group events.</p>
351
+ <p className="pe-ev-note">No calendars yet — the event shows on the main calendar. <a href="/admin/calendar/settings?new=1" target="_blank" rel="noopener">Create one</a> to group events.</p>
331
352
  ) : (
332
353
  <div className="pe-ev-cals">
333
354
  {calendars.map((c) => {
@@ -31,6 +31,7 @@ import { I18N, localeHomeUrl } from '../../lib/i18n';
31
31
  import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../lib/drafts';
32
32
  import { CALENDAR, eventUrl, type EventFieldConfig } from '../../lib/calendar/config';
33
33
  import { EventPanel, eventWhenLabel, type CalendarOption, type EventMeta } from './EventPanel';
34
+ import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHolds';
34
35
 
35
36
  // The Ghost-style writing surface, shared by posts AND pages: distraction-free
36
37
  // column, cards via "/", autosave, a collapsible settings sidebar, and an
@@ -149,6 +150,7 @@ const NO_EVENT: EventMeta = {
149
150
  start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
150
151
  location: { name: '', address: '', url: '' }, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
151
152
  rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4 },
153
+ roomHold: NO_HOLD,
152
154
  };
153
155
 
154
156
  export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' }, languages = [], mainLanguage, translationDrafts = false }: Props) {
@@ -176,7 +178,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
176
178
  template: event!.template, publishedAt: event!.publishedAt, scheduledAt: event!.scheduledAt,
177
179
  start: event!.start, end: event!.end, allDay: event!.allDay, timezone: event!.timezone, rrule: event!.rrule,
178
180
  exdates: event!.exdates, cancelled: event!.cancelled, location: event!.location, cta: event!.cta,
179
- calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp,
181
+ calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp, roomHold: event!.roomHold,
180
182
  }
181
183
  : {
182
184
  ...NO_EVENT,
@@ -192,7 +194,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
192
194
  const col = kind === 'event'
193
195
  ? { singular: 'Event', plural: 'Events', base: CALENDAR.eventsBase, redirectFrom: [] }
194
196
  : kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
195
- const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar/events' : `/admin/c/${meta.collection}`;
197
+ const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar' : `/admin/c/${meta.collection}`;
196
198
  const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
197
199
 
198
200
  const [authors, setAuthors] = useState<AuthorOption[]>([]);
@@ -408,6 +410,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
408
410
  calendarIds: m.calendarIds,
409
411
  fields: m.fields,
410
412
  rsvp: m.rsvp,
413
+ roomHold: m.roomHold,
411
414
  scheduledAt: m.scheduledAt,
412
415
  translations,
413
416
  });
@@ -711,8 +714,17 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
711
714
  toastTimer.current = setTimeout(() => setToast(null), 4500);
712
715
  }
713
716
 
714
- async function transition(patch: Partial<Meta>) {
717
+ // publishing or scheduling an event that holds rooms: check for bookings
718
+ // that overlap first, and let the author keep or cancel each one
719
+ const [roomGate, setRoomGate] = useState<{ patch: Partial<Meta>; rooms: Parameters<typeof RoomConflictDialog>[0]['rooms'] } | null>(null);
720
+ async function transition(patch: Partial<Meta>, roomsChecked = false) {
715
721
  setPublishOpen(false);
722
+ const m0 = metaRef.current;
723
+ if (kind === 'event' && !roomsChecked && (patch.status === 'published' || patch.status === 'scheduled') && m0.roomHold.resourceIds.length) {
724
+ const st = await fetchRoomStatus({ eventId: docId, title: m0.title, start: m0.start, end: m0.end, allDay: m0.allDay, timezone: m0.timezone, rrule: m0.rrule, exdates: m0.exdates }, m0.roomHold).catch(() => null);
725
+ const rooms = (st?.rooms ?? []).filter((r) => m0.roomHold.resourceIds.includes(r.id) && r.conflicts.length);
726
+ if (rooms.length) { setRoomGate({ patch, rooms }); return; }
727
+ }
716
728
  const next = { ...metaRef.current, ...patch };
717
729
  metaRef.current = next;
718
730
  setMetaState(next);
@@ -902,6 +914,19 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
902
914
 
903
915
  return (
904
916
  <div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
917
+ {roomGate && (
918
+ <RoomConflictDialog
919
+ rooms={roomGate.rooms}
920
+ onCancel={() => setRoomGate(null)}
921
+ onConfirm={async ({ keep, cancel, message }) => {
922
+ if (cancel.length) await actions.events.cancelBookingsForEvent({ ids: cancel, message });
923
+ const hold = metaRef.current.roomHold;
924
+ const patch = { ...roomGate.patch, roomHold: { ...hold, keep: [...new Set([...hold.keep, ...keep])] } };
925
+ setRoomGate(null);
926
+ await transition(patch, true);
927
+ }}
928
+ />
929
+ )}
905
930
  {toast && (
906
931
  <div className="pe-toast" role="status">
907
932
  <span className="pe-toast-check">✓</span>
@@ -911,7 +936,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
911
936
  )}
912
937
  <header className="pe-topbar">
913
938
  <div className="pe-topbar-left">
914
- <a className="pe-btn ghost sm" href={listHref} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); requestLeave('list'); }} aria-label={kind === 'page' ? 'Back to pages' : `Back to ${col.plural.toLowerCase()}`}>‹<span className="pe-hide-sm">{kind === 'page' ? 'Pages' : col.plural}</span></a>
939
+ <a className="pe-btn ghost sm" href={listHref} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); requestLeave('list'); }} aria-label={kind === 'page' ? 'Back to pages' : kind === 'event' ? 'Back to the schedule' : `Back to ${col.plural.toLowerCase()}`}>‹<span className="pe-hide-sm">{kind === 'page' ? 'Pages' : kind === 'event' ? 'Schedule' : col.plural}</span></a>
915
940
  <span className={`pe-savestate ${saveState}`}>
916
941
  <i className="pe-save-dot" aria-hidden="true" />
917
942
  {STATUS_LABEL[meta.status]}{pending && <em className="pe-edited-dot"> · edited</em>}<span className="pe-hide-sm"> · {saveLabel}</span>
@@ -1538,11 +1563,14 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
1538
1563
 
1539
1564
  {kind === 'event' && (
1540
1565
  <EventPanel
1566
+ eventId={docId}
1541
1567
  meta={meta}
1542
1568
  onChange={(patch) => setMeta(patch)}
1543
1569
  calendars={calendars}
1544
1570
  fields={eventFields}
1545
1571
  siteTimezone={siteTimezone}
1572
+ title={meta.title}
1573
+ status={meta.status}
1546
1574
  />
1547
1575
  )}
1548
1576
 
@@ -98,7 +98,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
98
98
  const key = planKeyFor(options);
99
99
  const stale = !!plan && planKey !== key;
100
100
  const assetFiles = bundle ? bundle.assets.filter((a) => a.kind !== 'media').length : 0;
101
- const needsFiles = isGhost && assetFiles > 0 && !siteUrl.trim() && !contentZip && !source?.zip;
101
+ const needsFiles = isGhost && !!bundle?.assets.some((a) => !/^https?:\/\//i.test(a.url)) && !siteUrl.trim() && !contentZip && !source?.zip;
102
102
  const resumable = source ? readJournal(source.hash) : null;
103
103
  const toImport = plan ? plan.counts.posts.create + plan.counts.posts.update + plan.counts.pages.create + plan.counts.pages.update : 0;
104
104
 
@@ -198,7 +198,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
198
198
  <span className="btn sm">{contentZip ? 'Change ZIP' : 'Add content folder ZIP'}</span>
199
199
  <span className="hint">{contentZip ? `${contentZip.name} · ${plural(contentZip.zip.names.length, 'file')}` : 'Optional. Used first; the address is the fallback.'}</span>
200
200
  </label>
201
- {needsFiles && <p className="imp-mark warn">Without an address or ZIP, images will point at the old site and break when it goes away.</p>}
201
+ {needsFiles && <p className="imp-mark warn">Enter the old site’s address or add its content folder ZIP before importing. Missing files without a source URL are left empty and listed in the report.</p>}
202
202
  </div>
203
203
  )}
204
204
 
@@ -252,7 +252,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
252
252
  <div className="imp-actions">
253
253
  <button type="button" className="btn" disabled={!!busy} onClick={check}>{busy === 'plan' ? 'Checking…' : plan ? 'Check again' : 'Check import'}</button>
254
254
  {plan && (
255
- <button type="button" className="btn primary" disabled={!!busy || stale || toImport === 0} onClick={run} title={stale ? 'Options changed: check again first' : ''}>
255
+ <button type="button" className="btn primary" disabled={!!busy || stale || needsFiles || toImport === 0} onClick={run} title={stale ? 'Options changed: check again first' : ''}>
256
256
  {busy === 'run' ? 'Importing…' : resumable?.runId ? `Resume import` : `Import ${plural(toImport, 'item')}`}
257
257
  </button>
258
258
  )}
@@ -341,7 +341,7 @@ function Done({ report, reset, openHistory }: { report: Report; reset: () => voi
341
341
  )}
342
342
  {!!report.failedAssets?.length && (
343
343
  <>
344
- <p className="imp-mark warn">{plural(report.failedAssets.length, 'file')} couldn’t be copied and still point at the old site</p>
344
+ <p className="imp-mark warn">{plural(report.failedAssets.length, 'file')} couldn’t be copied. Source URLs are kept when available; otherwise the references are left empty.</p>
345
345
  <ul className="imp-list">{report.failedAssets.map((f, i) => <li key={i}>{f.url}<small>{f.error}</small></li>)}</ul>
346
346
  </>
347
347
  )}
@@ -101,8 +101,14 @@ export function findInZip(zip: ZipIndex, path: string): string | null {
101
101
  }
102
102
 
103
103
  export function sourceAssetUrl(bundle: ImportBundle, asset: ImportAsset): string {
104
- if (/^https?:\/\//.test(asset.url)) return asset.url;
105
- return bundle.source.siteUrl ? `${bundle.source.siteUrl.replace(/\/+$/, '')}/${asset.url}` : `/${asset.url}`;
104
+ // A ZIP path is not a URL on the destination site. Only an absolute
105
+ // source URL is safe to retain when copying fails.
106
+ try {
107
+ const url = /^[a-z][a-z\d+.-]*:/i.test(asset.url)
108
+ ? new URL(asset.url)
109
+ : new URL(asset.url.replace(/^\/+/, ''), `${bundle.source.siteUrl.replace(/\/+$/, '')}/`);
110
+ return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
111
+ } catch { return ''; }
106
112
  }
107
113
 
108
114
  async function fetchAsset(bundle: ImportBundle, asset: ImportAsset, contentZip: ZipIndex | null): Promise<File> {
@@ -170,6 +176,10 @@ export async function runImport(
170
176
  const journal: Journal = readJournal(hash) ?? { runId: '', assets: {}, failed: {}, done: [], authorIds: {} };
171
177
  const save = () => writeJournal(hash, journal);
172
178
 
179
+ if (source.kind === 'ghost' && !files && bundle.assets.some((a) => !journal.assets[a.ref] && !sourceAssetUrl(bundle, a))) {
180
+ throw new Error('This Ghost export references images or files. Enter the old site’s address or add its content folder ZIP before importing.');
181
+ }
182
+
173
183
  // 1. files → media library
174
184
  const assets = bundle.assets.filter((a) => a.kind !== 'media');
175
185
  const total = assets.length + bundle.media.length;
@@ -214,6 +224,13 @@ export async function runImport(
214
224
 
215
225
  const resolved = new Map(Object.entries(journal.assets).map(([ref, r]) => [ref, r]));
216
226
  const fallback = new Map(bundle.assets.map((a) => [a.ref, sourceAssetUrl(bundle, a)]));
227
+ // Video/audio are not copied yet, but missing source URLs still need to
228
+ // be reported instead of quietly turning into destination-relative URLs.
229
+ for (const a of bundle.assets.filter((a) => a.kind === 'media')) {
230
+ if (!resolved.has(a.ref) && !fallback.get(a.ref)) {
231
+ journal.failed[a.ref] = 'Video/audio cannot be copied yet. Enter the old site’s address to keep its source URL.';
232
+ } else delete journal.failed[a.ref];
233
+ }
217
234
  const fix = (v: string) => resolveAssets(v, resolved, fallback);
218
235
 
219
236
  // 2. run + authors + tags; only people with a byline become authors
@@ -261,7 +278,7 @@ export async function runImport(
261
278
  const identity = Object.fromEntries(Object.entries(bundle.identity).map(([k, v]) => [k, typeof v === 'string' ? fix(v) : v]));
262
279
  const failedAssets = Object.entries(journal.failed).map(([ref, error]) => {
263
280
  const a = bundle.assets.find((x) => x.ref === ref);
264
- return { url: a ? sourceAssetUrl(bundle, a) : ref.replace(/^m-/, 'media '), error };
281
+ return { url: a ? sourceAssetUrl(bundle, a) || a.url : ref.replace(/^m-/, 'media '), error };
265
282
  });
266
283
  const { report } = await api<{ report: Record<string, unknown> }>({
267
284
  op: 'finish', runId: journal.runId, options, identity,
@@ -109,15 +109,14 @@ const groups: { id: string; label: string; items: Item[] }[] = [
109
109
  ? [{
110
110
  id: 'calendar',
111
111
  label: 'Calendar',
112
+ // two homes: Schedule (anything on a date — grid, list, calendar
113
+ // settings) and Bookings (anything people reserve — requests, rooms &
114
+ // people, booking links). Sub-pages highlight their home.
112
115
  items: [
113
- { key: 'calendar:events', label: 'Events', href: '/admin/calendar/events', icon: icons.calendar, count: null },
116
+ { key: 'calendar:schedule', label: 'Schedule', href: '/admin/calendar', icon: icons.calendar, count: null },
114
117
  ...(bookingsEnabled()
115
- ? [
116
- { key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 },
117
- { key: 'calendar:resources', label: 'Resources', href: '/admin/calendar/resources', icon: icons.resources, count: null },
118
- ]
118
+ ? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
119
119
  : []),
120
- { key: 'calendar:calendars', label: 'Calendars', href: '/admin/calendar/calendars', icon: icons.calendars, count: null },
121
120
  ],
122
121
  }]
123
122
  : []),
@@ -14,6 +14,7 @@ import { diffDesign } from '../lib/designDiff';
14
14
  import { getTheme } from '../lib/theme';
15
15
  import { analyticsHead, analyticsBody } from '../lib/analytics';
16
16
  import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
17
+ import type { SiteChrome } from '../lib/site-chrome';
17
18
  import '../styles/site.css';
18
19
  import '../styles/cards.css';
19
20
  import '../styles/live.css';
@@ -136,6 +137,30 @@ const readLinks = [
136
137
  ...(SEARCH.enabled ? [{ label: 'Search', url: searchAction }] : []),
137
138
  { label: 'RSS', url: '/rss.xml' },
138
139
  ];
140
+ // a theme's templates/header.astro / footer.astro get this instead of core markup
141
+ const langName = (l: string) => {
142
+ try { const n = new Intl.DisplayNames([l], { type: 'language' }).of(l); return n ? n[0].toLocaleUpperCase(l) + n.slice(1) : l.toUpperCase(); }
143
+ catch { return l.toUpperCase(); }
144
+ };
145
+ const chromeLink = (n: { label: string; url: string }) => ({ label: n.label, url: lnk(n.url), current: isCurrent(lnk(n.url)), external: isExternal(n.url) });
146
+ const chrome: SiteChrome = {
147
+ title: settings.siteTitle, tagline: settings.tagline, logo: settings.siteLogo,
148
+ homeUrl: reqLocale ? localeHomeUrl(reqLocale) : '/',
149
+ links: headerLinks.map(chromeLink),
150
+ footerLinks: footerNav.map(chromeLink),
151
+ social: socialNav.map((n) => ({ ...chromeLink(n), icon: n.icon })),
152
+ readLinks: readLinks.map(chromeLink),
153
+ footerHtml,
154
+ languages: I18N.enabled ? I18N.locales.map((l) => ({ code: l, label: langName(l), url: localePath(barePath, l), current: l === (reqLocale || I18N.defaultLocale) })) : [],
155
+ search: showSearch ? { url: searchAction, label: searchT.label } : null,
156
+ schemeToggle,
157
+ liveHeader: Boolean(liveSignals?.header),
158
+ headerStyle: String(tokens.header), footerStyle: String(tokens.footer),
159
+ };
160
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
+ const HeaderTpl = design.theme.HeaderTemplate as any;
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ const FooterTpl = design.theme.FooterTemplate as any;
139
164
  // hreflang alternates: every public page exists in each language (translated
140
165
  // words where available, the main language otherwise)
141
166
  const isHomePath = I18N.enabled && !noindex && !bare;
@@ -246,7 +271,8 @@ const homeJsonLd =
246
271
  </script>
247
272
  )}
248
273
  {!bare && <a class="skip-link" href="#main">Skip to content</a>}
249
- {!bare && (
274
+ {!bare && HeaderTpl && <HeaderTpl site={chrome} />}
275
+ {!bare && !HeaderTpl && (
250
276
  <header class="site-header" data-style={tokens.header}>
251
277
  <div class="site-header-in">
252
278
  <a class="brand" href={reqLocale ? localeHomeUrl(reqLocale) : '/'}>
@@ -644,7 +670,8 @@ const homeJsonLd =
644
670
  <main class="site-main" id="main">
645
671
  <slot />
646
672
  </main>
647
- {!bare && (
673
+ {!bare && FooterTpl && <FooterTpl site={chrome} />}
674
+ {!bare && !FooterTpl && (
648
675
  <footer class="site-footer" data-style={tokens.footer}>
649
676
  <div class="site-footer-in">
650
677
  {tokens.footer === 'columns' ? (
@@ -2,6 +2,11 @@ import { z } from 'zod';
2
2
  import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking } from '../schema';
3
3
  import type { ContentRepository } from '../repo/types';
4
4
 
5
+ // backups from before booking approval modes: a link that didn't confirm on
6
+ // its own must stay manual (a missing `approval` would follow the site default)
7
+ const legacyApproval = (l: unknown) =>
8
+ l && typeof l === 'object' && !('approval' in l) && (l as { autoConfirm?: boolean }).autoConfirm === false ? { ...l, approval: 'manual' } : l;
9
+
5
10
  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() });
6
11
  export type BookingRequest = z.infer<typeof BookingRequest>;
7
12
  export const BackupContent = z.object({
@@ -9,7 +14,7 @@ export const BackupContent = z.object({
9
14
  settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
10
15
  tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
11
16
  calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
12
- bookingLinks: z.array(BookingLink).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
17
+ bookingLinks: z.array(z.preprocess(legacyApproval, BookingLink)).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
13
18
  }).superRefine((data, ctx) => {
14
19
  if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
15
20
  const keys = new Set<string>();
@@ -8,6 +8,7 @@ import { ApiError } from '../api';
8
8
  import { bookingsEnabled } from '../calendar/config';
9
9
  import { availabilityContext, bookingJson, BookingError, createResourceBooking, rescheduleBooking, setBookingStatus } from './service';
10
10
  import { addDaysYmd, describeLink, slotsForDay, ymd } from './availability';
11
+ import { confirmsInstantly, linkApproval } from './approval';
11
12
 
12
13
  export const BookingToolInputs = [
13
14
  z.object({ tool: z.literal('list_resources'), arguments: z.object({}).strict() }).strict(),
@@ -65,7 +66,7 @@ export async function runBookingTool(repo: ContentRepository, input: Input, allo
65
66
  try {
66
67
  switch (input.tool) {
67
68
  case 'list_resources':
68
- return { resources: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, capacity: r.capacity, visibility: r.visibility, links: links.filter((l) => l.resourceId === r.id).map((l) => ({ slug: l.slug, name: l.name, rules: describeLink(l), mode: l.mode, lengths: l.mode === 'range' ? l.lengths : [l.duration], autoConfirm: l.autoConfirm, active: l.active, visibility: l.visibility })) })) };
69
+ return { resources: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, capacity: r.capacity, visibility: r.visibility, links: links.filter((l) => l.resourceId === r.id).map((l) => ({ slug: l.slug, name: l.name, rules: describeLink(l), mode: l.mode, lengths: l.mode === 'range' ? l.lengths : [l.duration], autoConfirm: confirmsInstantly(linkApproval(l, settings)), active: l.active, visibility: l.visibility })) })) };
69
70
  case 'check_availability': {
70
71
  const { r, l } = resolve(input.arguments.resource, input.arguments.link);
71
72
  const c = await availabilityContext(repo, r, l, settings);
@@ -74,7 +75,7 @@ export async function runBookingTool(repo: ContentRepository, input: Input, allo
74
75
  const days = Array.from({ length: input.arguments.days }, (_, i) => addDaysYmd(from, i)).map((date) => ({
75
76
  date, slots: slotsForDay(c, date, input.arguments.minutes).slice(0, 60).map((s) => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
76
77
  }));
77
- return { timezone: tz, autoConfirm: l.autoConfirm, days };
78
+ return { timezone: tz, autoConfirm: confirmsInstantly(linkApproval(l, settings)), days };
78
79
  }
79
80
  case 'list_bookings': {
80
81
  const a = input.arguments;
@@ -0,0 +1,70 @@
1
+ // Booking approval: does a new booking confirm at once, or wait for a
2
+ // decision? Each link either follows the site default (Calendar settings →
3
+ // Bookings, seeded from acms({ calendar: { bookings: { approval } } })) or
4
+ // picks its own mode. "rules" confirms unless one of the rules flags the
5
+ // booking. Pure apart from the first-time lookup, which the caller supplies.
6
+ import type { ApprovalMode, ApprovalRules, BookingLink, Settings } from '../schema';
7
+ import { CALENDAR } from '../calendar/config';
8
+
9
+ export interface Approval {
10
+ mode: ApprovalMode;
11
+ rules: ApprovalRules;
12
+ // true when the link follows the site default
13
+ inherited: boolean;
14
+ }
15
+
16
+ export function siteApproval(settings: Pick<Settings, 'calendar'>): { mode: ApprovalMode; rules: ApprovalRules } {
17
+ return { mode: settings.calendar.approval || CALENDAR.bookingApproval, rules: settings.calendar.approvalRules };
18
+ }
19
+
20
+ export function linkApproval(link: Pick<BookingLink, 'approval' | 'approvalRules'>, settings: Pick<Settings, 'calendar'>): Approval {
21
+ if (link.approval === 'default') return { ...siteApproval(settings), inherited: true };
22
+ return { mode: link.approval, rules: link.approvalRules, inherited: false };
23
+ }
24
+
25
+ // what the public page promises before someone books
26
+ export function confirmsInstantly(a: Approval): boolean {
27
+ return a.mode === 'auto';
28
+ }
29
+
30
+ export interface ApprovalInput {
31
+ start: number;
32
+ end: number;
33
+ seats: number;
34
+ // has this email booked (any status but declined/cancelled) before?
35
+ returning: boolean;
36
+ }
37
+
38
+ // why a "rules" booking needs a person to look at it; [] = confirm now
39
+ export function approvalReasons(a: Approval, b: ApprovalInput): string[] {
40
+ if (a.mode === 'auto') return [];
41
+ if (a.mode === 'manual') return ['Every booking on this link needs approval'];
42
+ const r = a.rules;
43
+ const out: string[] = [];
44
+ if (r.longerThan > 0 && (b.end - b.start) / 60_000 > r.longerThan) out.push(`Longer than ${durationLabel(r.longerThan)}`);
45
+ if (r.firstTime && !b.returning) out.push('First booking from this email');
46
+ if (r.moreSeats > 0 && b.seats > r.moreSeats) out.push(`More than ${r.moreSeats} seats`);
47
+ return out;
48
+ }
49
+
50
+ export function decideStatus(a: Approval, b: ApprovalInput): 'confirmed' | 'pending' {
51
+ return approvalReasons(a, b).length ? 'pending' : 'confirmed';
52
+ }
53
+
54
+ export function durationLabel(min: number): string {
55
+ if (min % 1440 === 0) return `${min / 1440} day${min === 1440 ? '' : 's'}`;
56
+ if (min % 60 === 0) return `${min / 60} hour${min === 60 ? '' : 's'}`;
57
+ if (min > 60) return `${Math.floor(min / 60)} h ${min % 60} min`;
58
+ return `${min} min`;
59
+ }
60
+
61
+ export const APPROVAL_LABEL: Record<ApprovalMode, string> = {
62
+ auto: 'Automatically confirm',
63
+ rules: 'Automatically, unless…',
64
+ manual: 'I approve each booking',
65
+ };
66
+
67
+ // one short phrase for rows and badges: "Instant" / "Needs approval" / "Instant, with rules"
68
+ export function approvalShort(a: Approval): string {
69
+ return a.mode === 'auto' ? 'Instant' : a.mode === 'manual' ? 'Needs approval' : 'Instant, with rules';
70
+ }
@@ -0,0 +1,120 @@
1
+ // Rooms held by events. An event that lists rooms/people in roomHold makes
2
+ // them unbookable while it happens (± setup/cleanup) or for the whole day.
3
+ // Holds are computed from events every time — never stored as bookings — so
4
+ // moving, repeating, skipping a date, cancelling or deleting the event frees
5
+ // or moves the room automatically. availabilityContext() adds these to its
6
+ // busy list, so every booking path (pages, embeds, app, API, agent) obeys.
7
+ import type { ContentRepository } from '../repo/types';
8
+ import type { Booking, CalEvent, Resource, Settings } from '../schema';
9
+ import { expandEvents } from '../calendar/events';
10
+ import { tzParts, zonedToUtc } from '../calendar/ics';
11
+ import { resourceZone } from './availability';
12
+
13
+ export interface Hold {
14
+ resourceId: string;
15
+ eventId: string;
16
+ title: string;
17
+ start: number; // incl. setup
18
+ end: number; // incl. cleanup
19
+ eventStart: number; // the occurrence itself
20
+ eventEnd: number;
21
+ }
22
+
23
+ const DAY = 86_400_000;
24
+
25
+ /** Does this event hold its rooms right now? */
26
+ export function holdsRooms(e: Pick<CalEvent, 'status' | 'cancelled' | 'roomHold'>): boolean {
27
+ if (e.cancelled || !e.roomHold.resourceIds.length) return false;
28
+ return e.status === 'published' || e.status === 'scheduled' || (e.status === 'draft' && e.roomHold.whileDraft);
29
+ }
30
+
31
+ // midnight (in tz) of the day containing ms
32
+ function dayStart(ms: number, tz: string): number {
33
+ const p = tzParts(ms, tz);
34
+ return zonedToUtc(p.y, p.m, p.d, 0, 0, 0, tz);
35
+ }
36
+ function nextDayStart(ms: number, tz: string): number {
37
+ // +26h then back to that day's midnight survives DST changes
38
+ return dayStart(dayStart(ms, tz) + DAY + 2 * 3_600_000, tz);
39
+ }
40
+
41
+ /** Holds for the given events in [from, to). `force` counts drafts too (editor previews). */
42
+ export function holdsFromEvents(
43
+ events: CalEvent[],
44
+ resources: Map<string, Pick<Resource, 'id' | 'timezone'>>,
45
+ settings: Pick<Settings, 'timezone'>,
46
+ from: number,
47
+ to: number,
48
+ opts: { force?: boolean; resourceId?: string } = {}
49
+ ): Hold[] {
50
+ const out: Hold[] = [];
51
+ const holding = events.filter((e) => (opts.force ? !e.cancelled && e.roomHold.resourceIds.length > 0 : holdsRooms(e))
52
+ && (!opts.resourceId || e.roomHold.resourceIds.includes(opts.resourceId)));
53
+ if (!holding.length) return out;
54
+ // widen the window so setup/cleanup and whole-day holds at the edges count
55
+ const pad = 2 * DAY;
56
+ const occurrences = expandEvents(holding, settings, { displayTz: settings.timezone || 'UTC', now: from, windowStartMs: from - pad, windowEndMs: to + pad });
57
+ for (const o of occurrences) {
58
+ const h = o.event.roomHold;
59
+ for (const resourceId of h.resourceIds) {
60
+ if (opts.resourceId && resourceId !== opts.resourceId) continue;
61
+ const r = resources.get(resourceId);
62
+ if (!r) continue;
63
+ let start: number;
64
+ let end: number;
65
+ if (h.span === 'day') {
66
+ const tz = resourceZone(r, settings.timezone || 'UTC');
67
+ start = dayStart(o.start, tz);
68
+ end = nextDayStart(Math.max(o.start, o.end - 1), tz);
69
+ } else {
70
+ start = o.start - h.before * 60_000;
71
+ end = o.end + h.after * 60_000;
72
+ }
73
+ if (end <= from || start >= to) continue;
74
+ out.push({ resourceId, eventId: o.event.id, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
75
+ }
76
+ }
77
+ return out.sort((a, b) => a.start - b.start);
78
+ }
79
+
80
+ /** Holds on the site's live events (what booking pages must respect). */
81
+ export async function eventHolds(repo: ContentRepository, settings: Pick<Settings, 'timezone'>, from: number, to: number, resourceId?: string): Promise<Hold[]> {
82
+ const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
83
+ return holdsFromEvents(events, new Map(resources.map((r) => [r.id, r])), settings, from, to, { resourceId });
84
+ }
85
+
86
+ export interface HoldConflict {
87
+ booking: Pick<Booking, 'id' | 'name' | 'email' | 'start' | 'end' | 'status' | 'resourceId' | 'kind' | 'notes'>;
88
+ hold: Hold;
89
+ }
90
+
91
+ /**
92
+ * Bookings that overlap an event's holds. `event` may be unsaved (the
93
+ * editor's current values): holds are computed as if it were published.
94
+ * Repeating events are checked over the next `horizonDays` (default a year).
95
+ */
96
+ export async function holdConflicts(
97
+ repo: ContentRepository,
98
+ settings: Pick<Settings, 'timezone'>,
99
+ event: CalEvent,
100
+ opts: { now?: number; horizonDays?: number } = {}
101
+ ): Promise<{ conflicts: HoldConflict[]; holds: Hold[] }> {
102
+ const now = opts.now ?? Date.now();
103
+ const to = now + (opts.horizonDays ?? 366) * DAY;
104
+ const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
105
+ const holds = holdsFromEvents([event], resources, settings, now, to, { force: true });
106
+ if (!holds.length) return { conflicts: [], holds };
107
+ const keep = new Set(event.roomHold.keep);
108
+ const conflicts: HoldConflict[] = [];
109
+ for (const resourceId of new Set(holds.map((h) => h.resourceId))) {
110
+ const mine = holds.filter((h) => h.resourceId === resourceId);
111
+ const bookings = await repo.listBookings({ resourceId, from: mine[0].start, to: mine[mine.length - 1].end, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] });
112
+ for (const b of bookings) {
113
+ if (keep.has(b.id)) continue;
114
+ // a booking's own buffers (hold_start/end) don't count, only its real time
115
+ const hold = mine.find((h) => b.start < h.end && b.end > h.start);
116
+ if (hold) conflicts.push({ booking: { id: b.id, name: b.name, email: b.email, start: b.start, end: b.end, status: b.status, resourceId: b.resourceId, kind: b.kind, notes: b.notes }, hold });
117
+ }
118
+ }
119
+ return { conflicts: conflicts.sort((a, b) => a.booking.start - b.booking.start), holds };
120
+ }