@acms-subir/core 0.3.6 → 0.3.8

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 (116) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/integration.mjs +38 -2
  3. package/package.json +1 -1
  4. package/src/actions/index.ts +146 -2
  5. package/src/components/EmailSettings.tsx +6 -0
  6. package/src/components/EventPage.astro +25 -5
  7. package/src/components/booking/DurationEditor.astro +156 -0
  8. package/src/components/calendar/BookingsHead.astro +4 -3
  9. package/src/components/calendar/RoomHolds.tsx +13 -8
  10. package/src/components/editor/EventPanel.tsx +9 -383
  11. package/src/components/editor/LivePane.tsx +16 -3
  12. package/src/components/editor/PostEditor.tsx +59 -46
  13. package/src/components/editor/PreviewOverlay.tsx +7 -1
  14. package/src/components/editor/event/EventSheet.tsx +136 -0
  15. package/src/components/editor/event/LocationField.tsx +266 -0
  16. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  17. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  18. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  19. package/src/components/editor/event/Section.tsx +27 -0
  20. package/src/components/editor/event/WhenCard.tsx +217 -0
  21. package/src/components/editor/event/dates.ts +28 -0
  22. package/src/components/editor/event/registration.ts +39 -0
  23. package/src/components/editor/extensions/form.tsx +60 -0
  24. package/src/components/editor/extensions/slash.tsx +5 -0
  25. package/src/components/editor/frameNav.ts +20 -0
  26. package/src/components/forms/Canvas.tsx +149 -0
  27. package/src/components/forms/FormBuildPage.tsx +155 -0
  28. package/src/components/forms/FormBuilder.tsx +283 -0
  29. package/src/components/forms/FormHeader.astro +31 -0
  30. package/src/components/forms/InsertMenu.tsx +77 -0
  31. package/src/components/forms/Inspector.tsx +232 -0
  32. package/src/components/forms/Outline.tsx +93 -0
  33. package/src/components/forms/ResponsesTable.tsx +123 -0
  34. package/src/components/forms/TypeIcon.tsx +24 -0
  35. package/src/components/forms/dnd.ts +3 -0
  36. package/src/components/forms/meta.ts +84 -0
  37. package/src/components/forms/useFormHistory.ts +41 -0
  38. package/src/layouts/Admin.astro +3 -2
  39. package/src/lib/backup/content.ts +10 -5
  40. package/src/lib/booking/forms.ts +67 -0
  41. package/src/lib/booking/service.ts +6 -2
  42. package/src/lib/calendar/agent-schema.ts +7 -1
  43. package/src/lib/calendar/agent.ts +28 -3
  44. package/src/lib/calendar/api.ts +2 -0
  45. package/src/lib/calendar/ics-write.ts +2 -0
  46. package/src/lib/calendar/ics.ts +4 -0
  47. package/src/lib/calendar/import.ts +3 -3
  48. package/src/lib/calendar/schedule.ts +6 -2
  49. package/src/lib/calendar/seo.ts +4 -1
  50. package/src/lib/content-service.ts +30 -4
  51. package/src/lib/drafts.ts +9 -1
  52. package/src/lib/dynamicBlocks.ts +14 -3
  53. package/src/lib/editor/documents.ts +12 -3
  54. package/src/lib/editor/schemas.ts +4 -3
  55. package/src/lib/email/readiness.ts +14 -0
  56. package/src/lib/email/service.ts +8 -3
  57. package/src/lib/email/templates.ts +8 -2
  58. package/src/lib/email/types.ts +1 -0
  59. package/src/lib/forms/events.ts +40 -0
  60. package/src/lib/forms/export.ts +62 -0
  61. package/src/lib/forms/fields.ts +50 -0
  62. package/src/lib/forms/http.ts +29 -0
  63. package/src/lib/forms/render.ts +11 -0
  64. package/src/lib/forms/schema.ts +56 -0
  65. package/src/lib/forms/service.ts +38 -0
  66. package/src/lib/forms/store.ts +78 -0
  67. package/src/lib/forms/validate.ts +71 -0
  68. package/src/lib/maps/config.ts +64 -0
  69. package/src/lib/maps/migration.ts +12 -0
  70. package/src/lib/maps/parse.ts +180 -0
  71. package/src/lib/maps/places.ts +109 -0
  72. package/src/lib/maps/providers/geoapify.ts +36 -0
  73. package/src/lib/maps/providers/google.ts +54 -0
  74. package/src/lib/maps/providers/http.ts +96 -0
  75. package/src/lib/maps/providers/index.ts +37 -0
  76. package/src/lib/maps/providers/locationiq.ts +35 -0
  77. package/src/lib/maps/providers/mapbox.ts +38 -0
  78. package/src/lib/maps/providers/osm.ts +59 -0
  79. package/src/lib/maps/providers/pelias.ts +51 -0
  80. package/src/lib/maps/service.ts +281 -0
  81. package/src/lib/maps/types.ts +35 -0
  82. package/src/lib/mobile/bookings.ts +1 -1
  83. package/src/lib/mobile/schemas.ts +2 -2
  84. package/src/lib/repo/migrations.ts +8 -0
  85. package/src/lib/repo/sql.ts +99 -10
  86. package/src/lib/repo/types.ts +21 -1
  87. package/src/lib/schema.ts +38 -0
  88. package/src/lib/umami.ts +8 -1
  89. package/src/pages/admin/calendar/bookings.astro +23 -12
  90. package/src/pages/admin/calendar/events/[id].astro +5 -2
  91. package/src/pages/admin/calendar/events/index.astro +3 -0
  92. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  93. package/src/pages/admin/calendar/resources/link.astro +95 -49
  94. package/src/pages/admin/calendar/settings.astro +140 -3
  95. package/src/pages/admin/forms/[id]/share.astro +121 -0
  96. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  97. package/src/pages/admin/forms/[id].astro +27 -0
  98. package/src/pages/admin/forms/index.astro +198 -0
  99. package/src/pages/admin/integrations.astro +57 -0
  100. package/src/pages/admin/settings.astro +1 -1
  101. package/src/pages/api/agent/index.ts +1 -1
  102. package/src/pages/api/calendar/docs.ts +2 -0
  103. package/src/pages/api/calendar/events/[id].ts +1 -1
  104. package/src/pages/api/calendar/events/index.ts +1 -1
  105. package/src/pages/api/email/settings.ts +4 -2
  106. package/src/pages/api/mcp.ts +1 -1
  107. package/src/pages/book/manage/[token].astro +4 -4
  108. package/src/pages/events/[slug].astro +24 -10
  109. package/src/pages/forms/[id].astro +27 -0
  110. package/src/styles/admin.css +2 -2
  111. package/src/styles/editor.css +275 -34
  112. package/src/styles/form-block.css +38 -0
  113. package/src/styles/forms-admin.css +184 -0
  114. package/src/styles/forms.css +281 -0
  115. package/src/styles/site.css +43 -0
  116. package/src/virtual.d.ts +2 -0
@@ -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
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'),
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,22 +1,25 @@
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';
12
- import { emailProvider } from '../../../lib/email';
14
+ import { resolveConfig } from '../../../lib/email/config';
15
+ import { emailReadiness } from '../../../lib/email/readiness';
13
16
  import { siteOrigin } from '../../../lib/seo';
14
17
  import { waitUntilFrom } from '../../../lib/webhooks';
15
18
 
16
19
  // Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
17
20
  // requests first, a Day view with one column per resource, a details panel
18
21
  // (?b=<id>) with private notes, plus Block time and Add booking.
19
- if (!bookingsEnabled()) return new Response(null, { status: 404 });
22
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
20
23
  const repo = await getRepo(Astro.locals);
21
24
  const settings = await repo.getSettings();
22
25
  const tz = settings.timezone || 'UTC';
@@ -43,15 +46,16 @@ let notice = Astro.url.searchParams.get('notice') ?? '';
43
46
  let error = '';
44
47
 
45
48
  if (Astro.request.method === 'POST') {
46
- const form = await Astro.request.formData();
49
+ const form = await readForm(Astro.request);
47
50
  const op = String(form.get('op'));
48
51
  const id = String(form.get('id') || '');
49
52
  try {
53
+ if (!bookingsEnabled() && ['block', 'add', 'reschedule'].includes(op)) throw new BookingError(400, 'Resource bookings are disabled.');
50
54
  if (op === 'status') {
51
55
  const b = await repo.getBooking(id);
52
56
  if (!b) throw new BookingError(404, 'Booking not found.');
53
57
  await setBookingStatus(repo, b, String(form.get('to')) as BookingStatus, flow, String(form.get('message') || '') || undefined);
54
- return Astro.redirect(keep({ notice: form.get('to') === 'confirmed' ? 'Confirmed — it’s on the calendar and the guest was emailed.' : 'Updated.' }));
58
+ return Astro.redirect(keep({ notice: form.get('to') === 'confirmed' ? 'Confirmed — it’s on the calendar. Check Email → Activity for notification status.' : 'Updated.' }));
55
59
  }
56
60
  if (op === 'notes') {
57
61
  await repo.updateBooking(id, { notes: String(form.get('notes') || '').slice(0, 4000) });
@@ -67,7 +71,7 @@ if (Astro.request.method === 'POST') {
67
71
  const start = toMs(String(form.get('date')), String(form.get('time')));
68
72
  if (Number.isNaN(start)) throw new BookingError(400, 'Pick a date and time.');
69
73
  await rescheduleBooking(repo, b, start, start + (b.end - b.start), { ...flow, skipRules: true });
70
- return Astro.redirect(keep({ notice: 'Moved, and the guest was emailed.' }));
74
+ return Astro.redirect(keep({ notice: 'Moved. Check Email → Activity for notification status.' }));
71
75
  }
72
76
  if (op === 'block') {
73
77
  const r = resById.get(String(form.get('resourceId')));
@@ -104,9 +108,10 @@ if (Astro.request.method === 'POST') {
104
108
  // ---- data ----
105
109
  const tab = (['upcoming', 'unconfirmed', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
106
110
  const view = Astro.url.searchParams.get('view') === 'day' ? 'day' : 'agenda';
111
+ const eventFilter = Astro.url.searchParams.get('event') ?? '';
107
112
  const resFilter = Astro.url.searchParams.get('r') ?? '';
108
113
  const q = (Astro.url.searchParams.get('q') ?? '').trim().toLowerCase();
109
- const all = await repo.listBookings({ from: now - 400 * 86_400_000 });
114
+ const all = await repo.listBookings({ from: now - 400 * 86_400_000, ...(eventFilter ? { eventId: eventFilter } : {}), ...(!bookingsEnabled() ? { kinds: ['rsvp'] } : {}) });
110
115
  const titleOf = (b: Booking) =>
111
116
  b.kind === 'block' ? `Blocked${b.notes ? ` · ${b.notes}` : ''}`
112
117
  : b.kind === 'rsvp' ? b.name
@@ -116,6 +121,7 @@ const contextOf = (b: Booking) =>
116
121
  : [linkById.get(b.linkId)?.name, ...Object.values(b.answers).slice(0, 2)].filter(Boolean).join(' · ');
117
122
  const where = (b: Booking) => (b.kind === 'rsvp' ? evById.get(b.eventId)?.location.name || 'Event' : resById.get(b.resourceId)?.name ?? '—');
118
123
  const matches = (b: Booking) =>
124
+ (!eventFilter || b.eventId === eventFilter) &&
119
125
  (!resFilter || b.resourceId === resFilter) &&
120
126
  (!q || `${b.name} ${b.email} ${b.phone} ${where(b)} ${contextOf(b)}`.toLowerCase().includes(q));
121
127
  const active = (b: Booking) => b.status === 'pending' || b.status === 'confirmed';
@@ -131,15 +137,14 @@ const list = all
131
137
  .sort((a, b) => (tab === 'past' || tab === 'cancelled' ? b.start - a.start : a.start - b.start))
132
138
  .slice(0, 300);
133
139
  if (Astro.url.searchParams.get('format') === 'csv') {
134
- const cell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
135
140
  const iso = (ms: number) => new Date(ms).toISOString();
136
141
  const keys = [...new Set(list.flatMap((b) => Object.keys(b.answers)))];
137
142
  const rows = [
138
143
  ['Start', 'End', 'Status', 'Kind', 'Resource / event', 'Link', 'Name', 'Email', 'Phone', 'Seats', ...keys, 'Notes', 'Booked'],
139
144
  ...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
145
  ];
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"` },
146
+ return new Response(csv(rows), {
147
+ headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="bookings-${tab}.csv"`, 'Cache-Control': 'private, no-store' },
143
148
  });
144
149
  }
145
150
  const pending = tab === 'upcoming' ? list.filter((b) => b.status === 'pending') : [];
@@ -170,7 +175,12 @@ const pos = (b: Booking) => {
170
175
  };
171
176
 
172
177
  const selected = Astro.url.searchParams.get('b') ? await repo.getBooking(Astro.url.searchParams.get('b')!) : null;
173
- const provider = emailProvider(Astro.locals);
178
+ const emailConfig = resolveConfig(await repo.email.config(), Astro.locals);
179
+ const emailStatus = emailReadiness(emailConfig, settings.siteUrl, Astro.locals);
180
+ const emailNotice = !emailConfig.config.enabled || emailConfig.config.provider === 'none'
181
+ ? 'Emails are turned off. Enable email so guests can receive confirmations and reminders.'
182
+ : !emailStatus.configured ? `Email needs attention: ${emailStatus.error || 'Choose a provider in Email settings.'}`
183
+ : emailStatus.linkError;
174
184
  const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
175
185
  const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
176
186
  const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
@@ -190,8 +200,9 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
190
200
  <a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
191
201
  </BookingsHead>
192
202
  {notice && <p class="notice" role="status">{notice}</p>}
203
+ {eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · <a href={keep({ event: null })}>Show all bookings</a></p>}
193
204
  {error && <p class="notice warn" role="alert">{error}</p>}
194
- {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>}
205
+ {emailNotice && <p class="notice warn"><span>{emailNotice} <a href={emailStatus.configured && emailStatus.linkError ? '/admin/settings#site' : '/admin/email'}>{emailStatus.configured && emailStatus.linkError ? 'Site settings' : 'Email settings'}</a></span></p>}
195
206
 
196
207
  {resources.length === 0 && !all.length ? (
197
208
  <div class="empty-state">
@@ -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>