@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.
- package/CHANGELOG.md +23 -0
- package/cli/remote.mjs +1 -1
- package/docs/theme-authoring.md +5 -2
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +148 -2
- package/src/components/DesignManager.tsx +34 -3
- package/src/components/EventPage.astro +25 -5
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/components/import/ImportExport.tsx +1 -1
- package/src/components/site/SiteLogo.astro +23 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/layouts/Site.astro +5 -4
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/design-service.ts +2 -2
- package/src/lib/designDiff.ts +2 -2
- package/src/lib/drafts.ts +11 -2
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/import/bundle.ts +1 -0
- package/src/lib/import/export.ts +1 -1
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +3 -3
- package/src/lib/mobile/site.ts +1 -1
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +104 -14
- package/src/lib/repo/types.ts +22 -2
- package/src/lib/schema.ts +39 -0
- package/src/lib/site-chrome.ts +1 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +12 -7
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- 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
|
+
}
|