@acms-subir/core 0.3.3 → 0.3.4

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 (66) hide show
  1. package/CHANGELOG.md +36 -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/layouts/Admin.astro +5 -6
  20. package/src/layouts/Site.astro +29 -2
  21. package/src/lib/backup/content.ts +6 -1
  22. package/src/lib/booking/agent.ts +3 -2
  23. package/src/lib/booking/approval.ts +70 -0
  24. package/src/lib/booking/holds.ts +120 -0
  25. package/src/lib/booking/service.ts +37 -3
  26. package/src/lib/calendar/agent-schema.ts +9 -1
  27. package/src/lib/calendar/agent.ts +16 -1
  28. package/src/lib/calendar/board.ts +171 -0
  29. package/src/lib/calendar/config.ts +3 -0
  30. package/src/lib/calendar/grid.ts +169 -0
  31. package/src/lib/calendar/migration.ts +16 -0
  32. package/src/lib/calendar/render.ts +9 -7
  33. package/src/lib/calendar/schedule.ts +166 -0
  34. package/src/lib/content-service.ts +5 -0
  35. package/src/lib/drafts.ts +1 -1
  36. package/src/lib/mobile/calendar.ts +10 -6
  37. package/src/lib/mobile/http.ts +6 -0
  38. package/src/lib/repo/migrations.ts +3 -0
  39. package/src/lib/repo/sql.ts +28 -16
  40. package/src/lib/repo/types.ts +2 -2
  41. package/src/lib/scheduler.ts +3 -1
  42. package/src/lib/schema.ts +49 -0
  43. package/src/lib/sections.ts +33 -8
  44. package/src/lib/site-chrome.ts +34 -0
  45. package/src/lib/theme-assets.ts +16 -0
  46. package/src/lib/theme.ts +17 -3
  47. package/src/middleware.ts +6 -2
  48. package/src/pages/admin/calendar/bookings.astro +32 -26
  49. package/src/pages/admin/calendar/calendars.astro +4 -263
  50. package/src/pages/admin/calendar/events/index.astro +41 -83
  51. package/src/pages/admin/calendar/index.astro +43 -0
  52. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  53. package/src/pages/admin/calendar/resources/index.astro +16 -17
  54. package/src/pages/admin/calendar/resources/link.astro +52 -12
  55. package/src/pages/admin/calendar/settings.astro +458 -0
  56. package/src/pages/admin/integrations.astro +5 -11
  57. package/src/pages/book/[resource].ics.ts +7 -1
  58. package/src/styles/admin.css +48 -0
  59. package/src/styles/cards.css +5 -1
  60. package/src/styles/editor.css +36 -0
  61. package/src/styles/schedule.css +325 -0
  62. package/src/styles/sections.css +19 -16
  63. package/src/theme-sdk/fields.mjs +5 -1
  64. package/src/theme-sdk/index.ts +5 -1
  65. package/src/virtual.d.ts +3 -0
  66. package/src/pages/admin/calendar/index.ts +0 -4
@@ -8,9 +8,10 @@ import { availabilityContext } from '../booking/service';
8
8
  import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
9
9
  import { siteOrigin } from '../seo';
10
10
  import { safeUrl } from './content';
11
- import { pageQuery, isoDay, queryParams } from './http';
11
+ import { pageQuery, isoDay, isoDayOrInstant, queryParams } from './http';
12
12
  import { localizeDoc } from '../translations';
13
13
  import { localeQuery, mobileLocale, localizedUrl } from './locale';
14
+ import { confirmsInstantly, linkApproval } from '../booking/approval';
14
15
 
15
16
  export function requireCalendar(bookings = false) {
16
17
  if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
@@ -21,7 +22,7 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
21
22
  const origin = siteOrigin(await repo.getSettings(), url);
22
23
  return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
23
24
  }
24
- export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDay.optional(), to: isoDay.optional(), calendar: z.string().min(1).max(200).optional(), q: z.string().max(100).optional() }).strict();
25
+ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(), q: z.string().max(100).optional() }).strict();
25
26
  export async function listEvents(repo: ContentRepository, url: URL) {
26
27
  requireCalendar();
27
28
  const q = eventsQuery.parse(queryParams(url));
@@ -29,8 +30,11 @@ export async function listEvents(repo: ContentRepository, url: URL) {
29
30
  const [settings, calendars, events] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
30
31
  // days are the site's calendar days (a New York site's "today" starts at midnight there, not UTC)
31
32
  const tz = settings.timezone || 'UTC';
32
- const from = localMidnight(q.from ?? ymd(Date.now(), tz), tz);
33
- const to = q.to ? localMidnight(q.to, tz) : localMidnight(addDaysYmd(q.from ?? ymd(Date.now(), tz), 30), tz);
33
+ // a plain day is the site's midnight; a date-time is that exact instant
34
+ const at = (v: string) => (v.length === 10 ? localMidnight(v, tz) : Date.parse(v));
35
+ const fromDay = q.from ?? ymd(Date.now(), tz);
36
+ const from = at(fromDay);
37
+ const to = q.to ? at(q.to) : fromDay.length === 10 ? localMidnight(addDaysYmd(fromDay, 30), tz) : from + 30 * 86400000;
34
38
  if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
35
39
  const all = new Map(calendars.map(c => [c.id, c]));
36
40
  const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
@@ -67,7 +71,7 @@ export async function listResources(repo: ContentRepository, url: URL) {
67
71
  photo: safeUrl(r.photo, origin), timezone: r.timezone || settings.timezone || 'UTC', url: origin + resourceUrl(r.slug),
68
72
  links: links.filter(l => l.resourceId === r.id && l.active && l.visibility === 'public').map(l => ({
69
73
  id: l.id, slug: l.slug, name: l.name, description: l.description, mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
70
- askPhone: l.askPhone, autoConfirm: l.autoConfirm, url: origin + bookingLinkUrl(r.slug, l.slug),
74
+ askPhone: l.askPhone, autoConfirm: confirmsInstantly(linkApproval(l, settings)), url: origin + bookingLinkUrl(r.slug, l.slug),
71
75
  questions: l.questions.map(({ id, label, type, required, options }) => ({ id, label, type, required, options })),
72
76
  })),
73
77
  })), pagination: { page: q.page, limit: q.limit, nextPage: visible.length > start + q.limit ? q.page + 1 : null } };
@@ -85,7 +89,7 @@ export async function getAvailability(repo: ContentRepository, url: URL) {
85
89
  const ctx = await availabilityContext(repo, resource, link, settings);
86
90
  const timezone = resource.timezone || settings.timezone || 'UTC';
87
91
  const from = q.from ?? ymd(Date.now(), timezone);
88
- return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: link.autoConfirm,
92
+ return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: confirmsInstantly(linkApproval(link, settings)),
89
93
  days: Array.from({ length: q.days }, (_, i) => addDaysYmd(from, i)).map(date => ({ date,
90
94
  slots: slotsForDay(ctx, date, q.minutes).map(s => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
91
95
  })),
@@ -57,6 +57,12 @@ export const pageQuery = {
57
57
  export const isoDay = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((s) => {
58
58
  const n = Date.parse(s); return Number.isFinite(n) && new Date(n).toISOString().slice(0, 10) === s;
59
59
  }, 'Use a valid calendar date.');
60
+ // a site-local day ("2026-10-01" = midnight in the site timezone) or an exact
61
+ // ISO 8601 instant with a zone ("2026-10-01T18:00:00Z", "…T13:00:00-05:00")
62
+ export const isoDayOrInstant = z.string().max(40).refine((s) => {
63
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return isoDay.safeParse(s).success;
64
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/.test(s) && Number.isFinite(Date.parse(s));
65
+ }, 'Use a date (YYYY-MM-DD) or an ISO date-time with a timezone (2026-10-01T18:00:00Z).');
60
66
 
61
67
  export function methodNotAllowed(allow = 'GET, HEAD, OPTIONS') {
62
68
  const res = failure(new ApiError(405, 'Method not allowed.'));
@@ -4,6 +4,7 @@ import { EMAIL_MIGRATION } from '../email/migration';
4
4
  import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
5
  import { PUSH_MIGRATION } from '../push/migration';
6
6
  import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
+ import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
7
8
  // Versioned schema migrations shared by the two SQLite-dialect adapters
8
9
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
9
10
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -512,6 +513,8 @@ export const MIGRATIONS: Migration[] = [
512
513
  PUSH_MIGRATION,
513
514
  EDITOR_MIGRATION,
514
515
  EDITOR_GRACE_MIGRATION,
516
+ CALENDAR_SETTINGS_MIGRATION,
517
+ ROOM_HOLD_MIGRATION,
515
518
  ];
516
519
 
517
520
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -144,7 +144,7 @@ function rowToEvent(r: Row): CalEvent {
144
144
  start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
145
145
  rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
146
146
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
147
- fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
147
+ fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
148
148
  startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
149
149
  publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
150
150
  scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
@@ -161,6 +161,14 @@ function rowToResource(r: Row): Resource {
161
161
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
162
162
  });
163
163
  }
164
+ // Callers that still speak the old on/off flag (REST, agent tools, older
165
+ // code) set autoConfirm without an approval mode: map it so "off" really
166
+ // holds bookings. On create, "on" follows the site default (which starts as
167
+ // automatic); on update it pins the link to automatic.
168
+ function legacyApproval<T extends { autoConfirm?: boolean; approval?: BookingLink['approval'] }>(data: T, update = false): T {
169
+ if (data.approval !== undefined || data.autoConfirm === undefined) return data;
170
+ return { ...data, approval: data.autoConfirm ? (update ? 'auto' : 'default') : 'manual' };
171
+ }
164
172
  function rowToLink2(r: Row): BookingLink {
165
173
  return BookingLink.parse({
166
174
  id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
@@ -168,6 +176,7 @@ function rowToLink2(r: Row): BookingLink {
168
176
  step: Number(r.step), bufferBefore: Number(r.buffer_before), bufferAfter: Number(r.buffer_after),
169
177
  minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap),
170
178
  hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
179
+ approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
171
180
  askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
172
181
  redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
173
182
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
@@ -811,8 +820,8 @@ export class SqlRepository implements ContentRepository {
811
820
  await this.driver.run(
812
821
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
813
822
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
814
- published_at, scheduled_at, created_at, updated_at, rsvp)
815
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
823
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
824
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
816
825
  [
817
826
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
818
827
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -820,7 +829,7 @@ export class SqlRepository implements ContentRepository {
820
829
  data.cancelled ? 1 : 0, JSON.stringify(data.location ?? {}), JSON.stringify(data.cta ?? {}), JSON.stringify(data.calendarIds ?? []),
821
830
  JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
822
831
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
823
- JSON.stringify(data.rsvp ?? {}),
832
+ JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}),
824
833
  ]
825
834
  );
826
835
  await this.syncEventIndex(id);
@@ -845,7 +854,7 @@ export class SqlRepository implements ContentRepository {
845
854
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
846
855
  }
847
856
  const jsonCols: Record<string, string> = {
848
- tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations',
857
+ tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
849
858
  };
850
859
  for (const [k, col] of Object.entries(jsonCols)) {
851
860
  const v = (data as Record<string, unknown>)[k];
@@ -936,10 +945,11 @@ export class SqlRepository implements ContentRepository {
936
945
  return {
937
946
  sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
938
947
  buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
939
- active, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
948
+ active, ord, created_at, updated_at, approval, approval_rules) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
940
949
  args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
941
950
  l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
942
- l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt],
951
+ l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
952
+ l.approval, JSON.stringify(l.approvalRules)],
943
953
  };
944
954
  }
945
955
  private bookingInsert(b: Booking): SqlStatement {
@@ -1017,7 +1027,7 @@ export class SqlRepository implements ContentRepository {
1017
1027
  await this.ready;
1018
1028
  const now = Date.now();
1019
1029
  const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM booking_links WHERE resource_id = ?', [data.resourceId]);
1020
- const l = BookingLink.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
1030
+ const l = BookingLink.parse({ ...legacyApproval(data), id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
1021
1031
  const st = this.linkInsert(l);
1022
1032
  await this.driver.run(st.sql, st.args);
1023
1033
  return l;
@@ -1026,14 +1036,14 @@ export class SqlRepository implements ContentRepository {
1026
1036
  await this.ready;
1027
1037
  const cur = await this.getBookingLink(id);
1028
1038
  if (!cur) return;
1029
- const l = BookingLink.parse({ ...cur, ...data, id, updatedAt: Date.now() });
1039
+ const l = BookingLink.parse({ ...cur, ...legacyApproval(data, true), id, updatedAt: Date.now() });
1030
1040
  await this.driver.run(
1031
1041
  `UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
1032
1042
  buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
1033
- ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ? WHERE id = ?`,
1043
+ ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ? WHERE id = ?`,
1034
1044
  [l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
1035
1045
  l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
1036
- JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, id]
1046
+ JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), id]
1037
1047
  );
1038
1048
  }
1039
1049
  async deleteBookingLink(id: string) {
@@ -1048,6 +1058,7 @@ export class SqlRepository implements ContentRepository {
1048
1058
  const args: unknown[] = [];
1049
1059
  if (filter.resourceId) { where.push('resource_id = ?'); args.push(filter.resourceId); }
1050
1060
  if (filter.eventId) { where.push('event_id = ?'); args.push(filter.eventId); }
1061
+ if (filter.email) { where.push('email = ?'); args.push(filter.email.toLowerCase()); }
1051
1062
  if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
1052
1063
  if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
1053
1064
  if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
@@ -1614,14 +1625,14 @@ export class SqlRepository implements ContentRepository {
1614
1625
  ...(content.events ?? []).map((e) => ({
1615
1626
  sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1616
1627
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1617
- draft, published_at, scheduled_at, created_at, updated_at, rsvp)
1618
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1628
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
1629
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1619
1630
  args: [
1620
1631
  e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
1621
1632
  JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1622
1633
  e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1623
1634
  e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1624
- JSON.stringify(e.rsvp ?? {}),
1635
+ JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
1625
1636
  ],
1626
1637
  })),
1627
1638
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
@@ -1836,6 +1847,7 @@ export class SqlRepository implements ContentRepository {
1836
1847
  ai: JSON.parse(String(r?.ai ?? '{}')),
1837
1848
  live: JSON.parse(String(r?.live ?? '{}')),
1838
1849
  push: JSON.parse(String(r?.push ?? '{}')),
1850
+ calendar: JSON.parse(String(r?.calendar ?? '{}')),
1839
1851
  agent: JSON.parse(String(r?.agent ?? '{}')),
1840
1852
  draftsApiKey: r?.drafts_api_key ?? '',
1841
1853
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
@@ -1852,9 +1864,9 @@ export class SqlRepository implements ContentRepository {
1852
1864
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1853
1865
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1854
1866
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1855
- draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push',
1867
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
1856
1868
  };
1857
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push']);
1869
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
1858
1870
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1859
1871
  if (!entries.length) return;
1860
1872
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
@@ -39,7 +39,7 @@ export type EventUpdate = Partial<
39
39
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
40
40
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
41
41
  | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
42
- | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations'
42
+ | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
43
43
  | 'publishedAt' | 'scheduledAt'
44
44
  >
45
45
  >;
@@ -152,7 +152,7 @@ export interface ContentRepository {
152
152
  // write that can fail on a race: it inserts atomically only if the held
153
153
  // range is still free (resource bookings) or seats remain (RSVPs), and
154
154
  // returns null when it lost.
155
- listBookings(filter?: { resourceId?: string; eventId?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
155
+ listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
156
156
  getBooking(id: string): Promise<Booking | null>;
157
157
  getBookingByToken(token: string): Promise<Booking | null>;
158
158
  createBooking(
@@ -2,7 +2,7 @@ import { syncDueLive } from './live/service';
2
2
  import type { ContentRepository } from './repo/types';
3
3
  import { processDueDeliveries } from './webhooks';
4
4
  import { dispatchEmail } from './email/service';
5
- import { sendDueReminders } from './booking/service';
5
+ import { decideOverdue, sendDueReminders } from './booking/service';
6
6
  import { runPushJobs } from './push/service';
7
7
 
8
8
  // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
@@ -14,6 +14,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
14
14
  publish: () => repo.publishDuePosts(startedAt),
15
15
  webhooks: () => processDueDeliveries(repo),
16
16
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
17
+ // pending bookings past their approval deadline (Calendar settings → Bookings)
18
+ approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
17
19
  email: () => dispatchEmail(repo, locals, 10),
18
20
  live: () => syncDueLive(repo, locals),
19
21
  // queued first-publish notices, starting-soon reminders, receipts, retention
package/src/lib/schema.ts CHANGED
@@ -314,6 +314,17 @@ export const EventCta = z.object({
314
314
  });
315
315
  export type EventCta = z.infer<typeof EventCta>;
316
316
 
317
+ export const RoomHold = z.object({
318
+ resourceIds: z.array(z.string()).default([]),
319
+ span: z.enum(['event', 'day']).default('event'),
320
+ before: z.number().int().min(0).max(1440).default(0), // setup minutes
321
+ after: z.number().int().min(0).max(1440).default(0), // cleanup minutes
322
+ whileDraft: z.boolean().default(false),
323
+ // bookings you chose to keep (share the room): no longer flagged as conflicts
324
+ keep: z.array(z.string()).default([]),
325
+ });
326
+ export type RoomHold = z.infer<typeof RoomHold>;
327
+
317
328
  export const CalEvent = z.object({
318
329
  id: z.string(),
319
330
  slug: z.string(),
@@ -355,6 +366,10 @@ export const CalEvent = z.object({
355
366
  autoConfirm: z.boolean().default(true),
356
367
  maxSeats: z.number().int().default(4),
357
368
  }).default({}),
369
+ // rooms/people this event uses: they can't be booked while it happens (or
370
+ // all day). Computed into availability, never stored as bookings. Drafts
371
+ // only hold when whileDraft is on; cancelled events never hold.
372
+ roomHold: RoomHold.default({}),
358
373
  startAt: z.number(),
359
374
  endAt: z.number(),
360
375
  translations: DocTranslations,
@@ -444,6 +459,36 @@ export type BookingQuestion = z.infer<typeof BookingQuestion>;
444
459
  export const BookingMode = z.enum(['slots', 'range', 'day']);
445
460
  export type BookingMode = z.infer<typeof BookingMode>;
446
461
 
462
+ // Booking approval: "auto" confirms at once, "manual" holds every booking for
463
+ // a decision, "rules" confirms unless one of the rules flags it. A link can
464
+ // follow the site default ("default") or pick its own. The deadline decides
465
+ // pending bookings nobody answered (0 hours = keep waiting).
466
+ export const ApprovalMode = z.enum(['auto', 'rules', 'manual']);
467
+ export type ApprovalMode = z.infer<typeof ApprovalMode>;
468
+ export const ApprovalRules = z.object({
469
+ // 0 = rule off
470
+ longerThan: z.number().int().min(0).default(0), // minutes
471
+ firstTime: z.boolean().default(false),
472
+ moreSeats: z.number().int().min(0).default(0),
473
+ deadlineHours: z.number().int().min(0).max(24 * 60).default(0),
474
+ deadlineAction: z.enum(['approve', 'decline']).default('approve'),
475
+ });
476
+ export type ApprovalRules = z.infer<typeof ApprovalRules>;
477
+
478
+ // Site-wide calendar preferences (Calendar settings → Defaults / Bookings)
479
+ export const CalendarSettings = z.object({
480
+ // first column of every month grid: 0 = Sunday, 1 = Monday
481
+ weekStart: z.union([z.literal(0), z.literal(1)]).default(0),
482
+ // calendar new events land in (quick add, New event); '' = none
483
+ defaultCalendarId: z.string().default(''),
484
+ // '' = not chosen in the admin yet → acms({ calendar.bookings.approval }) → auto
485
+ approval: z.union([ApprovalMode, z.literal('')]).default(''),
486
+ approvalRules: ApprovalRules.default({}),
487
+ // show bookings on the Schedule grid
488
+ showBookings: z.boolean().default(true),
489
+ });
490
+ export type CalendarSettings = z.infer<typeof CalendarSettings>;
491
+
447
492
  export const BookingLink = z.object({
448
493
  id: z.string(),
449
494
  resourceId: z.string(),
@@ -466,6 +511,9 @@ export const BookingLink = z.object({
466
511
  hours: WeeklyHours.nullable().default(null),
467
512
  // confirmed at once (and on the calendar) vs. held as pending for approval
468
513
  autoConfirm: z.boolean().default(true),
514
+ // supersedes autoConfirm: 'default' follows Calendar settings → Bookings
515
+ approval: z.union([ApprovalMode, z.literal('default')]).default('default'),
516
+ approvalRules: ApprovalRules.default({}),
469
517
  visibility: z.enum(['public', 'unlisted']).default('public'),
470
518
  askPhone: z.boolean().default(false),
471
519
  questions: z.array(BookingQuestion).default([]),
@@ -584,6 +632,7 @@ export function normalizeRedirectPath(input: string): string {
584
632
 
585
633
  export const Settings = z.object({
586
634
  live: LiveConfig.default({}),
635
+ calendar: CalendarSettings.default({}),
587
636
  // push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
588
637
  push: PushSettings.default({}),
589
638
  siteTitle: z.string().default('My Site'),
@@ -1,10 +1,12 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
2
  import type { z } from 'zod';
3
3
  import type { ContentRepository } from './repo/types';
4
- import type { Settings } from './schema';
4
+ import { authorSlug, type Settings, type User } from './schema';
5
+ import { htmlToPlaintext } from './plaintext';
5
6
  import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
6
7
  import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
7
8
  import type { Theme } from './theme';
9
+ import { themeAssetUrl } from './theme-assets';
8
10
  import { CONTENT, collectionUrl } from './content';
9
11
  import { formatDate, siteLocale } from './dates';
10
12
  import { upcomingEvents } from './calendar/cards';
@@ -33,7 +35,9 @@ export interface EventItem {
33
35
  /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
34
36
  cta: { label: string; href: string } | null;
35
37
  }
36
- export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[] }
38
+ export interface PostAuthor { name: string; url: string; avatar: string }
39
+ // excerpt: the post's own, else the first ~160 characters of its text
40
+ export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[]; authors: PostAuthor[] }
37
41
  export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
38
42
 
39
43
  /** apply a theme migration when the stored instance is older than the definition */
@@ -103,10 +107,14 @@ function resolveLink(value: unknown, refs: RefMap, lp: Lp = same): LinkValue {
103
107
  return { label: String(v.label ?? ''), href, external: /^https?:\/\//i.test(href) };
104
108
  }
105
109
 
106
- function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = same): unknown {
110
+ type Asset = (path: string) => string;
111
+ const noAsset: Asset = () => '';
112
+ function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = same, asset: Asset = noAsset): unknown {
107
113
  switch (field.type) {
108
114
  case 'richtext': return sanitizeRichText(String(value ?? ''));
109
- case 'url': case 'image': return safeUrl(value);
115
+ // images may name a file the theme ships: "assets/hero.jpg" → its built URL
116
+ case 'image': return typeof value === 'string' && /^(\.\/)?assets\//.test(value) ? asset(value) : safeUrl(value);
117
+ case 'url': return safeUrl(value);
110
118
  case 'link': return resolveLink(value, refs, lp);
111
119
  default: return value;
112
120
  }
@@ -145,9 +153,15 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
145
153
  const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
146
154
  let posts = await repo.listPublishedPosts(collection);
147
155
  if (cfg.tag) posts = posts.filter((p) => p.tags.some((t) => t.toLowerCase() === cfg.tag!.toLowerCase()));
148
- return posts.slice(0, limit).map((raw): PostItem => { const p = lang ? localizeDoc(raw, lang) : raw; return {
149
- title: p.title, url: lp(collectionUrl(p.collection, p.slug)), excerpt: p.excerpt, image: safeUrl(p.cover), imageAlt: p.coverAlt,
156
+ const shown = posts.slice(0, limit);
157
+ // public byline only: name, profile URL, avatar — never emails or roles
158
+ const users = shown.some((p) => p.authorIds.length || p.authorId) ? new Map((await repo.listUsers()).map((u) => [u.id, u])) : new Map();
159
+ return shown.map((raw): PostItem => { const p = lang ? localizeDoc(raw, lang) : raw; return {
160
+ title: p.title, url: lp(collectionUrl(p.collection, p.slug)), excerpt: p.excerpt || autoExcerpt(p.bodyHtml), image: safeUrl(p.cover), imageAlt: p.coverAlt,
150
161
  date: formatDate(p.publishedAt, settings, 'short'), publishedAt: p.publishedAt ?? null, tags: p.tags,
162
+ authors: (raw.authorIds.length ? raw.authorIds : raw.authorId ? [raw.authorId] : [])
163
+ .map((id) => users.get(id)).filter((u): u is User => Boolean(u))
164
+ .map((u) => ({ name: u.name, url: lp(`/author/${authorSlug(u)}`), avatar: safeUrl(u.avatar) })),
151
165
  }; });
152
166
  }
153
167
  if (!bookingsEnabled()) return [];
@@ -160,6 +174,15 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
160
174
  });
161
175
  }
162
176
 
177
+ // first ~160 characters of the body, cut at a word, for posts without an excerpt
178
+ export function autoExcerpt(html: string, max = 160): string {
179
+ const text = htmlToPlaintext(html);
180
+ if (text.length <= max) return text;
181
+ const cut = text.slice(0, max + 1);
182
+ const at = cut.lastIndexOf(' ');
183
+ return `${(at > max * 0.6 ? cut.slice(0, at) : text.slice(0, max)).replace(/[\s,.;:!?—–-]+$/, '')}…`;
184
+ }
185
+
163
186
  export interface ResolveContext { repo: ContentRepository; settings: Settings; now: number }
164
187
 
165
188
  /** stored data → component props: defaults filled, refs/sources resolved, richtext sanitized */
@@ -170,6 +193,8 @@ export async function resolveSectionData(def: Definition, stored: Record<string,
170
193
  const refMap = refs.size ? await resolveRefs(refs, ctx.repo) : new Map<string, string>();
171
194
  const lang = isExtraLocale(ctx.settings.locale) ? ctx.settings.locale : '';
172
195
  const lp: Lp = lang ? (p) => localePath(p, lang) : same;
196
+ // 'ink' = DEFAULT_THEME (importing it would load every theme template)
197
+ const asset: Asset = (p) => themeAssetUrl(ctx.settings.activeTheme || 'ink', p);
173
198
  const out: Record<string, unknown> = {};
174
199
  await Promise.all(Object.entries(def.fields).map(async ([key, field]) => {
175
200
  const value = data[key];
@@ -178,13 +203,13 @@ export async function resolveSectionData(def: Definition, stored: Record<string,
178
203
  out[key] = (value as Record<string, unknown>[]).map((row) => {
179
204
  const item: Record<string, unknown> = {};
180
205
  for (const [k, sub] of Object.entries(field.fields)) {
181
- item[k] = resolveScalar(sub as FieldDef, row[k], refMap, lp);
206
+ item[k] = resolveScalar(sub as FieldDef, row[k], refMap, lp, asset);
182
207
  if (sub.type === 'image') item[`${k}Focus`] = safeFocus(row[focusKey(k)]);
183
208
  }
184
209
  return item;
185
210
  });
186
211
  } else {
187
- out[key] = resolveScalar(field, value, refMap, lp);
212
+ out[key] = resolveScalar(field, value, refMap, lp, asset);
188
213
  // images also hand over their focal point, ready for object-position
189
214
  if (field.type === 'image') out[`${key}Focus`] = safeFocus(stored[focusKey(key)]);
190
215
  }
@@ -0,0 +1,34 @@
1
+ // The `site` prop given to a theme's templates/header.astro and
2
+ // templates/footer.astro. Everything is resolved for the current request:
3
+ // links are already localized, `current` marks the page you're on, languages
4
+ // carry their own names ("Español"), and social links carry inline SVG icons.
5
+ //
6
+ // Core behaviors keep working inside a custom header through data hooks:
7
+ // [data-search-open] opens the search dialog (when site.search is set)
8
+ // [data-scheme-toggle] flips light/dark (when site.schemeToggle is true)
9
+ // [data-menu-open] opens the phone menu sheet (built from site.links)
10
+ // [data-live-header] (hidden) becomes the "Live" link during a stream
11
+ export interface ChromeLink {
12
+ label: string;
13
+ url: string;
14
+ current: boolean;
15
+ external: boolean;
16
+ }
17
+
18
+ export interface SiteChrome {
19
+ title: string;
20
+ tagline: string;
21
+ logo: string; // '' when the site has no logo
22
+ homeUrl: string;
23
+ links: ChromeLink[]; // header navigation
24
+ footerLinks: ChromeLink[];
25
+ social: (ChromeLink & { icon: string })[]; // icon: inline SVG ('' = text link)
26
+ readLinks: ChromeLink[]; // collections, search, RSS
27
+ footerHtml: string; // Settings → footer text, rendered
28
+ languages: { code: string; label: string; url: string; current: boolean }[]; // [] when single-language
29
+ search: { url: string; label: string } | null;
30
+ schemeToggle: boolean;
31
+ liveHeader: boolean;
32
+ headerStyle: string; // theme token `header`
33
+ footerStyle: string; // theme token `footer`
34
+ }
@@ -0,0 +1,16 @@
1
+ // Files a theme ships under assets/, as built URLs. Kept apart from theme.ts
2
+ // (which loads every .astro template) so section data resolution can use it
3
+ // without pulling the templates in.
4
+ import * as user from 'virtual:acms-user-themes';
5
+
6
+ const builtin = import.meta.glob(['../themes/*/assets/**/*', '!**/.img-subir-originals/**'], { eager: true, query: '?url', import: 'default' }) as Record<string, string>;
7
+
8
+ // "assets/hero.jpg" (relative to the theme folder) → the built asset URL, so
9
+ // section image fields and defaults can point at files the theme ships.
10
+ // '' when the theme has no such file. Project themes win over built-ins.
11
+ export function themeAssetUrl(slug: string, path: string): string {
12
+ const file = path.replace(/^\.\//, '');
13
+ if (!/^assets\/[^?#]+$/.test(file) || file.includes('..')) return '';
14
+ const all = { ...builtin, ...((user as { assets?: Record<string, string> }).assets ?? {}) };
15
+ return Object.entries(all).find(([p]) => p.endsWith(`/themes/${slug}/${file}`))?.[1] ?? '';
16
+ }
package/src/lib/theme.ts CHANGED
@@ -83,6 +83,10 @@ export interface Theme {
83
83
  pageTemplates: Record<string, unknown>;
84
84
  // templates/event.astro replaces the built-in event page layout
85
85
  EventTemplate: unknown | null;
86
+ // templates/header.astro / footer.astro replace the site header and footer;
87
+ // they receive a `site` prop (lib/site-chrome.ts) with links already resolved
88
+ HeaderTemplate: unknown | null;
89
+ FooterTemplate: unknown | null;
86
90
  }
87
91
 
88
92
  interface ThemeJson { sections?: Record<string, unknown>; version?: string; name?: string; description?: string; tokens?: Record<string, unknown>; post?: Record<string, unknown> }
@@ -100,6 +104,8 @@ const homes = import.meta.glob('../themes/*/templates/home.astro', { eager: true
100
104
  const postTpls = import.meta.glob('../themes/*/templates/post-*.astro', { eager: true }) as Record<string, { default: unknown }>;
101
105
  const pageTpls = import.meta.glob('../themes/*/templates/page-*.astro', { eager: true }) as Record<string, { default: unknown }>;
102
106
  const eventTpls = import.meta.glob('../themes/*/templates/event.astro', { eager: true }) as Record<string, { default: unknown }>;
107
+ const headerTpls = import.meta.glob('../themes/*/templates/header.astro', { eager: true }) as Record<string, { default: unknown }>;
108
+ const footerTpls = import.meta.glob('../themes/*/templates/footer.astro', { eager: true }) as Record<string, { default: unknown }>;
103
109
  const migrationMods = {
104
110
  ...(import.meta.glob('../themes/*/migrations/*.mjs', { eager: true }) as Record<string, { default?: unknown }>),
105
111
  ...((user as { migrations?: Record<string, { default?: unknown }> }).migrations ?? {}),
@@ -126,7 +132,9 @@ function buildThemes(
126
132
  homeMap: Record<string, { default: unknown }>,
127
133
  postMap: Record<string, { default: unknown }>,
128
134
  pageMap: Record<string, { default: unknown }>,
129
- eventMap: Record<string, { default: unknown }> = {}
135
+ eventMap: Record<string, { default: unknown }> = {},
136
+ headerMap: Record<string, { default: unknown }> = {},
137
+ footerMap: Record<string, { default: unknown }> = {}
130
138
  ): Theme[] {
131
139
  return Object.entries(jsonMap).map(([path, raw]) => {
132
140
  const slug = slugOf(path);
@@ -157,18 +165,22 @@ function buildThemes(
157
165
  postTemplates: templatesFor(slug, postMap, 'post'),
158
166
  pageTemplates: templatesFor(slug, pageMap, 'page'),
159
167
  EventTemplate: Object.entries(eventMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
168
+ HeaderTemplate: Object.entries(headerMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
169
+ FooterTemplate: Object.entries(footerMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
160
170
  };
161
171
  });
162
172
  }
163
173
 
164
- const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls);
174
+ const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls, headerTpls, footerTpls);
165
175
  const projectThemes = buildThemes(
166
176
  user.jsons as Record<string, ThemeJson>,
167
177
  user.csses,
168
178
  user.homes,
169
179
  user.postTpls,
170
180
  user.pageTpls,
171
- (user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {}
181
+ (user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {},
182
+ (user as { headerTpls?: Record<string, { default: unknown }> }).headerTpls ?? {},
183
+ (user as { footerTpls?: Record<string, { default: unknown }> }).footerTpls ?? {}
172
184
  );
173
185
  const projectSlugs = new Set(projectThemes.map((t) => t.slug));
174
186
 
@@ -176,6 +188,8 @@ const themes: Theme[] = [...builtins.filter((t) => !projectSlugs.has(t.slug)), .
176
188
  (a, b) => (a.slug === DEFAULT_THEME ? -1 : b.slug === DEFAULT_THEME ? 1 : a.name.localeCompare(b.name))
177
189
  );
178
190
 
191
+ export { themeAssetUrl } from './theme-assets';
192
+
179
193
  export function listThemes(): Theme[] {
180
194
  return themes;
181
195
  }
package/src/middleware.ts CHANGED
@@ -5,7 +5,7 @@ import { getRepo } from './lib/repo';
5
5
  import { CONTENT } from './lib/content';
6
6
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
7
7
  import { dispatchEmail } from './lib/email/service';
8
- import { sendDueReminders } from './lib/booking/service';
8
+ import { decideOverdue, sendDueReminders } from './lib/booking/service';
9
9
  import { PUSH } from './lib/push/config';
10
10
  import { processPushQueue, runPushJobs } from './lib/push/service';
11
11
  import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
@@ -110,7 +110,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
110
110
  if (wait) wait(retries); else await retries;
111
111
  // booking reminders (Node has no cron): site URL, else this request's origin
112
112
  const reminders = repo.getSettings()
113
- .then((s) => sendDueReminders(repo, { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now }))
113
+ .then(async (s) => {
114
+ const flow = { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now };
115
+ await sendDueReminders(repo, flow);
116
+ await decideOverdue(repo, flow);
117
+ })
114
118
  .catch(() => {});
115
119
  if (wait) wait(reminders); else await reminders;
116
120
  // push jobs (Node has no cron): never awaited here, errors are logged