@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
@@ -1,3 +1,4 @@
1
+ import { FormStore, formInsert, submissionInsert } from '../forms/store';
1
2
  import { LiveStore } from '../live/store';
2
3
  import { PushStore } from '../push/store';
3
4
  import { EditorStore } from '../editor/store';
@@ -5,7 +6,7 @@ import { LiveConfig } from '../live/types';
5
6
  import { ApiError } from '../api';
6
7
  import { EmailStore, bookingJobs } from '../email/store';
7
8
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
8
- import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
9
+ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
9
10
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
10
11
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
11
12
  import { cleanRrule } from '../calendar/rrule';
@@ -157,7 +158,15 @@ function rowToResource(r: Row): Resource {
157
158
  capacity: r.capacity === null || r.capacity === undefined ? null : Number(r.capacity),
158
159
  amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
159
160
  busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
160
- showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', order: Number(r.ord ?? 0),
161
+ showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', placeId: r.place_id ?? '', order: Number(r.ord ?? 0),
162
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
163
+ });
164
+ }
165
+ function rowToPlace(r: Row): Place {
166
+ const n = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number(v));
167
+ return Place.parse({
168
+ id: r.id, name: r.name, address: r.address ?? '', url: r.url ?? '', lat: n(r.lat), lng: n(r.lng), notes: r.notes ?? '',
169
+ isDefault: Boolean(r.is_default), mapImage: r.map_image ?? '', mapImageDark: r.map_image_dark ?? '', order: Number(r.ord ?? 0),
161
170
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
162
171
  });
163
172
  }
@@ -187,6 +196,7 @@ function rowToBooking(r: Row): Booking {
187
196
  id: r.id, kind: r.kind ?? 'booking', resourceId: r.resource_id ?? '', linkId: r.link_id ?? '', eventId: r.event_id ?? '',
188
197
  status: r.status, start: Number(r.start_at), end: Number(r.end_at), holdStart: Number(r.hold_start), holdEnd: Number(r.hold_end),
189
198
  timezone: r.timezone ?? '', name: r.name ?? '', email: r.email ?? '', phone: r.phone ?? '', seats: Number(r.seats ?? 1),
199
+ formResponse: r.form_response ? json(r.form_response, undefined) : undefined,
190
200
  answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web',
191
201
  notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
192
202
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
@@ -248,12 +258,14 @@ export class SqlRepository implements ContentRepository {
248
258
  private driver!: SqlDriver;
249
259
  private ready: Promise<void>;
250
260
  readonly email: EmailStore;
261
+ readonly forms: FormStore;
251
262
  readonly live: LiveStore;
252
263
  readonly push: PushStore;
253
264
  readonly editor: EditorStore;
254
265
 
255
266
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
256
267
  this.ready = this.init(driver);
268
+ this.forms = new FormStore(async () => { await this.ready; return this.driver; });
257
269
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
258
270
  this.push = new PushStore(async () => { await this.ready; return this.driver; });
259
271
  this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
@@ -424,7 +436,7 @@ export class SqlRepository implements ContentRepository {
424
436
  await this.ready;
425
437
  // One SELECT snapshots bookings and their retry ledger together. No UI
426
438
  // list limit; include tombstones for deleted bookings as well.
427
- const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','notes','manage_token','source','reminder_sent_at','notification_version','created_at','updated_at'];
439
+ const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','reminder_sent_at','notification_version','created_at','updated_at'];
428
440
  const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
429
441
  UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
430
442
  return {
@@ -931,14 +943,88 @@ export class SqlRepository implements ContentRepository {
931
943
  }
932
944
 
933
945
 
946
+ async setEventLocation(id: string, patch: Parameters<ContentRepository['setEventLocation']>[1]) {
947
+ await this.ready;
948
+ const current = await this.getEvent(id);
949
+ if (!current) return;
950
+ const stmts: SqlStatement[] = [];
951
+ if (patch.live) {
952
+ stmts.push({ sql: 'UPDATE events SET location = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(EventLocation.parse(patch.live)), Date.now(), id] });
953
+ if (patch.notify && current.status === 'published') {
954
+ // same fan-out as updateEvent's location change, minus the time math
955
+ stmts.push(
956
+ { sql: `UPDATE bookings SET notification_version=notification_version+1 WHERE event_id=? AND kind='rsvp' AND status IN ('pending','confirmed')`, args: [id] },
957
+ ...bookingJobs(id, 'updated').map((st) => ({ ...st, sql: st.sql.replace('b.id = ?', `b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')`) })),
958
+ );
959
+ }
960
+ }
961
+ if (patch.draft && current.draft) {
962
+ const draft = { ...current.draft, location: EventLocation.parse(patch.draft) };
963
+ stmts.push({ sql: 'UPDATE events SET draft = ? WHERE id = ?', args: [JSON.stringify(draft), id] });
964
+ }
965
+ if (!stmts.length) return;
966
+ await this.driver.batch(stmts);
967
+ if (patch.live) await this.syncEventIndex(id);
968
+ }
969
+
970
+ // ---- places ----
971
+ private placeInsert(p: Place): SqlStatement {
972
+ return {
973
+ sql: `INSERT INTO places (id, name, address, url, lat, lng, notes, is_default, map_image, map_image_dark, ord, created_at, updated_at)
974
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
975
+ args: [p.id, p.name, p.address, p.url, p.lat, p.lng, p.notes, p.isDefault ? 1 : 0, p.mapImage, p.mapImageDark, p.order, p.createdAt, p.updatedAt],
976
+ };
977
+ }
978
+ async listPlaces() {
979
+ await this.ready;
980
+ return (await this.driver.all('SELECT * FROM places ORDER BY ord ASC, name COLLATE NOCASE ASC')).map(rowToPlace);
981
+ }
982
+ async getPlace(id: string) {
983
+ await this.ready;
984
+ const r = await this.first('SELECT * FROM places WHERE id = ?', [id]);
985
+ return r ? rowToPlace(r) : null;
986
+ }
987
+ async createPlace(data: Parameters<ContentRepository['createPlace']>[0]) {
988
+ await this.ready;
989
+ const now = Date.now();
990
+ const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM places');
991
+ const p = Place.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
992
+ await this.driver.batch([
993
+ ...(p.isDefault ? [{ sql: 'UPDATE places SET is_default = 0 WHERE is_default = 1', args: [] }] : []),
994
+ this.placeInsert(p),
995
+ ]);
996
+ return p;
997
+ }
998
+ async updatePlace(id: string, data: Parameters<ContentRepository['updatePlace']>[1]) {
999
+ await this.ready;
1000
+ const cur = await this.getPlace(id);
1001
+ if (!cur) return;
1002
+ const p = Place.parse({ ...cur, ...data, id, createdAt: cur.createdAt, updatedAt: Date.now() });
1003
+ await this.driver.batch([
1004
+ ...(p.isDefault ? [{ sql: 'UPDATE places SET is_default = 0 WHERE id != ? AND is_default = 1', args: [id] }] : []),
1005
+ {
1006
+ sql: `UPDATE places SET name = ?, address = ?, url = ?, lat = ?, lng = ?, notes = ?, is_default = ?, map_image = ?, map_image_dark = ?,
1007
+ ord = ?, updated_at = ? WHERE id = ?`,
1008
+ args: [p.name, p.address, p.url, p.lat, p.lng, p.notes, p.isDefault ? 1 : 0, p.mapImage, p.mapImageDark, p.order, p.updatedAt, id],
1009
+ },
1010
+ ]);
1011
+ }
1012
+ async deletePlace(id: string) {
1013
+ await this.ready;
1014
+ await this.driver.batch([
1015
+ { sql: 'DELETE FROM places WHERE id = ?', args: [id] },
1016
+ { sql: "UPDATE resources SET place_id = '' WHERE place_id = ?", args: [id] },
1017
+ ]);
1018
+ }
1019
+
934
1020
  // ---- resources + booking links ----
935
1021
  private resourceInsert(r: Resource): SqlStatement {
936
1022
  return {
937
1023
  sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
938
- notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1024
+ notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
939
1025
  args: [r.id, r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
940
1026
  JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
941
- r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt],
1027
+ r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId],
942
1028
  };
943
1029
  }
944
1030
  private linkInsert(l: BookingLink): SqlStatement {
@@ -955,9 +1041,9 @@ export class SqlRepository implements ContentRepository {
955
1041
  private bookingInsert(b: Booking): SqlStatement {
956
1042
  return {
957
1043
  sql: `INSERT INTO bookings (id, kind, resource_id, link_id, event_id, status, start_at, end_at, hold_start, hold_end, timezone, name, email, phone,
958
- seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1044
+ seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
959
1045
  args: [b.id, b.kind, b.resourceId, b.linkId, b.eventId, b.status, b.start, b.end, b.holdStart, b.holdEnd, b.timezone, b.name, b.email, b.phone,
960
- b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion],
1046
+ b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null],
961
1047
  };
962
1048
  }
963
1049
  async listResources() {
@@ -989,10 +1075,10 @@ export class SqlRepository implements ContentRepository {
989
1075
  const r = Resource.parse({ ...cur, ...data, id, updatedAt: Date.now() });
990
1076
  await this.driver.run(
991
1077
  `UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
992
- overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, updated_at = ? WHERE id = ?`,
1078
+ overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?, updated_at = ? WHERE id = ?`,
993
1079
  [r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
994
1080
  JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
995
- r.feedToken, r.updatedAt, id]
1081
+ r.feedToken, r.placeId, r.updatedAt, id]
996
1082
  );
997
1083
  }
998
1084
  async reorderResources(ids: string[]) {
@@ -1615,7 +1701,7 @@ export class SqlRepository implements ContentRepository {
1615
1701
  // Suppression and re-enabling share the restore transaction. A failed
1616
1702
  // restore cannot leave notifications disabled or emit phantom deletes.
1617
1703
  { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1618
- ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records'].map((t) => ({
1704
+ ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records', 'places', 'forms', 'form_submissions'].map((t) => ({
1619
1705
  sql: `DELETE FROM ${t}`,
1620
1706
  })),
1621
1707
  ...(content.calendars ?? []).map((c) => ({
@@ -1635,7 +1721,10 @@ export class SqlRepository implements ContentRepository {
1635
1721
  JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
1636
1722
  ],
1637
1723
  })),
1724
+ ...(content.forms ?? []).map(formInsert),
1725
+ ...(content.formSubmissions ?? []).map(submissionInsert),
1638
1726
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
1727
+ ...(content.places ?? []).map((p) => this.placeInsert(p)),
1639
1728
  ...(content.bookingLinks ?? []).map((l) => this.linkInsert(l)),
1640
1729
  ...(content.bookings ?? []).map((b) => this.bookingInsert(b)),
1641
1730
  ...(content.bookingRequests ?? []).map(r => ({ sql: 'INSERT INTO mobile_booking_requests (key_hash,request_hash,booking_id,created_at) VALUES (?,?,?,?)', args: [r.keyHash,r.requestHash,r.bookingId,r.createdAt] })),
@@ -1839,6 +1928,7 @@ export class SqlRepository implements ContentRepository {
1839
1928
  activeTheme: r?.active_theme ?? '', designOverrides: JSON.parse(String(r?.design_overrides ?? '{}')),
1840
1929
  sectionPages: JSON.parse(String(r?.section_pages ?? '{}')), designRevision: Number(r?.design_revision ?? 0),
1841
1930
  siteLogo: r?.site_logo ?? '',
1931
+ siteLogoDark: r?.site_logo_dark ?? '',
1842
1932
  siteUrl: r?.site_url ?? '', siteFavicon: r?.site_favicon ?? '', siteCover: r?.site_cover ?? '',
1843
1933
  announcement: JSON.parse(String(r?.announcement ?? '{}')),
1844
1934
  analytics: JSON.parse(String(r?.analytics ?? '{}')),
@@ -1862,14 +1952,14 @@ export class SqlRepository implements ContentRepository {
1862
1952
  private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
1863
1953
  const columns: Record<string, string> = {
1864
1954
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1865
- activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1955
+ activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteLogoDark:'site_logo_dark', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1866
1956
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1867
1957
  draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
1868
1958
  };
1869
1959
  const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
1870
1960
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1871
1961
  if (!entries.length) return;
1872
- const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
1962
+ const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
1873
1963
  return { sql: `UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
1874
1964
  args: entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v) };
1875
1965
  }
@@ -1919,8 +2009,8 @@ export class SqlRepository implements ContentRepository {
1919
2009
  const statements: SqlStatement[] = [
1920
2010
  { sql: 'INSERT INTO design_versions (id,created_at,author,summary,snapshot) VALUES (?,?,?,?,?)', args: [crypto.randomUUID(), Date.now(), opts.author ?? '', opts.summary ?? '', JSON.stringify(designSnapshot(current, liveNav))] },
1921
2011
  { sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
1922
- { sql: 'UPDATE settings SET active_theme=?,design_overrides=?,section_pages=?,site_logo=?,site_favicon=?,site_cover=?,draft_design=NULL WHERE id=1',
1923
- args: [design.activeTheme, JSON.stringify(design.designOverrides), JSON.stringify(design.sectionPages), design.siteLogo, design.siteFavicon, design.siteCover] },
2012
+ { sql: 'UPDATE settings SET active_theme=?,design_overrides=?,section_pages=?,site_logo=?,site_logo_dark=?,site_favicon=?,site_cover=?,draft_design=NULL WHERE id=1',
2013
+ args: [design.activeTheme, JSON.stringify(design.designOverrides), JSON.stringify(design.sectionPages), design.siteLogo, design.siteLogoDark, design.siteFavicon, design.siteCover] },
1924
2014
  ];
1925
2015
  if (nav) statements.push({ sql: 'DELETE FROM nav_items' }, ...nav.map((n,i) => ({
1926
2016
  sql: 'INSERT INTO nav_items (id,label,url,area,ord) VALUES (?,?,?,?,?)', args: [n.id.startsWith('tmp-') ? crypto.randomUUID() : n.id,n.label,n.url,n.area,i],
@@ -1,3 +1,5 @@
1
+ import type { FormStore } from '../forms/store';
2
+ import type { SavedForm, FormSubmission } from '../forms/schema';
1
3
  import type { LiveStore } from '../live/store';
2
4
  import type { BookingRequest } from '../backup/content';
3
5
  import type { EmailStore } from '../email/store';
@@ -8,7 +10,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
8
10
  export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
9
11
  export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
10
12
 
11
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus } from '../schema';
13
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation } from '../schema';
12
14
 
13
15
  export interface Stats {
14
16
  publishedPages: number;
@@ -48,6 +50,7 @@ export type EventUpdate = Partial<
48
50
  // this — never a concrete adapter.
49
51
  export interface ContentRepository {
50
52
  readonly email: EmailStore;
53
+ readonly forms: FormStore;
51
54
  readonly live: LiveStore;
52
55
  readonly push: PushStore;
53
56
  readonly editor: EditorStore;
@@ -131,6 +134,19 @@ export interface ContentRepository {
131
134
  applyEventDraft(id: string): Promise<void>;
132
135
  discardEventDraft(id: string): Promise<void>;
133
136
  deleteEvent(id: string): Promise<void>;
137
+ // saved-place sync (lib/maps/places.ts): rewrite just the location of the
138
+ // live row and/or the staged draft, leaving every other field — and the
139
+ // draft itself — alone. notify = RSVP guests of a published event get an
140
+ // "event updated" email (the address changed).
141
+ setEventLocation(id: string, patch: { live?: EventLocation; draft?: EventLocation; notify?: boolean }): Promise<void>;
142
+
143
+ // saved places. Only one can be the default (saving one as default clears
144
+ // the others). Deleting a place unlinks its rooms; events keep their copy.
145
+ listPlaces(): Promise<Place[]>;
146
+ getPlace(id: string): Promise<Place | null>;
147
+ createPlace(data: Partial<Omit<Place, 'id' | 'createdAt' | 'updatedAt' | 'order'>> & { name: string }): Promise<Place>;
148
+ updatePlace(id: string, data: Partial<Omit<Place, 'id' | 'createdAt' | 'updatedAt'>>): Promise<void>;
149
+ deletePlace(id: string): Promise<void>;
134
150
 
135
151
  // resources (rooms, people, vans…) and their booking links. Deleting a
136
152
  // resource deletes its links; its bookings stay as history.
@@ -265,6 +281,10 @@ export interface ContentRepository {
265
281
  bookingLinks?: BookingLink[];
266
282
  bookings?: Booking[];
267
283
  bookingRequests?: BookingRequest[];
284
+ // added with maps support; older backups restore with none
285
+ places?: Place[];
286
+ forms?: SavedForm[];
287
+ formSubmissions?: FormSubmission[];
268
288
  }): Promise<void>;
269
289
 
270
290
  // imports (lib/import/apply.ts drives these). writeImported is the
@@ -292,7 +312,7 @@ export interface ContentRepository {
292
312
  updateSettings(data: Partial<Settings>): Promise<void>;
293
313
  // Design-screen staging: save stages into settings.draftDesign, apply
294
314
  // copies staged → live design fields and clears, discard just clears
295
- saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
315
+ saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteLogoDark' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
296
316
  navItems?: { id: string; label: string; url: string; area: NavItem['area'] }[];
297
317
  shareEpoch?: string;
298
318
  }): Promise<void>;
package/src/lib/schema.ts CHANGED
@@ -3,6 +3,7 @@ import { PushSettings } from './push/types';
3
3
  import { SectionPages } from '../theme-sdk/contract.mjs';
4
4
  import { AiConfig, AgentConfig } from './ai-schema';
5
5
  import { z } from 'zod';
6
+ import { FormDefinition, FormResponse } from './forms/schema';
6
7
 
7
8
  // ---- Widgets -------------------------------------------------------------
8
9
  // Each widget type has a zod config schema; pages store an ordered array of
@@ -304,6 +305,18 @@ export const EventLocation = z.object({
304
305
  address: z.string().default(''),
305
306
  // a map link or an online-meeting URL
306
307
  url: z.string().default(''),
308
+ // the pin, when known (picked from suggestions, parsed from a map link,
309
+ // or copied from a saved place); null = no pin, so no map image
310
+ lat: z.number().nullable().default(null),
311
+ lng: z.number().nullable().default(null),
312
+ // the saved place this was picked from ('' = typed by hand). The fields
313
+ // above are a copy; saving the place refreshes upcoming events' copies.
314
+ placeId: z.string().default(''),
315
+ // show the static map image on the public event page
316
+ showMap: z.boolean().default(true),
317
+ // cached static map images (media URLs) for the current pin; '' = none
318
+ mapImage: z.string().default(''),
319
+ mapImageDark: z.string().default(''),
307
320
  });
308
321
  export type EventLocation = z.infer<typeof EventLocation>;
309
322
 
@@ -365,6 +378,7 @@ export const CalEvent = z.object({
365
378
  capacity: z.number().int().default(0),
366
379
  autoConfirm: z.boolean().default(true),
367
380
  maxSeats: z.number().int().default(4),
381
+ form: FormDefinition.optional(),
368
382
  }).default({}),
369
383
  // rooms/people this event uses: they can't be booked while it happens (or
370
384
  // all day). Computed into availability, never stored as bookings. Drafts
@@ -439,12 +453,35 @@ export const Resource = z.object({
439
453
  showOnCalendar: z.boolean().default(false),
440
454
  // secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
441
455
  feedToken: z.string().default(''),
456
+ // the saved place this room is in ('' = none)
457
+ placeId: z.string().default(''),
442
458
  order: z.number().default(0),
443
459
  createdAt: z.number(),
444
460
  updatedAt: z.number(),
445
461
  });
446
462
  export type Resource = z.infer<typeof Resource>;
447
463
 
464
+ // A saved place (HQ, the park pavilion…): picked in an event's Where field,
465
+ // it copies its details into the event. Rooms can belong to one (resource.placeId).
466
+ export const Place = z.object({
467
+ id: z.string(),
468
+ name: z.string(),
469
+ address: z.string().default(''),
470
+ url: z.string().default(''),
471
+ lat: z.number().nullable().default(null),
472
+ lng: z.number().nullable().default(null),
473
+ // shown to editors only ("Use the side door")
474
+ notes: z.string().default(''),
475
+ // new events start here
476
+ isDefault: z.boolean().default(false),
477
+ mapImage: z.string().default(''),
478
+ mapImageDark: z.string().default(''),
479
+ order: z.number().default(0),
480
+ createdAt: z.number(),
481
+ updatedAt: z.number(),
482
+ });
483
+ export type Place = z.infer<typeof Place>;
484
+
448
485
  export const QUESTION_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date'] as const;
449
486
  export const BookingQuestion = z.object({
450
487
  id: z.string(),
@@ -549,6 +586,7 @@ export const Booking = z.object({
549
586
  phone: z.string().default(''),
550
587
  seats: z.number().int().default(1),
551
588
  answers: z.record(z.string()).default({}),
589
+ formResponse: FormResponse.optional(),
552
590
  notes: z.string().default(''), // staff-only
553
591
  manageToken: z.string().default(''),
554
592
  source: z.enum(['web', 'admin', 'api', 'agent']).default('web'),
@@ -650,6 +688,7 @@ export const Settings = z.object({
650
688
  sectionPages: SectionPages.default({}),
651
689
  designRevision: z.number().int().nonnegative().default(0),
652
690
  siteLogo: z.string().default(''),
691
+ siteLogoDark: z.string().default(''),
653
692
  // SEO foundation: canonical origin for absolute URLs (sitemap, feeds, OG
654
693
  // tags — empty falls back to the request origin), favicon, and the default
655
694
  // social-share image used when a post has no cover
@@ -19,6 +19,7 @@ export interface SiteChrome {
19
19
  title: string;
20
20
  tagline: string;
21
21
  logo: string; // '' when the site has no logo
22
+ logoDark: string; // '' when unset; themes can fall back to logo
22
23
  homeUrl: string;
23
24
  links: ChromeLink[]; // header navigation
24
25
  footerLinks: ChromeLink[];
package/src/lib/umami.ts CHANGED
@@ -21,6 +21,7 @@ export interface TrafficSummary {
21
21
  }
22
22
 
23
23
  const TTL = 10 * 60_000;
24
+ const CACHE_MAX = 32;
24
25
  const cache = new Map<string, { at: number; value: TrafficSummary | null }>();
25
26
 
26
27
  function config(settings: Settings) {
@@ -97,7 +98,13 @@ export async function getTraffic(settings: Settings, days = 30): Promise<Traffic
97
98
  } catch {
98
99
  value = null;
99
100
  }
100
- cache.set(cacheKey, { at: Date.now(), value });
101
+ // Expiry controls freshness, not retention. Bound old site/range keys in
102
+ // long-lived Node processes as well as short-lived Worker isolates.
103
+ const now = Date.now();
104
+ for (const [key, entry] of cache) if (now - entry.at >= TTL) cache.delete(key);
105
+ cache.delete(cacheKey);
106
+ cache.set(cacheKey, { at: now, value });
107
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value!);
101
108
  return value;
102
109
  }
103
110
 
@@ -1,11 +1,13 @@
1
1
  ---
2
+ import { csv } from '../../../lib/forms/service';
3
+ import { readForm } from '../../../lib/forms/http';
2
4
  import Admin from '../../../layouts/Admin.astro';
3
5
  import BookingsHead from '../../../components/calendar/BookingsHead.astro';
4
6
  import { eventHolds } from '../../../lib/booking/holds';
5
7
  import Modal from '../../../components/Modal.astro';
6
8
  import { getRepo } from '../../../lib/repo';
7
9
  import type { Booking, BookingStatus } from '../../../lib/schema';
8
- import { bookingsEnabled, eventUrl } from '../../../lib/calendar/config';
10
+ import { CALENDAR, bookingsEnabled, eventUrl } from '../../../lib/calendar/config';
9
11
  import { createBlock, createResourceBooking, rescheduleBooking, setBookingStatus, BookingError } from '../../../lib/booking/service';
10
12
  import { addDaysYmd, localMidnight, ymd } from '../../../lib/booking/availability';
11
13
  import { fmt } from '../../../lib/booking/view';
@@ -16,7 +18,7 @@ import { waitUntilFrom } from '../../../lib/webhooks';
16
18
  // Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
17
19
  // requests first, a Day view with one column per resource, a details panel
18
20
  // (?b=<id>) with private notes, plus Block time and Add booking.
19
- if (!bookingsEnabled()) return new Response(null, { status: 404 });
21
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
20
22
  const repo = await getRepo(Astro.locals);
21
23
  const settings = await repo.getSettings();
22
24
  const tz = settings.timezone || 'UTC';
@@ -43,10 +45,11 @@ let notice = Astro.url.searchParams.get('notice') ?? '';
43
45
  let error = '';
44
46
 
45
47
  if (Astro.request.method === 'POST') {
46
- const form = await Astro.request.formData();
48
+ const form = await readForm(Astro.request);
47
49
  const op = String(form.get('op'));
48
50
  const id = String(form.get('id') || '');
49
51
  try {
52
+ if (!bookingsEnabled() && ['block', 'add', 'reschedule'].includes(op)) throw new BookingError(400, 'Resource bookings are disabled.');
50
53
  if (op === 'status') {
51
54
  const b = await repo.getBooking(id);
52
55
  if (!b) throw new BookingError(404, 'Booking not found.');
@@ -104,9 +107,10 @@ if (Astro.request.method === 'POST') {
104
107
  // ---- data ----
105
108
  const tab = (['upcoming', 'unconfirmed', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
106
109
  const view = Astro.url.searchParams.get('view') === 'day' ? 'day' : 'agenda';
110
+ const eventFilter = Astro.url.searchParams.get('event') ?? '';
107
111
  const resFilter = Astro.url.searchParams.get('r') ?? '';
108
112
  const q = (Astro.url.searchParams.get('q') ?? '').trim().toLowerCase();
109
- const all = await repo.listBookings({ from: now - 400 * 86_400_000 });
113
+ const all = await repo.listBookings({ from: now - 400 * 86_400_000, ...(eventFilter ? { eventId: eventFilter } : {}), ...(!bookingsEnabled() ? { kinds: ['rsvp'] } : {}) });
110
114
  const titleOf = (b: Booking) =>
111
115
  b.kind === 'block' ? `Blocked${b.notes ? ` · ${b.notes}` : ''}`
112
116
  : b.kind === 'rsvp' ? b.name
@@ -116,6 +120,7 @@ const contextOf = (b: Booking) =>
116
120
  : [linkById.get(b.linkId)?.name, ...Object.values(b.answers).slice(0, 2)].filter(Boolean).join(' · ');
117
121
  const where = (b: Booking) => (b.kind === 'rsvp' ? evById.get(b.eventId)?.location.name || 'Event' : resById.get(b.resourceId)?.name ?? '—');
118
122
  const matches = (b: Booking) =>
123
+ (!eventFilter || b.eventId === eventFilter) &&
119
124
  (!resFilter || b.resourceId === resFilter) &&
120
125
  (!q || `${b.name} ${b.email} ${b.phone} ${where(b)} ${contextOf(b)}`.toLowerCase().includes(q));
121
126
  const active = (b: Booking) => b.status === 'pending' || b.status === 'confirmed';
@@ -131,15 +136,14 @@ const list = all
131
136
  .sort((a, b) => (tab === 'past' || tab === 'cancelled' ? b.start - a.start : a.start - b.start))
132
137
  .slice(0, 300);
133
138
  if (Astro.url.searchParams.get('format') === 'csv') {
134
- const cell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
135
139
  const iso = (ms: number) => new Date(ms).toISOString();
136
140
  const keys = [...new Set(list.flatMap((b) => Object.keys(b.answers)))];
137
141
  const rows = [
138
142
  ['Start', 'End', 'Status', 'Kind', 'Resource / event', 'Link', 'Name', 'Email', 'Phone', 'Seats', ...keys, 'Notes', 'Booked'],
139
143
  ...list.map((b) => [iso(b.start), iso(b.end), b.status, b.kind, where(b), linkById.get(b.linkId)?.name ?? '', b.name, b.email, b.phone, String(b.seats), ...keys.map((k) => b.answers[k] ?? ''), b.notes, iso(b.createdAt)]),
140
144
  ];
141
- return new Response(rows.map((r) => r.map(cell).join(',')).join('\n'), {
142
- headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="bookings-${tab}.csv"` },
145
+ return new Response(csv(rows), {
146
+ headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="bookings-${tab}.csv"`, 'Cache-Control': 'private, no-store' },
143
147
  });
144
148
  }
145
149
  const pending = tab === 'upcoming' ? list.filter((b) => b.status === 'pending') : [];
@@ -190,6 +194,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
190
194
  <a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
191
195
  </BookingsHead>
192
196
  {notice && <p class="notice" role="status">{notice}</p>}
197
+ {eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · <a href={keep({ event: null })}>Show all bookings</a></p>}
193
198
  {error && <p class="notice warn" role="alert">{error}</p>}
194
199
  {provider === 'none' && <p class="notice"><span>Emails are off — set <code>EMAIL_FROM</code> plus <code>RESEND_API_KEY</code> (or a Cloudflare <code>send_email</code> binding named EMAIL) so guests get confirmations and reminders.</span></p>}
195
200
 
@@ -8,14 +8,16 @@ import { BRAND } from '../../../../lib/brand';
8
8
  import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
9
9
  import { mergeEventDraft } from '../../../../lib/drafts';
10
10
  import { safeColor } from '../../../../lib/calendar/events';
11
+ import { mapsStatus } from '../../../../lib/maps/config';
11
12
  import ThemeScript from '../../../../components/ThemeScript.astro';
12
13
  import PostEditor from '../../../../components/editor/PostEditor';
13
14
  import '../../../../styles/admin.css';
14
15
  import '../../../../styles/editor.css';
15
16
 
16
17
  // The shared writing surface in event mode: same cards, autosave, preview and
17
- // publish flow as posts, plus the When / Repeats / Where / Button / Calendars
18
- // sidebar. Live events stage edits until Update, like posts.
18
+ // publish flow as posts, with the event's settings (When · Where ·
19
+ // Registration · Organize) on the page above its content. Live events stage
20
+ // edits until Update, like posts.
19
21
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
20
22
 
21
23
  const repo = await getRepo(Astro.locals);
@@ -59,6 +61,7 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
59
61
  calendars={calendars.filter((c) => c.kind === 'native').map((c) => ({ id: c.id, name: c.name, color: safeColor(c.color) }))}
60
62
  eventFields={CALENDAR.eventFields}
61
63
  siteTimezone={settings.timezone}
64
+ maps={mapsStatus(Astro.locals)}
62
65
  site={{ title: settings.siteTitle, url: settings.siteUrl }}
63
66
  />
64
67
  </body>
@@ -9,6 +9,7 @@ import { getRepo } from '../../../../lib/repo';
9
9
  import { slugify, type Status } from '../../../../lib/schema';
10
10
  import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
11
11
  import { featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
12
+ import { defaultPlaceLocation } from '../../../../lib/maps/places';
12
13
  import { describeRepeat } from '../../../../lib/calendar/rrule';
13
14
  import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
14
15
  import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
@@ -71,9 +72,11 @@ if (Astro.request.method === 'POST') {
71
72
  const today = tzParts(now + 86_400_000, tz);
72
73
  const day = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : `${today.y}-${String(today.m).padStart(2, '0')}-${String(today.d).padStart(2, '0')}`;
73
74
  const start = /^\d{2}:\d{2}$/.test(time) ? `${day}T${time}` : day;
75
+ const place = await defaultPlaceLocation(repo);
74
76
  const event = await repo.createEvent({
75
77
  title, slug: await uniqueSlug(title), start, allDay: !/^\d{2}:\d{2}$/.test(time),
76
78
  calendarIds: calendarId && (await repo.getCalendar(calendarId))?.kind === 'native' ? [calendarId] : [],
79
+ ...(place ? { location: place } : {}),
77
80
  });
78
81
  return Astro.redirect(`/admin/calendar/events/${event.id}`);
79
82
  }
@@ -26,6 +26,9 @@ let notice = Astro.url.searchParams.has('created') ? 'Created. Set its hours, th
26
26
  let error = '';
27
27
  const token = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(18)))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
28
 
29
+ // saved places (Calendar settings → Places): which building this room is in
30
+ const places = await repo.listPlaces();
31
+
29
32
  if (Astro.request.method === 'POST') {
30
33
  const form = await Astro.request.formData();
31
34
  const op = String(form.get('op'));
@@ -48,6 +51,7 @@ if (Astro.request.method === 'POST') {
48
51
  timezone: tz && isValidTimeZone(tz) ? tz : '',
49
52
  visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
50
53
  showOnCalendar: Boolean(form.get('showOnCalendar')),
54
+ placeId: places.some((p) => p.id === form.get('placeId')) ? String(form.get('placeId')) : '',
51
55
  hours: hoursFromForm(form), overrides: overridesFromForm(form), busyFeeds, notifyEmails: emails,
52
56
  });
53
57
  notice = error ? '' : 'Saved.';
@@ -160,6 +164,13 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
160
164
  <label class="field"><span>Description</span><textarea name="description" rows="3">{resource.description}</textarea></label>
161
165
  <label class="field"><span>Photo URL</span><input type="text" class="mono" name="photo" value={resource.photo} placeholder="/media/… or https://…" /></label>
162
166
  <label class="field"><span>Amenities <em class="hint">comma-separated</em></span><input type="text" name="amenities" value={resource.amenities.join(', ')} placeholder="Kitchen, Projector, Accessible" /></label>
167
+ <label class="field"><span>Place</span>
168
+ <select name="placeId">
169
+ <option value="">{places.length ? 'Not set' : 'No saved places yet'}</option>
170
+ {places.map((p) => <option value={p.id} selected={resource.placeId === p.id}>{p.name}{p.address ? ` — ${p.address}` : ''}</option>)}
171
+ </select>
172
+ <small class="hint">The building or venue it’s in. Add places in <a href="/admin/calendar/settings#places">Calendar settings</a>.</small>
173
+ </label>
163
174
  <label class="field"><span>Timezone</span><input type="text" name="timezone" value={resource.timezone} placeholder={`${settings.timezone || 'UTC'} (site default)`} /></label>
164
175
  <label class="field">
165
176
  <span>Listed</span>