@acms-subir/core 0.3.6 → 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 (106) hide show
  1. package/CHANGELOG.md +17 -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/EventPage.astro +25 -5
  6. package/src/components/calendar/BookingsHead.astro +4 -3
  7. package/src/components/calendar/RoomHolds.tsx +13 -8
  8. package/src/components/editor/EventPanel.tsx +9 -383
  9. package/src/components/editor/LivePane.tsx +16 -3
  10. package/src/components/editor/PostEditor.tsx +59 -46
  11. package/src/components/editor/PreviewOverlay.tsx +7 -1
  12. package/src/components/editor/event/EventSheet.tsx +136 -0
  13. package/src/components/editor/event/LocationField.tsx +266 -0
  14. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  15. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  16. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  17. package/src/components/editor/event/Section.tsx +27 -0
  18. package/src/components/editor/event/WhenCard.tsx +217 -0
  19. package/src/components/editor/event/dates.ts +28 -0
  20. package/src/components/editor/event/registration.ts +39 -0
  21. package/src/components/editor/extensions/form.tsx +60 -0
  22. package/src/components/editor/extensions/slash.tsx +5 -0
  23. package/src/components/editor/frameNav.ts +20 -0
  24. package/src/components/forms/Canvas.tsx +149 -0
  25. package/src/components/forms/FormBuildPage.tsx +155 -0
  26. package/src/components/forms/FormBuilder.tsx +283 -0
  27. package/src/components/forms/FormHeader.astro +31 -0
  28. package/src/components/forms/InsertMenu.tsx +77 -0
  29. package/src/components/forms/Inspector.tsx +232 -0
  30. package/src/components/forms/Outline.tsx +93 -0
  31. package/src/components/forms/ResponsesTable.tsx +123 -0
  32. package/src/components/forms/TypeIcon.tsx +24 -0
  33. package/src/components/forms/dnd.ts +3 -0
  34. package/src/components/forms/meta.ts +84 -0
  35. package/src/components/forms/useFormHistory.ts +41 -0
  36. package/src/layouts/Admin.astro +3 -2
  37. package/src/lib/backup/content.ts +10 -5
  38. package/src/lib/booking/service.ts +6 -2
  39. package/src/lib/calendar/agent-schema.ts +7 -1
  40. package/src/lib/calendar/agent.ts +28 -3
  41. package/src/lib/calendar/api.ts +2 -0
  42. package/src/lib/calendar/ics-write.ts +2 -0
  43. package/src/lib/calendar/ics.ts +4 -0
  44. package/src/lib/calendar/import.ts +3 -3
  45. package/src/lib/calendar/schedule.ts +6 -2
  46. package/src/lib/calendar/seo.ts +4 -1
  47. package/src/lib/content-service.ts +30 -4
  48. package/src/lib/drafts.ts +9 -1
  49. package/src/lib/dynamicBlocks.ts +14 -3
  50. package/src/lib/editor/documents.ts +12 -3
  51. package/src/lib/editor/schemas.ts +4 -3
  52. package/src/lib/forms/events.ts +40 -0
  53. package/src/lib/forms/export.ts +62 -0
  54. package/src/lib/forms/fields.ts +50 -0
  55. package/src/lib/forms/http.ts +29 -0
  56. package/src/lib/forms/render.ts +11 -0
  57. package/src/lib/forms/schema.ts +56 -0
  58. package/src/lib/forms/service.ts +38 -0
  59. package/src/lib/forms/store.ts +78 -0
  60. package/src/lib/forms/validate.ts +71 -0
  61. package/src/lib/maps/config.ts +64 -0
  62. package/src/lib/maps/migration.ts +12 -0
  63. package/src/lib/maps/parse.ts +180 -0
  64. package/src/lib/maps/places.ts +109 -0
  65. package/src/lib/maps/providers/geoapify.ts +36 -0
  66. package/src/lib/maps/providers/google.ts +54 -0
  67. package/src/lib/maps/providers/http.ts +96 -0
  68. package/src/lib/maps/providers/index.ts +37 -0
  69. package/src/lib/maps/providers/locationiq.ts +35 -0
  70. package/src/lib/maps/providers/mapbox.ts +38 -0
  71. package/src/lib/maps/providers/osm.ts +59 -0
  72. package/src/lib/maps/providers/pelias.ts +51 -0
  73. package/src/lib/maps/service.ts +281 -0
  74. package/src/lib/maps/types.ts +35 -0
  75. package/src/lib/mobile/bookings.ts +1 -1
  76. package/src/lib/mobile/schemas.ts +2 -2
  77. package/src/lib/repo/migrations.ts +8 -0
  78. package/src/lib/repo/sql.ts +99 -10
  79. package/src/lib/repo/types.ts +21 -1
  80. package/src/lib/schema.ts +38 -0
  81. package/src/lib/umami.ts +8 -1
  82. package/src/pages/admin/calendar/bookings.astro +12 -7
  83. package/src/pages/admin/calendar/events/[id].astro +5 -2
  84. package/src/pages/admin/calendar/events/index.astro +3 -0
  85. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  86. package/src/pages/admin/calendar/settings.astro +140 -3
  87. package/src/pages/admin/forms/[id]/share.astro +121 -0
  88. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  89. package/src/pages/admin/forms/[id].astro +27 -0
  90. package/src/pages/admin/forms/index.astro +198 -0
  91. package/src/pages/admin/integrations.astro +57 -0
  92. package/src/pages/api/agent/index.ts +1 -1
  93. package/src/pages/api/calendar/docs.ts +2 -0
  94. package/src/pages/api/calendar/events/[id].ts +1 -1
  95. package/src/pages/api/calendar/events/index.ts +1 -1
  96. package/src/pages/api/mcp.ts +1 -1
  97. package/src/pages/book/manage/[token].astro +4 -4
  98. package/src/pages/events/[slug].astro +24 -10
  99. package/src/pages/forms/[id].astro +27 -0
  100. package/src/styles/admin.css +2 -2
  101. package/src/styles/editor.css +275 -34
  102. package/src/styles/form-block.css +38 -0
  103. package/src/styles/forms-admin.css +184 -0
  104. package/src/styles/forms.css +281 -0
  105. package/src/styles/site.css +43 -0
  106. package/src/virtual.d.ts +2 -0
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,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>
@@ -9,9 +9,14 @@ import { normalizeCalendarSrc } from '../../../lib/calendar/src';
9
9
  import { loadFeed } from '../../../lib/calendar/ics';
10
10
  import { siteApproval, APPROVAL_LABEL } from '../../../lib/booking/approval';
11
11
  import { siteOrigin } from '../../../lib/seo';
12
+ import { placeUsage, savePlace } from '../../../lib/maps/places';
13
+ import { parseMapLink } from '../../../lib/maps/parse';
14
+ import { ApiError } from '../../../lib/api';
15
+ import type { Place } from '../../../lib/schema';
12
16
 
13
17
  // Calendar settings — one page behind the ⚙ on Schedule and Bookings:
14
- // calendars (groups + colors), subscribed calendars (read-only, synced in),
18
+ // calendars (groups + colors), saved places (picked in an event's Where),
19
+ // subscribed calendars (read-only, synced in),
15
20
  // share links (out to Google/Apple/Outlook), booking approval, defaults.
16
21
  // Replaces the old Calendars page and the duplicate subscribe dialogs.
17
22
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
@@ -77,6 +82,34 @@ if (Astro.request.method === 'POST') {
77
82
  }
78
83
  return back(reset ? `Approval saved — ${reset} booking link${reset === 1 ? '' : 's'} now follow it.` : 'Approval saved.', '#bookings');
79
84
  }
85
+ if (op === 'place-save') {
86
+ // the map link field is parsed in the browser (maps.resolve, short links
87
+ // included); without JS, a full map link is still read here
88
+ const num = (k: string) => { const v = String(form.get(k) ?? '').trim(); return v === '' ? null : Number(v); };
89
+ let lat = num('lat');
90
+ let lng = num('lng');
91
+ const url = String(form.get('url') || '').trim();
92
+ let address = String(form.get('address') || '');
93
+ if ((lat === null || lng === null) && url) {
94
+ const parsed = parseMapLink(url);
95
+ if (parsed?.lat !== null && parsed?.lat !== undefined) { lat = parsed.lat; lng = parsed.lng; }
96
+ if (!address.trim() && parsed?.address) address = parsed.address;
97
+ }
98
+ try {
99
+ const { place, updatedEvents } = await savePlace(repo, {
100
+ id: id || undefined, name, address, url, lat: Number.isFinite(lat) ? lat : null, lng: Number.isFinite(lng) ? lng : null,
101
+ notes: String(form.get('notes') || ''), isDefault: form.has('isDefault'),
102
+ }, { locals: Astro.locals, origin });
103
+ return back(`Saved “${place.name}”${updatedEvents ? ` — ${updatedEvents} upcoming event${updatedEvents === 1 ? '' : 's'} updated` : ''}.`, '#places');
104
+ } catch (err) {
105
+ return back(err instanceof ApiError ? err.message : 'Couldn’t save that place.', '#places');
106
+ }
107
+ }
108
+ if (op === 'place-delete') {
109
+ const place = id ? await repo.getPlace(id) : null;
110
+ if (place) await repo.deletePlace(place.id);
111
+ return back(place ? `Deleted “${place.name}”. Events that used it keep their address.` : undefined, '#places');
112
+ }
80
113
  if (op === 'create' || op === 'subscribe') {
81
114
  if (!name) return back('Give the calendar a name.');
82
115
  let feedUrl = '';
@@ -122,7 +155,9 @@ if (Astro.request.method === 'POST') {
122
155
  }
123
156
  }
124
157
 
125
- const [calendars, events, links] = await Promise.all([repo.listCalendars(), repo.listEvents(), bookingsEnabled() ? repo.listBookingLinks() : Promise.resolve([])]);
158
+ const [calendars, events, links, places] = await Promise.all([repo.listCalendars(), repo.listEvents(), bookingsEnabled() ? repo.listBookingLinks() : Promise.resolve([]), repo.listPlaces()]);
159
+ const placeCounts = placeUsage(events, settings);
160
+ const placeLine = (p: Place) => [p.address, `${placeCounts.get(p.id) ?? 0} upcoming event${placeCounts.get(p.id) === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
126
161
  const natives = calendars.filter((c) => c.kind === 'native');
127
162
  const feeds = calendars.filter((c) => c.kind === 'feed');
128
163
  const counts = new Map<string, number>();
@@ -148,9 +183,10 @@ const APPROVAL_NOTE: Record<ApprovalMode, string> = {
148
183
  const I = {
149
184
  up: 'M6 15l6-6 6 6', down: 'M6 9l6 6 6-6',
150
185
  ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
186
+ pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
151
187
  };
152
188
  const sections = [
153
- ['calendars', 'Calendars'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
189
+ ['calendars', 'Calendars'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
154
190
  ...(bookingsEnabled() ? [['bookings', 'Bookings']] : []), ['defaults', 'Defaults'],
155
191
  ] as const;
156
192
  ---
@@ -204,6 +240,28 @@ const sections = [
204
240
  ))}
205
241
  </section>
206
242
 
243
+ <!-- Places -->
244
+ <section class="cs-sect" id="places">
245
+ <header>
246
+ <div><h2>Places</h2><p>Where your events usually happen. Pick one in an event’s Where field to fill in the address and map. Changing a place updates its upcoming events.</p></div>
247
+ <button class="btn" type="button" data-modal="place-new">+ New place</button>
248
+ </header>
249
+ {places.length === 0 ? (
250
+ <p class="cs-empty">No saved places yet. Add your building, a park or a partner venue once instead of typing it into every event.</p>
251
+ ) : places.map((p) => (
252
+ <div class="cs-row" id={`place-${p.id}`}>
253
+ <svg class="cs-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.pin} /></svg>
254
+ <div class="cs-main">
255
+ <b>{p.name}{p.isDefault && <span class="cs-default">Default</span>}</b>
256
+ <small>{placeLine(p)}{p.lat === null ? ' · no pin' : ''}</small>
257
+ </div>
258
+ <div class="cs-actions">
259
+ <button class="btn sm" type="button" data-modal={`place-edit-${p.id}`}>Edit</button>
260
+ </div>
261
+ </div>
262
+ ))}
263
+ </section>
264
+
207
265
  <!-- Subscribed -->
208
266
  <section class="cs-sect" id="subscribed">
209
267
  <header>
@@ -347,6 +405,37 @@ const sections = [
347
405
  </Modal>
348
406
  ))}
349
407
 
408
+ {[...places, null].map((p: Place | null) => (
409
+ <Modal id={p ? `place-edit-${p.id}` : 'place-new'} title={p ? `Edit ${p.name}` : 'New place'}>
410
+ <form method="post" data-place-form>
411
+ <input type="hidden" name="op" value="place-save" />
412
+ {p && <input type="hidden" name="id" value={p.id} />}
413
+ <input type="hidden" name="lat" value={p?.lat ?? ''} data-lat />
414
+ <input type="hidden" name="lng" value={p?.lng ?? ''} data-lng />
415
+ <label class="field"><span>Map link <em class="hint">paste from Google Maps, Apple Maps or OpenStreetMap — or a map embed code</em></span>
416
+ <input class="mono" type="text" name="url" value={p?.url ?? ''} placeholder="https://maps.app.goo.gl/…" data-maplink autocomplete="off" />
417
+ </label>
418
+ <p class="cs-pinline" data-pinline>
419
+ <span data-pintext>{p?.lat != null && p?.lng != null ? `Pin set at ${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}` : 'No pin yet — paste a map link to set one.'}</span>
420
+ <button class="btn sm ghost" type="button" data-clearpin hidden={p?.lat == null}>Remove pin</button>
421
+ </p>
422
+ <label class="field"><span>Name</span><input type="text" name="name" value={p?.name ?? ''} placeholder="Main building" required data-name /></label>
423
+ <label class="field"><span>Address</span><input type="text" name="address" value={p?.address ?? ''} placeholder="Street, city" data-address /></label>
424
+ <label class="field"><span>Notes <em class="hint">for editors only</em></span><textarea name="notes" rows="2" placeholder="Use the side door after 6 PM">{p?.notes ?? ''}</textarea></label>
425
+ <label class="check-row"><input type="checkbox" name="isDefault" checked={p?.isDefault ?? false} /> New events start here</label>
426
+ {p?.mapImage && (
427
+ <figure class="cs-map"><img src={p.mapImage} alt={`Map of ${p.name}`} width="1200" height="500" loading="lazy" /></figure>
428
+ )}
429
+ <div class="modal-foot">
430
+ {p && <button class="btn danger ghost" type="submit" name="op" value="place-delete" formnovalidate data-confirm={`Delete “${p.name}”? Events that used it keep their address.`}>Delete</button>}
431
+ <span class="spacer" />
432
+ <button class="btn ghost" type="button" data-close>Cancel</button>
433
+ <button class="btn primary" type="submit">{p ? 'Save' : 'Add place'}</button>
434
+ </div>
435
+ </form>
436
+ </Modal>
437
+ ))}
438
+
350
439
  <Modal id="cal-new" title="New calendar" open={Astro.url.searchParams.has('new')}>
351
440
  <form method="post">
352
441
  <input type="hidden" name="op" value="create" />
@@ -424,6 +513,14 @@ const sections = [
424
513
  .cs-field { display: grid; grid-template-columns: 160px auto; align-items: center; gap: 10px; font-size: 13px; color: var(--text-2); justify-content: start; }
425
514
  .cs-foot { display: flex; align-items: center; gap: 12px; justify-content: space-between; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 12px; }
426
515
  .cs-foot small { color: var(--muted); font-size: 12px; }
516
+ .cs-pin { width: 14px; height: 14px; color: var(--muted); }
517
+ .cs-default { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; font-size: 11.5px; font-weight: 600; color: var(--text-2); vertical-align: 1px; }
518
+ .cs-default::before { content: ''; width: 6px; height: 6px; border-radius: 2px; background: var(--accent); }
519
+ .cs-pinline { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: -4px 0 12px; font-size: 12px; color: var(--muted); }
520
+ .cs-pinline.is-busy span { opacity: 0.6; }
521
+ .cs-pinline.is-error span { color: var(--red); }
522
+ .cs-map { margin: 0 0 4px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
523
+ .cs-map img { display: block; width: 100%; height: auto; }
427
524
  [hidden] { display: none !important; }
428
525
  @media (max-width: 860px) {
429
526
  .cs { grid-template-columns: 1fr; }
@@ -437,6 +534,8 @@ const sections = [
437
534
  </style>
438
535
 
439
536
  <script>
537
+ import { actions } from 'astro:actions';
538
+
440
539
  document.addEventListener('click', async (e) => {
441
540
  const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
442
541
  if (b) {
@@ -448,6 +547,44 @@ const sections = [
448
547
  const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
449
548
  if (c && !confirm(c.dataset.confirm)) e.preventDefault();
450
549
  });
550
+ // places: a pasted map link fills the pin (and name/address when empty).
551
+ // maps.resolve expands short links server-side; nothing needs a maps key.
552
+ for (const f of document.querySelectorAll<HTMLFormElement>('form[data-place-form]')) {
553
+ const link = f.querySelector<HTMLInputElement>('[data-maplink]')!;
554
+ const lat = f.querySelector<HTMLInputElement>('[data-lat]')!;
555
+ const lng = f.querySelector<HTMLInputElement>('[data-lng]')!;
556
+ const line = f.querySelector<HTMLElement>('[data-pinline]')!;
557
+ const text = f.querySelector<HTMLElement>('[data-pintext]')!;
558
+ const clear = f.querySelector<HTMLButtonElement>('[data-clearpin]')!;
559
+ const name = f.querySelector<HTMLInputElement>('[data-name]')!;
560
+ const address = f.querySelector<HTMLInputElement>('[data-address]')!;
561
+ const show = (msg: string, state = '') => { text.textContent = msg; line.className = `cs-pinline${state ? ` is-${state}` : ''}`; clear.hidden = !lat.value; };
562
+ let last = link.value.trim();
563
+ const resolve = async () => {
564
+ const raw = link.value.trim();
565
+ if (raw === last) return;
566
+ last = raw;
567
+ if (!raw) return;
568
+ show('Reading the link…', 'busy');
569
+ const { data, error } = await actions.maps.resolve({ url: raw });
570
+ if (link.value.trim() !== raw) return;
571
+ const loc = data?.location;
572
+ if (error || !loc) { show('That link didn’t include a place we could read. You can still type the address.', 'error'); return; }
573
+ if (loc.kind === 'meeting') { show('That’s an online meeting link — saved places are for physical places.', 'error'); return; }
574
+ // an embed's src isn't a link people can open: keep the field tidy
575
+ if (loc.url !== raw) { link.value = loc.url; last = loc.url; }
576
+ if (!name.value.trim() && loc.name) name.value = loc.name;
577
+ if (!address.value.trim() && loc.address) address.value = loc.address;
578
+ if (loc.lat !== null && loc.lng !== null) {
579
+ lat.value = String(loc.lat); lng.value = String(loc.lng);
580
+ show(`Pin set at ${loc.lat.toFixed(5)}, ${loc.lng.toFixed(5)}`);
581
+ } else show('No pin in that link — the address was filled in.');
582
+ };
583
+ link.addEventListener('paste', () => setTimeout(resolve, 0));
584
+ link.addEventListener('change', resolve);
585
+ clear.addEventListener('click', () => { lat.value = ''; lng.value = ''; show('Pin removed — it’s gone when you save.'); });
586
+ }
587
+
451
588
  // approval: rules only for "unless…", the deadline only when bookings can wait
452
589
  const form = document.querySelector<HTMLFormElement>('[data-approval]');
453
590
  form?.addEventListener('change', () => {
@@ -0,0 +1,121 @@
1
+ ---
2
+ import Admin from '../../../../layouts/Admin.astro';
3
+ import FormHeader from '../../../../components/forms/FormHeader.astro';
4
+ import { getRepo } from '../../../../lib/repo';
5
+ import { readForm } from '../../../../lib/forms/http';
6
+ import { escapeForm } from '../../../../lib/forms/fields';
7
+ import { siteOrigin } from '../../../../lib/seo';
8
+ import { CONTENT } from '../../../../lib/content';
9
+ import '../../../../styles/forms-admin.css';
10
+
11
+ const repo = await getRepo(Astro.locals);
12
+ let form = await repo.forms.get(Astro.params.id || '');
13
+ if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
14
+ Astro.response.headers.set('Cache-Control', 'private, no-store');
15
+
16
+ let error = '', notice = '';
17
+ if (Astro.request.method === 'POST') {
18
+ try {
19
+ const data = await readForm(Astro.request);
20
+ const op = String(data.get('op') || '');
21
+ if (op !== 'publish' && op !== 'archive') throw new Error('Unknown action.');
22
+ // Same store operation as the Build tab: publish promotes pending edits.
23
+ await repo.forms.save(form.id, Number(data.get('revision')), form.name, form.draft ?? form.definition, op);
24
+ form = (await repo.forms.get(form.id))!;
25
+ notice = op === 'publish' ? 'Published. The form is accepting responses.' : 'Archived. The form no longer accepts responses; existing responses are kept.';
26
+ } catch (e) { error = e instanceof Error ? e.message : 'Could not update the form.'; }
27
+ }
28
+
29
+ const [settings, stats, used] = await Promise.all([repo.getSettings(), repo.forms.submissionStats(form.id, 0), repo.forms.usedOn(form.id)]);
30
+ const origin = siteOrigin(settings, Astro.url);
31
+ const link = `${origin}/forms/${form.id}`;
32
+ const title = escapeForm(form.definition.title || form.name);
33
+ const embed = `<iframe src="${link}?embed=1" title="${title}" loading="lazy" style="width:100%;min-height:560px;border:0"></iframe>`;
34
+ const live = form.status === 'published';
35
+ const state = live ? (form.draft ? ['Accepting responses', 'green', 'Visitors see the last published version. Publish to apply your changes.'] : ['Accepting responses', 'green', 'Anyone with the link, or on a page with this form, can respond.'])
36
+ : form.status === 'archived' ? ['Not accepting responses', 'muted', 'Archived. The link and embedded copies are hidden. Responses are kept.']
37
+ : ['Not accepting responses', 'amber', 'Publish the form to open the link and show it on pages.'];
38
+ const editHref = (u: (typeof used)[number]) => u.kind === 'page' ? `/admin/pages/${u.id}` : u.kind === 'event' ? `/admin/calendar/events/${u.id}` : `/admin/c/${u.collection}/${u.id}`;
39
+ const kindLabel = (u: (typeof used)[number]) => u.kind === 'page' ? 'Page' : u.kind === 'event' ? 'Event' : (CONTENT.collections[u.collection]?.singular ?? 'Post');
40
+ const copyIcon = 'M9 9h10v10H9zM5 15V5h10';
41
+ ---
42
+ <Admin title={`${form.name} · Share`} section="forms">
43
+ <FormHeader form={form} active="share" responses={stats.total}>
44
+ {live && <a slot="actions" class="btn" href={`/forms/${form.id}`} target="_blank" rel="noopener">Open form ↗</a>}
45
+ </FormHeader>
46
+ {error && <p class="notice warn" role="alert">{error}</p>}
47
+ {notice && <p class="notice" role="status">{notice}</p>}
48
+
49
+ <div class="fs">
50
+ <section class="fs-sec">
51
+ <div class="fs-label"><b>Responses</b><small>Publishing opens the form; archiving closes it.</small></div>
52
+ <div class="fs-body">
53
+ <div class="fs-state">
54
+ <div>
55
+ <p class={`fst fst-${state[1]} fs-state-title`}><i aria-hidden="true" />{state[0]}</p>
56
+ <p class="hint">{state[2]}</p>
57
+ </div>
58
+ <form method="post" class="fs-state-actions">
59
+ <input type="hidden" name="revision" value={form.revision} />
60
+ {(!live || form.draft) && <button class="btn primary" name="op" value="publish">{form.draft ? 'Publish changes' : form.status === 'archived' ? 'Reopen and publish' : 'Publish'}</button>}
61
+ {live && <button class="btn" name="op" value="archive">Stop accepting responses</button>}
62
+ </form>
63
+ </div>
64
+ </div>
65
+ </section>
66
+
67
+ <section class="fs-sec">
68
+ <div class="fs-label"><b>Public link</b><small>A page with just this form.</small></div>
69
+ <div class="fs-body">
70
+ <div class="fs-copy">
71
+ <input type="text" class="mono" readonly value={link} aria-label="Public link" id="fs-link" />
72
+ <button class="btn" type="button" data-copy="fs-link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={copyIcon} /></svg><span>Copy</span></button>
73
+ </div>
74
+ {!live && <p class="hint">The link opens once the form is published.</p>}
75
+ </div>
76
+ </section>
77
+
78
+ <section class="fs-sec">
79
+ <div class="fs-label"><b>Embed</b><small>Paste into another website. On this site, add a Form block to a page instead.</small></div>
80
+ <div class="fs-body">
81
+ <textarea class="code fs-embed" readonly rows="3" id="fs-embed" aria-label="Embed code">{embed}</textarea>
82
+ <div class="fs-row"><button class="btn" type="button" data-copy="fs-embed"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={copyIcon} /></svg><span>Copy code</span></button></div>
83
+ </div>
84
+ </section>
85
+
86
+ <section class="fs-sec">
87
+ <div class="fs-label"><b>Used on</b><small>Pages, posts and events with a Form block for this form.</small></div>
88
+ <div class="fs-body">
89
+ {used.length ? (
90
+ <ul class="fs-used">
91
+ {used.map(u => (
92
+ <li>
93
+ <a href={editHref(u)}><b>{u.title || 'Untitled'}</b><small>{kindLabel(u)} · <span class={`status ${u.status}`}>{u.status[0].toUpperCase() + u.status.slice(1)}</span></small></a>
94
+ <a class="btn sm ghost" href={editHref(u)}>Edit</a>
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ ) : <p class="hint fs-none">Not on any page yet. In the editor, type <kbd>/</kbd> and choose Form.</p>}
99
+ </div>
100
+ </section>
101
+
102
+ <section class="fs-sec">
103
+ <div class="fs-label"><b>Notifications</b></div>
104
+ <div class="fs-body"><p class="hint">Email alerts and webhooks for new responses are coming later. For now, check Responses or export a CSV.</p></div>
105
+ </section>
106
+ </div>
107
+ </Admin>
108
+
109
+ <script>
110
+ document.addEventListener('click', async (e) => {
111
+ const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
112
+ if (!b) return;
113
+ const el = document.getElementById(b.dataset.copy!) as HTMLInputElement | HTMLTextAreaElement | null;
114
+ if (!el) return;
115
+ const label = b.querySelector('span')!;
116
+ const was = label.textContent;
117
+ try { await navigator.clipboard.writeText(el.value); label.textContent = 'Copied'; }
118
+ catch { el.select(); label.textContent = 'Press ⌘C'; }
119
+ setTimeout(() => (label.textContent = was), 1600);
120
+ });
121
+ </script>