@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
@@ -0,0 +1,266 @@
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
2
+ import { actions } from 'astro:actions';
3
+ import type { EventLocation, Place } from '../../../lib/schema';
4
+ import type { GeoResult, MapsStatus, ParsedLocation } from '../../../lib/maps/types';
5
+
6
+ // The Where field: one box that searches saved places (always) and the
7
+ // site's maps provider (when one is configured), and turns a pasted map
8
+ // link or Google embed into name + address + pin. The plain fields below it
9
+ // stay editable; typing in them detaches the event from its saved place.
10
+
11
+ interface Props {
12
+ location: EventLocation;
13
+ onChange: (location: EventLocation) => void;
14
+ maps: MapsStatus;
15
+ places: Place[];
16
+ onPlaceSaved: (place: Place) => void;
17
+ }
18
+
19
+ type Option =
20
+ | { kind: 'place'; place: Place }
21
+ | { kind: 'geo'; result: GeoResult };
22
+
23
+ const looksLikeLink = (s: string) => /^\s*(https?:\/\/|<iframe)/i.test(s);
24
+ const matches = (p: Place, q: string) => `${p.name} ${p.address}`.toLowerCase().includes(q.toLowerCase());
25
+
26
+ export function LocationField({ location, onChange, maps, places, onPlaceSaved }: Props) {
27
+ const [query, setQuery] = useState('');
28
+ const [open, setOpen] = useState(false);
29
+ const [active, setActive] = useState(0);
30
+ const [results, setResults] = useState<GeoResult[]>([]);
31
+ const [busy, setBusy] = useState<'' | 'search' | 'link' | 'save'>('');
32
+ const [msg, setMsg] = useState('');
33
+ const listId = useId();
34
+ const seq = useRef(0);
35
+
36
+ const place = location.placeId ? places.find((p) => p.id === location.placeId) : undefined;
37
+ const hasPin = location.lat != null && location.lng != null;
38
+
39
+ // saved places first (all of them while the box is empty), then suggestions
40
+ const options: Option[] = useMemo(() => {
41
+ const q = query.trim();
42
+ const saved = (q ? places.filter((p) => matches(p, q)) : places).slice(0, 6).map((p) => ({ kind: 'place' as const, place: p }));
43
+ return [...saved, ...results.map((r) => ({ kind: 'geo' as const, result: r }))];
44
+ }, [query, places, results]);
45
+
46
+ // provider suggestions, debounced; stale responses are dropped
47
+ useEffect(() => {
48
+ const q = query.trim();
49
+ if (!maps.search || q.length < 3 || looksLikeLink(q)) { setResults([]); return; }
50
+ const id = ++seq.current;
51
+ const t = setTimeout(async () => {
52
+ setBusy('search');
53
+ const r = await actions.maps.search({ q, near: hasPin ? { lat: location.lat!, lng: location.lng! } : undefined });
54
+ if (id !== seq.current) return;
55
+ setBusy('');
56
+ setResults(r.data?.results ?? []);
57
+ if (r.error) setMsg('Suggestions are unavailable right now.');
58
+ }, 300);
59
+ return () => clearTimeout(t);
60
+ // eslint-disable-next-line react-hooks/exhaustive-deps
61
+ }, [query, maps.search]);
62
+
63
+ const close = () => { setOpen(false); setActive(0); };
64
+
65
+ const applyPlace = (p: Place) => {
66
+ onChange({ ...location, name: p.name, address: p.address, url: p.url, lat: p.lat, lng: p.lng, placeId: p.id, mapImage: p.mapImage, mapImageDark: p.mapImageDark });
67
+ setQuery(''); setResults([]); setMsg(''); close();
68
+ };
69
+
70
+ const applyGeo = (r: GeoResult | ParsedLocation, link = '') => {
71
+ const meeting = 'kind' in r && r.kind === 'meeting';
72
+ onChange({
73
+ ...location,
74
+ name: r.name || (meeting ? location.name || 'Online' : location.name),
75
+ address: meeting ? '' : r.address,
76
+ url: link || r.url || location.url,
77
+ lat: meeting ? null : r.lat,
78
+ lng: meeting ? null : r.lng,
79
+ placeId: '',
80
+ // a new pin gets a fresh map image when the event saves
81
+ mapImage: '', mapImageDark: '',
82
+ });
83
+ setQuery(''); setResults([]); close();
84
+ };
85
+
86
+ const resolveLink = async (raw: string) => {
87
+ setBusy('link'); setMsg('');
88
+ const r = await actions.maps.resolve({ url: raw.trim() });
89
+ setBusy('');
90
+ const loc = r.data?.location;
91
+ // an iframe's src (or the link itself) becomes the event's map link
92
+ const link = /^\s*<iframe/i.test(raw) ? (raw.match(/src="([^"]+)"/i)?.[1] ?? '') : raw.trim();
93
+ if (loc) {
94
+ applyGeo(loc, loc.kind === 'meeting' ? link : loc.url || link);
95
+ setMsg(loc.kind === 'meeting' ? 'Online meeting link added.' : loc.lat != null ? 'Filled in from the map link.' : 'Read the place from the link. Add the address if it’s missing.');
96
+ } else {
97
+ onChange({ ...location, url: link, placeId: '' });
98
+ setQuery(''); close();
99
+ setMsg('Saved as the map link. Couldn’t read the place from it, so fill in the address.');
100
+ }
101
+ };
102
+
103
+ const choose = (o: Option | undefined) => {
104
+ if (!o) return;
105
+ if (o.kind === 'place') applyPlace(o.place);
106
+ else applyGeo(o.result);
107
+ };
108
+
109
+ // typing in the plain fields detaches from the saved place
110
+ const edit = (patch: Partial<EventLocation>) => onChange({ ...location, ...patch, placeId: '' });
111
+
112
+ const saveAsPlace = async () => {
113
+ setBusy('save'); setMsg('');
114
+ const r = await actions.places.save({ name: location.name.trim(), address: location.address, url: location.url, lat: location.lat, lng: location.lng, notes: '', isDefault: false });
115
+ setBusy('');
116
+ if (r.error || !r.data) { setMsg(r.error?.message ?? 'Couldn’t save the place.'); return; }
117
+ onPlaceSaved(r.data.place);
118
+ onChange({ ...location, placeId: r.data.place.id });
119
+ setMsg(`Saved. Pick “${r.data.place.name}” for future events.`);
120
+ };
121
+
122
+ const showList = open && (options.length > 0 || (query.trim().length > 0 && !looksLikeLink(query)));
123
+
124
+ return (
125
+ <div className="pe-loc pe-evs-fields">
126
+ <div className={`pe-loc-search ${open ? 'is-open' : ''}`}>
127
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><circle cx="11" cy="11" r="6.5" /><path d="m20 20-4.2-4.2" /></svg>
128
+ <input
129
+ type="text"
130
+ role="combobox"
131
+ aria-expanded={showList}
132
+ aria-controls={listId}
133
+ aria-autocomplete="list"
134
+ aria-activedescendant={showList && options[active] ? `${listId}-${active}` : undefined}
135
+ aria-label="Find a place"
136
+ placeholder={maps.search ? 'Search, pick a saved place, or paste a map link' : places.length ? 'Pick a saved place or paste a map link' : 'Paste a map link or Google embed'}
137
+ value={query}
138
+ spellCheck={false}
139
+ onFocus={() => setOpen(true)}
140
+ onClick={() => setOpen(true)}
141
+ onBlur={() => setTimeout(close, 120)}
142
+ onPaste={(e) => {
143
+ const text = e.clipboardData.getData('text/plain');
144
+ if (looksLikeLink(text)) { e.preventDefault(); void resolveLink(text); }
145
+ }}
146
+ onChange={(e) => { setQuery(e.target.value); setOpen(true); setActive(0); setMsg(''); }}
147
+ onKeyDown={(e) => {
148
+ if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive((i) => Math.min(i + 1, options.length - 1)); }
149
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
150
+ else if (e.key === 'Enter') {
151
+ e.preventDefault();
152
+ if (looksLikeLink(query)) void resolveLink(query);
153
+ else choose(options[active]);
154
+ } else if (e.key === 'Escape') close();
155
+ }}
156
+ />
157
+ {busy && busy !== 'save' && <span className="pe-loc-spin" aria-hidden="true" />}
158
+ {showList && (
159
+ <ul className="pe-loc-list" id={listId} role="listbox" onMouseDown={(e) => e.preventDefault()}>
160
+ {options.map((o, i) => {
161
+ const first = i === 0 || options[i - 1].kind !== o.kind;
162
+ const name = o.kind === 'place' ? o.place.name : o.result.name || o.result.address;
163
+ const sub = o.kind === 'place' ? o.place.address : o.result.name ? o.result.address : '';
164
+ return (
165
+ <li key={o.kind === 'place' ? o.place.id : `${o.result.lat},${o.result.lng},${i}`} role="presentation">
166
+ {first && <span className="pe-loc-group" aria-hidden="true">{o.kind === 'place' ? 'Saved places' : `Suggestions · ${maps.label}`}</span>}
167
+ <div
168
+ id={`${listId}-${i}`}
169
+ role="option"
170
+ aria-selected={i === active}
171
+ className={`pe-loc-opt ${i === active ? 'is-active' : ''}`}
172
+ onMouseEnter={() => setActive(i)}
173
+ onClick={() => choose(o)}
174
+ >
175
+ <span className="pe-loc-mk" aria-hidden="true">{o.kind === 'place' ? '★' : '⌖'}</span>
176
+ <span><b>{name}</b>{sub && <small>{sub}</small>}</span>
177
+ {o.kind === 'place' && o.place.isDefault && <em>Default</em>}
178
+ </div>
179
+ </li>
180
+ );
181
+ })}
182
+ {options.length === 0 && (
183
+ <li className="pe-loc-empty" role="presentation">{busy === 'search' ? 'Searching…' : maps.search ? 'No matches. Type the details below instead.' : 'No saved place matches.'}</li>
184
+ )}
185
+ {!maps.enabled && query.trim().length > 0 && (
186
+ <li className="pe-loc-foot" role="presentation">Address suggestions need a maps provider. <a href="/admin/integrations#maps" target="_blank" rel="noopener">Set one up</a></li>
187
+ )}
188
+ </ul>
189
+ )}
190
+ </div>
191
+
192
+ {msg && <p className="pe-loc-msg" role="status">{msg}</p>}
193
+
194
+ <div className="pe-evs-pair">
195
+ <label className="pe-field">
196
+ <span>Place {place ? <em>★ Saved place</em> : null}</span>
197
+ <input type="text" placeholder="Fellowship Hall, Zoom, the park…" value={location.name} onChange={(e) => edit({ name: e.target.value })} />
198
+ </label>
199
+ <label className="pe-field">
200
+ <span>Map or online link <em>optional</em></span>
201
+ <input
202
+ type="url"
203
+ className="mono"
204
+ placeholder="Maps link, Zoom, Meet…"
205
+ value={location.url}
206
+ onChange={(e) => edit({ url: e.target.value.trim() })}
207
+ onPaste={(e) => {
208
+ // a pasted map link fills in whatever is missing
209
+ const text = e.clipboardData.getData('text/plain');
210
+ if (looksLikeLink(text) && !hasPin) { e.preventDefault(); void resolveLink(text); }
211
+ }}
212
+ />
213
+ </label>
214
+ </div>
215
+ <label className="pe-field">
216
+ <span>Address</span>
217
+ <input type="text" placeholder="1200 River Rd, Riverside" value={location.address} onChange={(e) => edit({ address: e.target.value })} />
218
+ </label>
219
+
220
+ {hasPin && (
221
+ <div className="pe-loc-map">
222
+ {location.mapImage ? (
223
+ <>
224
+ <img className="pe-loc-img is-light" src={location.mapImage} alt={`Map of ${location.name || 'the location'}`} />
225
+ <img className="pe-loc-img is-dark" src={location.mapImageDark || location.mapImage} alt="" aria-hidden="true" />
226
+ </>
227
+ ) : (
228
+ <div className="pe-loc-noimg">
229
+ <span className="pe-loc-pin" aria-hidden="true" />
230
+ <small>
231
+ {maps.staticMaps
232
+ ? 'The map image is made when you save.'
233
+ : <>Pinned at {location.lat!.toFixed(4)}, {location.lng!.toFixed(4)}. <a href="/admin/integrations#maps" target="_blank" rel="noopener">Add a maps provider</a> to show a map image.</>}
234
+ </small>
235
+ </div>
236
+ )}
237
+ {maps.staticMaps && (
238
+ <label className="pe-loc-check">
239
+ <input type="checkbox" checked={location.showMap} onChange={(e) => onChange({ ...location, showMap: e.target.checked })} />
240
+ Show map on event page
241
+ </label>
242
+ )}
243
+ </div>
244
+ )}
245
+
246
+ {(location.name || location.address || location.url) && (
247
+ <div className="pe-loc-actions">
248
+ {!place && location.name.trim() && (
249
+ <button type="button" className="pe-btn sm" disabled={busy === 'save'} onClick={() => void saveAsPlace()}>☆ Save as place</button>
250
+ )}
251
+ <span className="pe-loc-gap" />
252
+ {hasPin && (
253
+ <button type="button" className="pe-btn ghost sm" onClick={() => edit({ lat: null, lng: null, mapImage: '', mapImageDark: '' })}>Remove pin</button>
254
+ )}
255
+ <button
256
+ type="button"
257
+ className="pe-btn ghost sm"
258
+ onClick={() => { onChange({ ...location, name: '', address: '', url: '', lat: null, lng: null, placeId: '', mapImage: '', mapImageDark: '' }); setMsg(''); }}
259
+ >
260
+ Clear
261
+ </button>
262
+ </div>
263
+ )}
264
+ </div>
265
+ );
266
+ }
@@ -0,0 +1,87 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { EventFieldConfig } from '../../../lib/calendar/config';
3
+ import type { CalendarOption, EventMeta } from '../EventPanel';
4
+ import { Section } from './Section';
5
+
6
+ interface Props {
7
+ meta: EventMeta;
8
+ onChange: (patch: Partial<EventMeta>) => void;
9
+ calendars: CalendarOption[];
10
+ fields: EventFieldConfig[];
11
+ // the shared tag input (it lives in the editor, with its suggestions)
12
+ tags?: ReactNode;
13
+ }
14
+
15
+ // Calendars, tags and acms({ calendar.eventFields }) custom fields.
16
+ export function OrganizeCard({ meta, onChange, calendars, fields, tags }: Props) {
17
+ return (
18
+ <Section id="ev-organize" title="Organize" help="Calendars decide where it’s listed. Tags help search.">
19
+ <div className="pe-evs-fields">
20
+ <div className="pe-evs-group">
21
+ <span className="pe-evw-sub">Calendars</span>
22
+ {calendars.length === 0 ? (
23
+ <p className="pe-evs-hint">No calendars yet. The event shows on the main calendar. <a href="/admin/calendar/settings?new=1" target="_blank" rel="noopener">Create one</a> to group events.</p>
24
+ ) : (
25
+ <div className="pe-ev-cals">
26
+ {calendars.map((c) => {
27
+ const on = meta.calendarIds.includes(c.id);
28
+ return (
29
+ <label key={c.id} className={on ? 'is-on' : ''}>
30
+ <input
31
+ type="checkbox"
32
+ checked={on}
33
+ onChange={(e) => onChange({ calendarIds: e.target.checked ? [...meta.calendarIds, c.id] : meta.calendarIds.filter((x) => x !== c.id) })}
34
+ />
35
+ <i style={{ background: c.color }} aria-hidden="true" />
36
+ <span>{c.name}</span>
37
+ </label>
38
+ );
39
+ })}
40
+ </div>
41
+ )}
42
+ </div>
43
+
44
+ {tags && (
45
+ <label className="pe-field">
46
+ <span>Tags</span>
47
+ {tags}
48
+ </label>
49
+ )}
50
+
51
+ {fields.map((f) => {
52
+ const value = meta.fields[f.key] ?? '';
53
+ const set = (v: string) => onChange({ fields: { ...meta.fields, [f.key]: v } });
54
+ return (
55
+ <label key={f.key} className="pe-field">
56
+ <span>{f.label}</span>
57
+ {f.type === 'select' ? (
58
+ <select value={value} onChange={(e) => set(e.target.value)}>
59
+ <option value="">—</option>
60
+ {f.options.map((o) => <option key={o} value={o}>{o}</option>)}
61
+ </select>
62
+ ) : f.type === 'textarea' ? (
63
+ <textarea rows={3} value={value} onChange={(e) => set(e.target.value)} />
64
+ ) : (
65
+ <input type={f.type === 'number' ? 'number' : f.type === 'url' ? 'url' : 'text'} value={value} onChange={(e) => set(e.target.value)} />
66
+ )}
67
+ </label>
68
+ );
69
+ })}
70
+
71
+ </div>
72
+ </Section>
73
+ );
74
+ }
75
+
76
+ export function StatusSection({ meta, onChange }: Pick<Props, 'meta' | 'onChange'>) {
77
+ return (
78
+ <Section id="ev-status" title="Status" help="Cancelled events stay listed and say so." className={meta.cancelled ? 'is-cancelled' : ''}>
79
+ <div className="pe-evs-box pe-evs-cancel">
80
+ <label className="pe-switch-row">
81
+ <span><b>Mark as cancelled</b><small>Keeps the page up with a “Cancelled” notice, so nobody wonders where it went</small></span>
82
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.cancelled} onChange={(e) => onChange({ cancelled: e.target.checked })} />
83
+ </label>
84
+ </div>
85
+ </Section>
86
+ );
87
+ }
@@ -0,0 +1,114 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { starter } from '../../forms/FormBuilder';
3
+ import type { EventCta } from '../../../lib/schema';
4
+ import type { EventMeta } from '../EventPanel';
5
+ import { Section } from './Section';
6
+ import { RegistrationFormEditor } from './RegistrationFormEditor';
7
+ import { ctaHrefValid, normalizeCtaHref, registrationMode, registrationPatch, type RegistrationMode } from './registration';
8
+
9
+ const MODES: { id: RegistrationMode; label: string; sub: string }[] = [
10
+ { id: 'none', label: 'None', sub: 'Just the details' },
11
+ { id: 'link', label: 'Link', sub: 'Tickets or a page elsewhere' },
12
+ { id: 'rsvp', label: 'Built-in form', sub: 'Collect RSVPs here' },
13
+ ];
14
+
15
+ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { eventId?: string; title?: string; meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void }) {
16
+ // "Link button" with nothing typed yet is still link mode, so keep the
17
+ // choice locally and only fall back to the derived one on first render
18
+ const [mode, setMode] = useState<RegistrationMode>(() => registrationMode(meta));
19
+ const [editing, setEditing] = useState(false);
20
+ const form = meta.rsvp.form ?? starter('simple');
21
+ const pick = (next: RegistrationMode) => {
22
+ setMode(next);
23
+ onChange(registrationPatch(next, meta));
24
+ };
25
+ const questions = form.fields.map((f) => f.label).filter(Boolean);
26
+
27
+ return (
28
+ <Section id="ev-registration" title="CTA" help="The call-to-action button on the event page. Built-in form responses land in Bookings.">
29
+ <div className="pe-evr-modes" role="radiogroup" aria-label="How people sign up">
30
+ {MODES.map((m) => (
31
+ <button key={m.id} type="button" role="radio" aria-checked={mode === m.id} onClick={() => pick(m.id)}>
32
+ <b>{m.label}</b>
33
+ <small>{m.sub}</small>
34
+ </button>
35
+ ))}
36
+ </div>
37
+
38
+ {mode === 'link' && (
39
+ <div className="pe-evs-fields">
40
+ <div className="pe-evs-pair pe-evr-link">
41
+ <label className="pe-field">
42
+ <span>Button label</span>
43
+ <input type="text" placeholder="Register, Get tickets…" maxLength={120} value={meta.cta.label} onChange={(e) => onChange({ cta: { ...meta.cta, label: e.target.value } })} />
44
+ </label>
45
+ <label className="pe-field">
46
+ <span>Style</span>
47
+ <select value={meta.cta.style} onChange={(e) => onChange({ cta: { ...meta.cta, style: e.target.value as EventCta['style'] } })}>
48
+ <option value="primary">Solid</option>
49
+ <option value="outline">Outline</option>
50
+ </select>
51
+ </label>
52
+ </div>
53
+ <CtaLink href={meta.cta.href} onChange={(href) => onChange({ cta: { ...meta.cta, href } })} />
54
+ </div>
55
+ )}
56
+
57
+ {mode === 'rsvp' && (
58
+ <div className="pe-evs-fields">
59
+ <div className="pe-evs-pair pe-evr-seats">
60
+ <label className="pe-field">
61
+ <span>Seats</span>
62
+ <input type="number" min={0} placeholder="No limit" value={meta.rsvp.capacity || ''} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, capacity: Math.max(0, Number(e.target.value) || 0) } })} />
63
+ <small className="pe-evs-hint">Leave empty for no limit</small>
64
+ </label>
65
+ <label className="pe-field">
66
+ <span>Max per person</span>
67
+ <input type="number" min={1} max={50} value={meta.rsvp.maxSeats} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, maxSeats: Math.min(50, Math.max(1, Number(e.target.value) || 1)) } })} />
68
+ </label>
69
+ </div>
70
+ <div className="pe-evs-box">
71
+ <label className="pe-switch-row">
72
+ <span><b>Confirm automatically</b><small>Off: RSVPs wait for your approval in Bookings</small></span>
73
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.autoConfirm} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, autoConfirm: e.target.checked } })} />
74
+ </label>
75
+ </div>
76
+ <div className="pe-evr-form">
77
+ <div className="pe-evr-form-t">
78
+ <b>{form.title || 'Registration form'}</b>
79
+ <small>Name · Email · Seats{questions.length ? ` + ${questions.length} question${questions.length === 1 ? '' : 's'} (${questions.join(', ')})` : ''}</small>
80
+ </div>
81
+ <div className="pe-evr-form-a">
82
+ {eventId && <a className="pe-evs-link" href={`/admin/calendar/bookings?event=${encodeURIComponent(eventId)}`}>View registrations ↗</a>}
83
+ <button type="button" className="pe-btn sm" aria-haspopup="dialog" onClick={() => setEditing(true)}>Edit form</button>
84
+ </div>
85
+ </div>
86
+ {editing && <RegistrationFormEditor form={form} maxSeats={meta.rsvp.maxSeats} eventTitle={title} onClose={() => setEditing(false)} onChange={(f) => onChange({ rsvp: { ...meta.rsvp, form: f } })} />}
87
+ </div>
88
+ )}
89
+ </Section>
90
+ );
91
+ }
92
+
93
+ // The link only reaches the event (and autosave) once it's valid, so a
94
+ // half-typed link never blocks saving the rest. Blur fills in https://,
95
+ // mailto: or tel: for bare domains, emails and phone numbers.
96
+ function CtaLink({ href, onChange }: { href: string; onChange: (href: string) => void }) {
97
+ const [draft, setDraft] = useState(href);
98
+ const [touched, setTouched] = useState(false);
99
+ useEffect(() => { if (ctaHrefValid(draft.trim()) && draft.trim() !== href) setDraft(href); }, [href]); // eslint-disable-line react-hooks/exhaustive-deps
100
+ const bad = touched && !ctaHrefValid(draft.trim());
101
+ return (
102
+ <label className={`pe-field ${bad ? 'is-invalid' : ''}`}>
103
+ <span>Link</span>
104
+ <input
105
+ type="text" className="mono" placeholder="https://… or /page" value={draft} aria-invalid={bad || undefined} aria-describedby="ev-cta-link-hint"
106
+ onChange={(e) => { const v = e.target.value; setDraft(v); if (ctaHrefValid(v.trim())) onChange(v.trim()); }}
107
+ onBlur={() => { const v = normalizeCtaHref(draft); setDraft(v); setTouched(true); if (ctaHrefValid(v)) onChange(v); }}
108
+ />
109
+ <small id="ev-cta-link-hint" className={`pe-evs-hint ${bad ? 'is-error' : ''}`}>
110
+ {bad ? 'Not saved yet: use a full link like https://example.org, a page like /visit, or mailto: / tel:.' : 'A web address, a page on this site (/visit), mailto: or tel:.'}
111
+ </small>
112
+ </label>
113
+ );
114
+ }
@@ -0,0 +1,142 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { actions } from 'astro:actions';
3
+ import FormBuilder, { starter } from '../../forms/FormBuilder';
4
+ import type { SystemField } from '../../forms/meta';
5
+ import type { FormDefinition, SavedForm } from '../../../lib/forms/schema';
6
+ import '../../../styles/forms.css';
7
+
8
+ type Choice = { id: string; name: string; note: string; definition: () => FormDefinition | undefined };
9
+
10
+ // The event's own registration questions in the full forms builder, on top
11
+ // of the event editor. Edits go straight into the event's meta (its own copy);
12
+ // the event's Update/autosave persists them, so "Done" only closes.
13
+ export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, eventTitle }: {
14
+ form: FormDefinition;
15
+ onChange: (form: FormDefinition) => void;
16
+ onClose: () => void;
17
+ maxSeats: number;
18
+ eventTitle: string;
19
+ }) {
20
+ const ref = useRef<HTMLDialogElement>(null);
21
+ const [templates, setTemplates] = useState<SavedForm[]>([]);
22
+ const [menu, setMenu] = useState<'' | 'templates' | 'more'>('');
23
+ const [replacing, setReplacing] = useState<Choice | null>(null);
24
+ const [saveOpen, setSaveOpen] = useState(false);
25
+ const [templateName, setTemplateName] = useState('');
26
+ const [notice, setNotice] = useState('');
27
+ const [saving, setSaving] = useState(false);
28
+ const [undo, setUndo] = useState<{ name: string; form: FormDefinition } | null>(null);
29
+
30
+ useEffect(() => {
31
+ const d = ref.current;
32
+ if (d && !d.open) d.showModal();
33
+ document.documentElement.classList.add('pe-evr-lock');
34
+ return () => document.documentElement.classList.remove('pe-evr-lock');
35
+ }, []);
36
+ useEffect(() => {
37
+ let live = true;
38
+ actions.forms.list().then(r => { if (live && r.data) setTemplates(r.data.forms.filter(f => f.kind === 'template' && f.purpose === 'event' && f.status !== 'archived')); }).catch(() => {});
39
+ return () => { live = false; };
40
+ }, []);
41
+
42
+ const system: SystemField[] = [
43
+ { label: 'Name', type: 'text', note: 'Bookings uses this to list who is coming.' },
44
+ { label: 'Email', type: 'email', note: 'Confirmations and changes are sent here.' },
45
+ { label: `Seats (max ${maxSeats})`, type: 'select', note: 'Change the limit in Registration → Max per person.' },
46
+ ];
47
+ const choices: Choice[] = [
48
+ { id: 'simple', name: 'Simple RSVP', note: 'Name, email and seats only', definition: () => starter('simple') },
49
+ { id: 'event', name: 'Event registration', note: 'Adds phone, dietary needs and notes', definition: () => starter('event') },
50
+ ...templates.map(t => ({ id: t.id, name: t.name, note: `${t.definition.fields.length} question${t.definition.fields.length === 1 ? '' : 's'}`, definition: () => t.definition })),
51
+ ];
52
+ const apply = (c: Choice) => {
53
+ const definition = c.definition();
54
+ if (definition) { setUndo({ name: c.name, form }); onChange(structuredClone(definition)); }
55
+ setReplacing(null); setMenu('');
56
+ };
57
+ const pick = (c: Choice) => { if (form.fields.length) setReplacing(c); else apply(c); };
58
+
59
+ async function saveTemplate() {
60
+ setSaving(true); setNotice('');
61
+ try {
62
+ const r = await actions.forms.saveTemplate({ name: templateName.trim(), definition: form, purpose: 'event' });
63
+ if (r.error) setNotice(r.error.message);
64
+ else { setTemplates(v => [r.data.form, ...v]); setNotice('Template saved. This event keeps its own copy.'); setTemplateName(''); }
65
+ } catch { setNotice('Could not save the template. Please try again.'); }
66
+ finally { setSaving(false); }
67
+ }
68
+
69
+ return (
70
+ <dialog
71
+ ref={ref}
72
+ className="pe-evr-overlay"
73
+ aria-label="Registration form"
74
+ onCancel={(e) => {
75
+ // Esc first clears whatever is open inside; only an idle Esc closes
76
+ e.preventDefault();
77
+ if (menu || replacing || saveOpen) { setMenu(''); setReplacing(null); setSaveOpen(false); return; }
78
+ const a = document.activeElement;
79
+ if (!a || a === document.body || a === ref.current) onClose();
80
+ }}
81
+ onClick={(e) => { if (menu && !(e.target as HTMLElement).closest('.pe-evr-menu, .pe-evr-menubtn')) setMenu(''); }}
82
+ >
83
+ <header className="pe-evr-bar">
84
+ <div className="pe-evr-crumb"><span>{eventTitle || 'Event'}</span> / <b>Registration form</b></div>
85
+ <span className="pe-evr-hint">Changes apply when you update the event</span>
86
+ <div className="pe-evr-acts">
87
+ <div className="pe-evr-pop">
88
+ <button type="button" className="pe-btn pe-evr-menubtn" aria-expanded={menu === 'templates'} onClick={() => { setMenu(m => m === 'templates' ? '' : 'templates'); setReplacing(null); }}>Use a template ▾</button>
89
+ {menu === 'templates' && (
90
+ <div className="pe-evr-menu" role="menu">
91
+ {replacing ? (
92
+ <div className="pe-evr-confirm">
93
+ <b>Replace your questions?</b>
94
+ <p>Your {form.fields.length} question{form.fields.length === 1 ? '' : 's'} will be replaced by a copy of “{replacing.name}”.</p>
95
+ <div><button type="button" className="pe-btn sm" onClick={() => setReplacing(null)}>Cancel</button><button type="button" className="pe-btn sm primary" onClick={() => apply(replacing)}>Replace</button></div>
96
+ </div>
97
+ ) : (
98
+ <>
99
+ <span className="pe-evr-menu-h">Starters</span>
100
+ {choices.slice(0, 2).map(c => <button key={c.id} type="button" role="menuitem" onClick={() => pick(c)}><b>{c.name}</b><small>{c.note}</small></button>)}
101
+ <span className="pe-evr-menu-h">Your templates</span>
102
+ {choices.length > 2
103
+ ? choices.slice(2).map(c => <button key={c.id} type="button" role="menuitem" onClick={() => pick(c)}><b>{c.name}</b><small>{c.note}</small></button>)
104
+ : <p className="pe-evr-menu-empty">None yet. Use “Save as template” to reuse these questions.</p>}
105
+ </>
106
+ )}
107
+ </div>
108
+ )}
109
+ </div>
110
+ <div className="pe-evr-pop">
111
+ <button type="button" className="pe-btn pe-evr-menubtn" aria-label="More" aria-expanded={menu === 'more'} onClick={() => setMenu(m => m === 'more' ? '' : 'more')}>⋯</button>
112
+ {menu === 'more' && (
113
+ <div className="pe-evr-menu pe-evr-menu-r" role="menu">
114
+ <button type="button" role="menuitem" onClick={() => { setMenu(''); setSaveOpen(true); setNotice(''); }}><b>Save as template…</b><small>Other events can start from a copy</small></button>
115
+ <a role="menuitem" href="/admin/forms?tab=templates" target="_blank" rel="noopener"><b>Manage templates ↗</b></a>
116
+ </div>
117
+ )}
118
+ </div>
119
+ <button type="button" className="pe-btn primary" onClick={onClose}>Done</button>
120
+ </div>
121
+ </header>
122
+ {saveOpen && (
123
+ <div className="pe-evr-save">
124
+ <label className="pe-field"><span>Template name</span><input autoFocus value={templateName} maxLength={120} placeholder="Volunteer registration" onChange={e => setTemplateName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && templateName.trim()) saveTemplate(); }} /></label>
125
+ <button type="button" className="pe-btn primary" disabled={saving || !templateName.trim()} onClick={saveTemplate}>{saving ? 'Saving…' : 'Save template'}</button>
126
+ <button type="button" className="pe-btn" onClick={() => setSaveOpen(false)}>Close</button>
127
+ <p role="status">{notice}</p>
128
+ </div>
129
+ )}
130
+ {undo && (
131
+ <div className="pe-evr-undo" role="status">
132
+ <span>Questions replaced with “{undo.name}”.</span>
133
+ <button type="button" className="pe-btn sm" onClick={() => { onChange(undo.form); setUndo(null); }}>Undo</button>
134
+ <button type="button" className="pe-btn sm ghost" aria-label="Dismiss" onClick={() => setUndo(null)}>✕</button>
135
+ </div>
136
+ )}
137
+ <div className="pe-evr-body">
138
+ <FormBuilder value={form} onChange={onChange} mode="event" systemFields={system} />
139
+ </div>
140
+ </dialog>
141
+ );
142
+ }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ // One row of the event settings column: title + one-line help on the left,
4
+ // controls on the right. `id` doubles as the scroll anchor (the index jumps
5
+ // to it; save errors jump to the section they belong to).
6
+ export function Section({ id, title, help, className = '', children }: { id: string; title: string; help?: ReactNode; className?: string; children: ReactNode }) {
7
+ return (
8
+ <section className={`pe-evsec ${className}`} id={id} aria-labelledby={`${id}-t`}>
9
+ <header className="pe-evsec-h">
10
+ <h3 id={`${id}-t`}>{title}</h3>
11
+ {help && <p>{help}</p>}
12
+ </header>
13
+ <div className="pe-evsec-b">{children}</div>
14
+ </section>
15
+ );
16
+ }
17
+
18
+ // scroll a section into view and tint it briefly
19
+ export function focusEventCard(id: string) {
20
+ const el = document.getElementById(id);
21
+ if (!el) return;
22
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
23
+ el.classList.remove('is-flash');
24
+ void el.offsetWidth;
25
+ el.classList.add('is-flash');
26
+ el.querySelector<HTMLElement>('.pe-evsec-b input, .pe-evsec-b select, .pe-evsec-b textarea, .pe-evsec-b button')?.focus({ preventScroll: true });
27
+ }