@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
@@ -0,0 +1,217 @@
1
+ import { useMemo, useState } from 'react';
2
+ import WhereItLands from '../../calendar/WhereItLands';
3
+ import { buildRepeat, describeRepeat, parseRepeat, WEEKDAY_CODES, weekdayOf, type RepeatParts } from '../../../lib/calendar/rrule';
4
+ import type { EventMeta } from '../EventPanel';
5
+ import { Section } from './Section';
6
+ import { datePart, fromMin, timePart, toMin, zoneList } from './dates';
7
+
8
+ const DAY_LETTERS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
9
+ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
10
+ const ORD = ['first', 'second', 'third', 'fourth', 'last'];
11
+ const UNITS: Record<string, [string, string] | undefined> = { DAILY: ['day', 'days'], WEEKLY: ['week', 'weeks'], MONTHLY: ['month', 'months'], YEARLY: ['year', 'years'] };
12
+
13
+ interface Props {
14
+ meta: EventMeta;
15
+ onChange: (patch: Partial<EventMeta>) => void;
16
+ siteTimezone: string;
17
+ // shows the "where it lands" mini month (Schedule data, minus this event)
18
+ eventId?: string;
19
+ }
20
+
21
+ export function WhenCard({ meta, onChange, siteTimezone, eventId }: Props) {
22
+ const repeat = useMemo(() => parseRepeat(meta.rrule, meta.start), [meta.rrule, meta.start]);
23
+ const [skipDraft, setSkipDraft] = useState<string | null>(null);
24
+ const [tzEdit, setTzEdit] = useState(false);
25
+ const zones = useMemo(zoneList, []);
26
+
27
+ const setRepeat = (patch: Partial<RepeatParts>) => onChange({ rrule: buildRepeat({ ...repeat, ...patch }, meta.start) });
28
+
29
+ // moving the start keeps the duration; the end never lands before the start
30
+ const setStart = (start: string) => {
31
+ if (!start) return;
32
+ const dur = Math.max(meta.allDay ? 0 : 15, toMin(meta.end || meta.start) - toMin(meta.start));
33
+ const patch: Partial<EventMeta> = { start, end: fromMin(toMin(start) + dur, !meta.allDay) };
34
+ // a weekly rule that pinned the old weekday follows the new one
35
+ if (repeat.freq === 'WEEKLY' && repeat.byday.length === 1) {
36
+ patch.rrule = buildRepeat({ ...repeat, byday: [WEEKDAY_CODES[weekdayOf(start).dow]] }, start);
37
+ } else if (meta.rrule) patch.rrule = buildRepeat(repeat, start);
38
+ onChange(patch);
39
+ };
40
+ const setEnd = (end: string) => {
41
+ if (!end) return;
42
+ onChange({ end: toMin(end) < toMin(meta.start) ? meta.start : end });
43
+ };
44
+ const toggleAllDay = (allDay: boolean) => {
45
+ if (allDay) onChange({ allDay, start: datePart(meta.start), end: datePart(meta.end || meta.start) });
46
+ else onChange({ allDay, start: `${datePart(meta.start)}T10:00`, end: `${datePart(meta.end || meta.start)}T11:00` });
47
+ };
48
+
49
+ const wd = weekdayOf(meta.start || new Date().toISOString().slice(0, 10));
50
+ const summary = describeRepeat(meta.rrule, meta.start);
51
+ const endDate = datePart(meta.end || meta.start);
52
+
53
+ const zoneOpen = tzEdit || !meta.timezone && !siteTimezone;
54
+ const unit = UNITS[repeat.freq];
55
+ const fmtDay = (d: string) => new Date(`${datePart(d)}T12:00:00Z`).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
56
+ const addSkip = (day: string) => {
57
+ if (!day) return;
58
+ // an exdate is an occurrence START (same form as `start`)
59
+ const value = meta.allDay ? day : `${day}T${timePart(meta.start)}`;
60
+ if (!meta.exdates.includes(value)) onChange({ exdates: [...meta.exdates, value].sort() });
61
+ setSkipDraft(null);
62
+ };
63
+
64
+ return (
65
+ <Section id="ev-when" title="When" help={<>Times are in the event’s zone. Change it for events held elsewhere.</>}>
66
+ <div className={`pe-evw ${eventId && datePart(meta.start) ? 'has-lands' : ''}`}>
67
+ <div className="pe-evw-main">
68
+ <div className={`pe-evw-times ${meta.allDay ? 'is-allday' : ''}`}>
69
+ <span className="pe-evw-key">Starts</span>
70
+ <input type="date" aria-label="Start date" value={datePart(meta.start)} onChange={(e) => setStart(meta.allDay ? e.target.value : `${e.target.value}T${timePart(meta.start) || '10:00'}`)} />
71
+ {!meta.allDay && <input type="time" aria-label="Start time" step={300} value={timePart(meta.start)} onChange={(e) => setStart(`${datePart(meta.start)}T${e.target.value}`)} />}
72
+ <span className="pe-evw-key">Ends</span>
73
+ <input type="date" aria-label="End date" min={datePart(meta.start)} value={endDate} onChange={(e) => setEnd(meta.allDay ? e.target.value : `${e.target.value}T${timePart(meta.end) || timePart(meta.start)}`)} />
74
+ {!meta.allDay && <input type="time" aria-label="End time" step={300} value={timePart(meta.end)} onChange={(e) => setEnd(`${endDate}T${e.target.value}`)} />}
75
+ </div>
76
+
77
+ <div className="pe-evw-opts">
78
+ <label className="pe-evw-allday">
79
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.allDay} onChange={(e) => toggleAllDay(e.target.checked)} />
80
+ <span>All day</span>
81
+ </label>
82
+ <div className="pe-evw-tz">
83
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></svg>
84
+ {zoneOpen ? (
85
+ <input
86
+ type="text"
87
+ list="pe-ev-zones"
88
+ aria-label="Time zone"
89
+ placeholder={siteTimezone || 'UTC'}
90
+ value={meta.timezone}
91
+ spellCheck={false}
92
+ autoFocus={tzEdit}
93
+ onChange={(e) => onChange({ timezone: e.target.value.trim() })}
94
+ onBlur={() => setTzEdit(false)}
95
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); setTzEdit(false); } }}
96
+ />
97
+ ) : (
98
+ <>
99
+ <span>{meta.timezone || siteTimezone}{!meta.timezone && <em> site default</em>}</span>
100
+ <button type="button" className="pe-evw-link" onClick={() => setTzEdit(true)} aria-label="Change time zone">change</button>
101
+ </>
102
+ )}
103
+ <datalist id="pe-ev-zones">{zones.map((z) => <option key={z} value={z} />)}</datalist>
104
+ </div>
105
+ </div>
106
+
107
+ <div className="pe-evw-repeat">
108
+ <span className="pe-evw-sub">Repeat</span>
109
+ <div className="pe-evw-sentence">
110
+ <select
111
+ aria-label="Repeat"
112
+ value={repeat.freq}
113
+ onChange={(e) => {
114
+ const freq = e.target.value as RepeatParts['freq'];
115
+ if (freq === 'custom') onChange({ rrule: meta.rrule || 'FREQ=WEEKLY' });
116
+ else setRepeat({ freq, interval: 1 });
117
+ }}
118
+ >
119
+ <option value="none">Does not repeat</option>
120
+ <option value="DAILY">Daily</option>
121
+ <option value="WEEKLY">Weekly</option>
122
+ <option value="MONTHLY">Monthly</option>
123
+ <option value="YEARLY">Yearly</option>
124
+ <option value="custom">Custom rule…</option>
125
+ </select>
126
+ {unit && (
127
+ <>
128
+ <span className="pe-evw-word">every</span>
129
+ <input type="number" className="pe-evw-num" aria-label="Interval" min={1} max={99} value={repeat.interval} onChange={(e) => setRepeat({ interval: Math.max(1, Number(e.target.value) || 1) })} />
130
+ <span className="pe-evw-word">{repeat.interval === 1 ? unit[0] : unit[1]}</span>
131
+ <select
132
+ aria-label="Ends"
133
+ value={repeat.ends}
134
+ onChange={(e) => {
135
+ const ends = e.target.value as RepeatParts['ends'];
136
+ if (ends === 'on') setRepeat({ ends, until: repeat.until || datePart(fromMin(toMin(meta.start) + 60 * 24 * 90, false)) });
137
+ else setRepeat({ ends });
138
+ }}
139
+ >
140
+ <option value="never">Never ends</option>
141
+ <option value="on">Ends on</option>
142
+ <option value="after">Ends after</option>
143
+ </select>
144
+ {repeat.ends === 'on' && <input type="date" aria-label="Last date" min={datePart(meta.start)} value={repeat.until} onChange={(e) => setRepeat({ until: e.target.value })} />}
145
+ {repeat.ends === 'after' && (
146
+ <>
147
+ <input type="number" className="pe-evw-num" aria-label="Number of times" min={1} max={999} value={repeat.count} onChange={(e) => setRepeat({ count: Math.max(1, Number(e.target.value) || 1) })} />
148
+ <span className="pe-evw-word">times</span>
149
+ </>
150
+ )}
151
+ </>
152
+ )}
153
+ </div>
154
+ {summary && (repeat.freq === 'custom' || repeat.freq === 'WEEKLY' || repeat.freq === 'MONTHLY') && <p className="pe-evw-sum">{summary}</p>}
155
+ {repeat.freq === 'WEEKLY' && (
156
+ <div className="pe-ev-days" role="group" aria-label="Days of the week">
157
+ {WEEKDAY_CODES.map((code, i) => {
158
+ const on = repeat.byday.includes(code);
159
+ return (
160
+ <button
161
+ key={code}
162
+ type="button"
163
+ aria-pressed={on}
164
+ title={DAY_NAMES[i]}
165
+ onClick={() => {
166
+ const next = on ? repeat.byday.filter((c) => c !== code) : [...repeat.byday, code];
167
+ setRepeat({ byday: next.length ? next : [code] });
168
+ }}
169
+ >
170
+ {DAY_LETTERS[i]}
171
+ </button>
172
+ );
173
+ })}
174
+ </div>
175
+ )}
176
+ {repeat.freq === 'MONTHLY' && (
177
+ <div className="pe-ev-choices">
178
+ <label><input type="radio" name="pe-monthly" checked={repeat.monthly === 'day'} onChange={() => setRepeat({ monthly: 'day' })} /> On day {wd.day}</label>
179
+ <label><input type="radio" name="pe-monthly" checked={repeat.monthly === 'weekday'} onChange={() => setRepeat({ monthly: 'weekday' })} /> On the {ORD[Math.min(wd.nth, 5) - 1]} {DAY_NAMES[wd.dow]}</label>
180
+ </div>
181
+ )}
182
+ {repeat.freq === 'custom' && (
183
+ <label className="pe-field">
184
+ <span>RRULE <em>RFC 5545</em></span>
185
+ <input type="text" className="mono" spellCheck={false} value={meta.rrule} placeholder="FREQ=MONTHLY;BYDAY=2TU" onChange={(e) => onChange({ rrule: e.target.value.toUpperCase() })} />
186
+ </label>
187
+ )}
188
+ {meta.rrule && (
189
+ <div className="pe-evw-skips">
190
+ <span className="pe-evw-word">Skip dates:</span>
191
+ {meta.exdates.map((d) => (
192
+ <span key={d} className="pe-evw-skip">
193
+ {fmtDay(d)}
194
+ <button type="button" aria-label={`Stop skipping ${datePart(d)}`} onClick={() => onChange({ exdates: meta.exdates.filter((x) => x !== d) })}>✕</button>
195
+ </span>
196
+ ))}
197
+ {skipDraft === null ? (
198
+ <button type="button" className="pe-evw-link" onClick={() => setSkipDraft('')}>+ Skip a date</button>
199
+ ) : (
200
+ <span className="pe-evw-skipadd">
201
+ <input type="date" aria-label="Date to skip" autoFocus value={skipDraft} min={datePart(meta.start)} onChange={(e) => setSkipDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSkip(skipDraft); } else if (e.key === 'Escape') setSkipDraft(null); }} />
202
+ <button type="button" className="pe-btn sm" disabled={!skipDraft} onClick={() => addSkip(skipDraft)}>Skip</button>
203
+ <button type="button" className="pe-btn ghost sm" onClick={() => setSkipDraft(null)}>Cancel</button>
204
+ </span>
205
+ )}
206
+ </div>
207
+ )}
208
+ </div>
209
+ </div>
210
+
211
+ {eventId && datePart(meta.start) && (
212
+ <WhereItLands date={datePart(meta.start)} eventId={eventId} onPick={(d) => setStart(meta.allDay ? d : `${d}T${timePart(meta.start) || '10:00'}`)} />
213
+ )}
214
+ </div>
215
+ </Section>
216
+ );
217
+ }
@@ -0,0 +1,28 @@
1
+ // Wall-clock helpers for the event sheet. Times are strings in the event's
2
+ // zone: 'YYYY-MM-DDTHH:mm' (timed) or 'YYYY-MM-DD' (all-day, inclusive end).
3
+
4
+ export const datePart = (s: string) => s.slice(0, 10);
5
+ export const timePart = (s: string) => (s.length > 10 ? s.slice(11, 16) : '');
6
+
7
+ // minutes since the epoch, treating the wall clock as UTC (only differences matter)
8
+ export const toMin = (s: string) => {
9
+ const [y, m, d] = s.slice(0, 10).split('-').map(Number);
10
+ const [hh, mm] = s.length > 10 ? s.slice(11, 16).split(':').map(Number) : [0, 0];
11
+ return Date.UTC(y, m - 1, d, hh, mm) / 60000;
12
+ };
13
+
14
+ export const fromMin = (min: number, withTime: boolean) => {
15
+ const d = new Date(min * 60000);
16
+ const p = (n: number) => String(n).padStart(2, '0');
17
+ const date = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
18
+ return withTime ? `${date}T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}` : date;
19
+ };
20
+
21
+ // every IANA zone the browser knows, for the timezone datalist
22
+ export function zoneList(): string[] {
23
+ try {
24
+ return (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
@@ -0,0 +1,39 @@
1
+ import type { EventCta } from '../../../lib/schema';
2
+
3
+ // The Registration card is one choice over two stored fields: `cta` (a link
4
+ // button) and `rsvp` (the "Save my seat" form, which replaces the button on
5
+ // the public page). No schema change — the mode is derived from the data.
6
+
7
+ export type RegistrationMode = 'none' | 'link' | 'rsvp';
8
+
9
+ interface RegistrationData {
10
+ cta: EventCta;
11
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number };
12
+ }
13
+
14
+ export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
15
+ if (rsvp.enabled) return 'rsvp';
16
+ return cta.label.trim() || cta.href.trim() ? 'link' : 'none';
17
+ }
18
+
19
+ // the patch that switches modes. None clears the button (the public page
20
+ // shows one whenever it has a label or link); RSVP keeps the button's data
21
+ // so switching back restores it.
22
+ export function registrationPatch(mode: RegistrationMode, data: RegistrationData): Partial<RegistrationData> {
23
+ if (mode === 'rsvp') return { rsvp: { ...data.rsvp, enabled: true } };
24
+ if (mode === 'link') return { rsvp: { ...data.rsvp, enabled: false } };
25
+ return { rsvp: { ...data.rsvp, enabled: false }, cta: { ...data.cta, label: '', href: '' } };
26
+ }
27
+
28
+ // same rule the server enforces on save (lib/content-service.ts)
29
+ export const ctaHrefValid = (href: string) => !href || /^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(href);
30
+
31
+ // finish what people usually type: a bare domain, an email or a phone number
32
+ export function normalizeCtaHref(raw: string): string {
33
+ const v = raw.trim();
34
+ if (!v || ctaHrefValid(v)) return v;
35
+ if (/^[^\s@/]+@[^\s@]+\.[^\s@]+$/.test(v)) return `mailto:${v}`;
36
+ if (/^\+?[\d\s().-]{7,}$/.test(v)) return `tel:${v.replace(/[\s().-]/g, '')}`;
37
+ if (/^([\w-]+\.)+[a-z]{2,}(:\d+)?([/?#]\S*)?$/i.test(v)) return `https://${v}`;
38
+ return v;
39
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Node } from '@tiptap/core';
3
+ import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from '@tiptap/react';
4
+ import { actions } from 'astro:actions';
5
+ import '../../../styles/form-block.css';
6
+
7
+ export const FormCard = Node.create({
8
+ name: 'formCard', group: 'block', atom: true, draggable: true,
9
+ addAttributes() { return { formId: { default: '' } }; },
10
+ parseHTML() { return [{ tag: 'div[data-dyn="form"]', getAttrs: el => ({ formId: (el as HTMLElement).getAttribute('data-form') ?? '' }) }]; },
11
+ renderHTML({ node }) { return ['div', { 'data-dyn': 'form', 'data-form': node.attrs.formId }]; },
12
+ addNodeView() { return ReactNodeViewRenderer(FormCardView); },
13
+ });
14
+
15
+ type Preview = { name: string; status: string; hasDraft: boolean; title: string; intro: string; submitLabel: string; html: string };
16
+
17
+ function FormCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
18
+ const id: string = node.attrs.formId;
19
+ const [forms, setForms] = useState<{ id: string; name: string; status: string }[] | null>(null);
20
+ const [preview, setPreview] = useState<Preview | null>(null);
21
+ const [error, setError] = useState('');
22
+ useEffect(() => { let live = true; actions.forms.list().then(r => {
23
+ if (!live) return;
24
+ if (r.error) setError('Could not load forms.');
25
+ else setForms(r.data.forms.filter(f => f.kind === 'form' && f.purpose === 'general' && (f.status !== 'archived' || f.id === id)));
26
+ }).catch(() => { if (live) setError('Could not load forms.'); }); return () => { live = false; }; }, []);
27
+ useEffect(() => {
28
+ let live = true; setPreview(null);
29
+ if (id) actions.forms.preview({ id }).then(r => { if (live) r.error ? setError('This form no longer exists.') : (setError(''), setPreview(r.data)); }).catch(() => { if (live) setError('Could not load the preview.'); });
30
+ return () => { live = false; };
31
+ }, [id]);
32
+
33
+ const status = preview ? preview.status === 'published'
34
+ ? <span className="pfb-st is-live"><i aria-hidden="true" />Published{preview.hasDraft ? ' · unpublished changes not shown' : ''}</span>
35
+ : <span className="pfb-st is-hidden"><i aria-hidden="true" />{preview.status === 'archived' ? 'Archived' : 'Not published'} — hidden on the site</span> : null;
36
+
37
+ return <NodeViewWrapper className={`pe-card pfb-card ${selected ? 'is-selected' : ''}`} data-drag-handle>
38
+ <div className="pe-card-tools" contentEditable={false}><button type="button" title="Remove form" onClick={deleteNode}>✕</button></div>
39
+ <div className="pfb-bar" contentEditable={false}>
40
+ <span className="pfb-kind">Form</span>
41
+ <select aria-label="Form to display" value={id} onChange={e => updateAttributes({ formId: e.target.value })}>
42
+ <option value="">{forms === null ? 'Loading forms…' : forms.length ? 'Choose a form…' : 'No forms yet'}</option>
43
+ {forms?.map(f => <option value={f.id} key={f.id}>{f.name}{f.status !== 'published' ? ` (${f.status})` : ''}</option>)}
44
+ </select>
45
+ {status}
46
+ <a className="pfb-link" href={id ? `/admin/forms/${id}` : '/admin/forms'} target="_blank" rel="noopener">{id ? 'Edit form ↗' : 'Manage forms ↗'}</a>
47
+ </div>
48
+ <div className="pfb-stage" contentEditable={false}>
49
+ {error ? <p className="pfb-note">{error}</p>
50
+ : !id ? <p className="pfb-note">Choose a form to place it here. Only published forms appear on your site.</p>
51
+ : !preview ? <p className="pfb-note">Loading preview…</p>
52
+ : <fieldset disabled className="pfb-preview" aria-label={`Preview of ${preview.name}`} tabIndex={-1}>
53
+ {preview.title && <h3>{preview.title}</h3>}
54
+ {preview.intro && <p className="pfb-intro">{preview.intro}</p>}
55
+ {preview.html ? <div className="pfb-fields" dangerouslySetInnerHTML={{ __html: preview.html }} /> : <p className="pfb-note">This form has no questions yet.</p>}
56
+ <span className="pfb-submit">{preview.submitLabel}</span>
57
+ </fieldset>}
58
+ </div>
59
+ </NodeViewWrapper>;
60
+ }
@@ -96,6 +96,11 @@ const ITEMS: SlashItem[] = [
96
96
  icon: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4M7 13h4v4H7z',
97
97
  run: (editor: Editor, range: Range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'eventCard' }).run(),
98
98
  } satisfies SlashItem] : []),
99
+ {
100
+ title: 'Form', description: 'Add a published contact or sign-up form', keywords: 'form contact signup register', group: 'Cards',
101
+ icon: 'M5 3h14v18H5zM8 7h8M8 11h8M8 15h5',
102
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'formCard' }).run(),
103
+ },
99
104
  {
100
105
  title: 'Info cards', description: 'Grid of cards: title, badge, details', keywords: 'info cards grid services hours schedule team features locations badge', group: 'Cards',
101
106
  icon: 'M3 5h18v14H3zM7 9h6M15.5 9h2M7 13h7M7 16h5',
@@ -0,0 +1,20 @@
1
+ import { useCallback, useState, type RefObject } from 'react';
2
+
3
+ // Tracks whether a preview iframe has left the page it was opened on (a CTA,
4
+ // a nav link, another site). Cross-origin pages can't be read, which itself
5
+ // means "away". `back` reloads the original page by remounting the frame.
6
+ export function useFrameAway(frame: RefObject<HTMLIFrameElement | null>, src: string) {
7
+ const [away, setAway] = useState('');
8
+ const [nav, setNav] = useState(0);
9
+ const check = useCallback(() => {
10
+ const home = new URL(src, location.href);
11
+ let here: URL | null = null;
12
+ try { here = new URL(frame.current?.contentWindow?.location.href ?? home.href); } catch { here = null; }
13
+ if (!here) return setAway('another site'), true;
14
+ const same = here.origin === home.origin && here.pathname.replace(/\/$/, '') === home.pathname.replace(/\/$/, '');
15
+ setAway(same ? '' : here.origin === home.origin ? here.pathname : here.host);
16
+ return !same;
17
+ }, [frame, src]);
18
+ const back = useCallback(() => { setAway(''); setNav((n) => n + 1); }, []);
19
+ return { away, nav, check, back };
20
+ }
@@ -0,0 +1,149 @@
1
+ import { Fragment, useId, useLayoutEffect, useRef, useState } from 'react';
2
+ import { escapeForm, renderControl, renderField, renderFields } from '../../lib/forms/fields';
3
+ import { isHalf, type FormDefinition, type FormField } from '../../lib/forms/schema';
4
+ import { answersFrom, firstError } from '../../lib/forms/validate';
5
+ import { META, type Selection, type SystemField } from './meta';
6
+
7
+ /** Plain-text contentEditable that doesn't fight the caret: the DOM is only rewritten when the value changes from outside. */
8
+ export function Inline({ value, onChange, onDone, placeholder, max, multiline = false, label, className = '' }: {
9
+ value: string; onChange: (v: string) => void; onDone?: () => void; placeholder: string; max: number; multiline?: boolean; label: string; className?: string;
10
+ }) {
11
+ const ref = useRef<HTMLSpanElement>(null);
12
+ useLayoutEffect(() => { const el = ref.current; if (el && el.textContent !== value && document.activeElement !== el) el.textContent = value; }, [value]);
13
+ return (
14
+ <span
15
+ ref={ref} className={`fb-inline ${className}`} contentEditable="plaintext-only" suppressContentEditableWarning spellCheck
16
+ role="textbox" aria-label={label} aria-multiline={multiline} data-placeholder={placeholder}
17
+ onInput={e => {
18
+ const el = e.currentTarget;
19
+ let text = el.textContent ?? '';
20
+ if (!multiline && /\n/.test(text)) text = text.replace(/\n+/g, ' ');
21
+ if (text.length > max) { text = text.slice(0, max); el.textContent = text; getSelection()?.selectAllChildren(el); getSelection()?.collapseToEnd(); }
22
+ onChange(text);
23
+ }}
24
+ onKeyDown={e => { if ((e.key === 'Enter' && !multiline) || e.key === 'Escape') { e.preventDefault(); e.currentTarget.blur(); } }}
25
+ onPaste={e => {
26
+ // Fallback for browsers without plaintext-only: insert text, never markup.
27
+ e.preventDefault();
28
+ document.execCommand('insertText', false, e.clipboardData.getData('text/plain'));
29
+ }}
30
+ onBlur={() => { const el = ref.current; if (el && el.textContent !== value) el.textContent = value; onDone?.(); }}
31
+ />
32
+ );
33
+ }
34
+
35
+ type CanvasProps = {
36
+ value: FormDefinition; system: SystemField[]; selection: Selection; event: boolean;
37
+ onSelect: (s: Selection) => void; patch: (next: Partial<FormDefinition>, key: string) => void;
38
+ patchField: (id: string, next: Partial<FormField>, key: string) => void; onLabelDone: (id: string) => void;
39
+ onInsertAfter: (id: string | null) => void; onDuplicate: (id: string) => void; onRemove: (id: string) => void;
40
+ };
41
+
42
+ /** The form drawn with the public form markup/classes; labels, help, title, intro and the button edit in place. */
43
+ export function Canvas(p: CanvasProps) {
44
+ const { value, system, selection, event, onSelect } = p;
45
+ const prefix = useId().replace(/:/g, '');
46
+ const sel = (s: Selection) => JSON.stringify(s) === JSON.stringify(selection);
47
+ const submitFallback = event ? 'Confirm my seat' : 'Submit';
48
+
49
+ if (selection?.kind === 'thanks') return (
50
+ <div className="fb-paper fb-paper-done">
51
+ <p className="fb-paper-tag">After someone submits</p>
52
+ <h2 className="fb-title">{value.title || 'Thank you'}</h2>
53
+ <p className={`fb-thanks on`}><Inline value={value.confirmation} onChange={v => p.patch({ confirmation: v }, 'confirmation')} placeholder={event ? 'Uses the booking confirmation message' : 'Thank you. Your response has been received.'} max={1000} multiline label="Thank-you message" /></p>
54
+ <button type="button" className="fb-link" onClick={() => onSelect(null)}>← Back to the questions</button>
55
+ </div>
56
+ );
57
+
58
+ // Pair consecutive half-width questions exactly like renderFields does.
59
+ const rows: FormField[][] = [];
60
+ for (const f of value.fields) {
61
+ const last = rows[rows.length - 1];
62
+ if (isHalf(f) && last && last.length === 1 && isHalf(last[0])) last.push(f); else rows.push([f]);
63
+ }
64
+ const item = (f: FormField) => <Item key={f.id} {...p} field={f} prefix={prefix} on={sel({ kind: 'field', id: f.id })} />;
65
+
66
+ return (
67
+ <div className="fb-paper" onClick={e => { if (e.target === e.currentTarget) onSelect(null); }}>
68
+ <div className={`fb-item fb-head${sel({ kind: 'form' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'form' })}>
69
+ <h2 className="fb-title"><Inline value={value.title} onChange={v => p.patch({ title: v }, 'title')} placeholder="Form title" max={120} label="Form title" /></h2>
70
+ <p className="fb-intro"><Inline value={value.intro} onChange={v => p.patch({ intro: v }, 'intro')} placeholder="Add an introduction (optional)" max={1000} multiline label="Introduction" /></p>
71
+ </div>
72
+ {system.map((f, i) => (
73
+ <div key={`sys-${i}`} className={`fb-item fb-sys${sel({ kind: 'system', index: i }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'system', index: i })} title="Always asked">
74
+ <div inert dangerouslySetInnerHTML={{ __html: renderField({ id: `sys${i}`, type: f.type, label: f.label, required: true, help: '', options: f.type === 'select' ? ['1'] : [] }, '', `${prefix}-sys`) }} />
75
+ </div>
76
+ ))}
77
+ {rows.map((row, i) => row.length === 2 ? <div key={row[0].id} className="pc-form-row">{row.map(item)}</div> : <Fragment key={row[0].id}>{row[0].width === 'half' ? <div className="pc-form-row">{item(row[0])}</div> : item(row[0])}</Fragment>)}
78
+ {!value.fields.length && (
79
+ <button type="button" className="fb-empty" onClick={() => p.onInsertAfter(null)}>
80
+ <b>Add your first question</b><span>Press / or click here to pick a question type.</span>
81
+ </button>
82
+ )}
83
+ <div className={`fb-item fb-submit-wrap${sel({ kind: 'submit' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'submit' })}>
84
+ <div className="pc-bk-submit fb-submit"><Inline value={value.submitLabel} onChange={v => p.patch({ submitLabel: v }, 'submit')} placeholder={submitFallback} max={80} label="Submit button label" /></div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ function Item({ field: f, prefix, on, onSelect, patchField, onLabelDone, onInsertAfter, onDuplicate, onRemove }: CanvasProps & { field: FormField; prefix: string; on: boolean }) {
91
+ const id = `${prefix}-${f.id}`;
92
+ const label = <Inline value={f.label} onChange={v => patchField(f.id, { label: v }, `label:${f.id}`)} onDone={() => onLabelDone(f.id)} placeholder={META[f.type].fresh} max={200} label={`${META[f.type].label} label`} />;
93
+ const req = f.required ? <span className="fb-req-mark" aria-hidden="true"> *</span> : null;
94
+ const help = (f.help || on) ? <small className="fb-help"><Inline value={f.help} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} placeholder={f.type === 'heading' ? 'Add a description (optional)' : 'Add help text (optional)'} max={300} label="Help text" /></small> : null;
95
+ const control = <div className="fb-control" inert dangerouslySetInnerHTML={{ __html: renderControl(f, '', id) }} />;
96
+ const body = f.type === 'heading'
97
+ ? <div className="pc-form-heading"><h3>{label}</h3>{(f.help || on) && <p><Inline value={f.help} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} placeholder="Add a description (optional)" max={300} label="Section description" /></p>}</div>
98
+ : f.type === 'checkbox' || f.type === 'consent'
99
+ ? <div className="pc-bk-check"><input type="checkbox" tabIndex={-1} aria-hidden="true" readOnly checked={false} /><span>{label}{req}{help}</span></div>
100
+ : f.type === 'radio' || f.type === 'checkboxes'
101
+ ? <div className="pc-bk-field pc-bk-choices"><div className="fb-legend">{label}{req}</div>{control}{help}</div>
102
+ : <div className="pc-bk-field"><span>{label}{req}</span>{control}{help}</div>;
103
+ return (
104
+ <div className={`fb-item${on ? ' on' : ''}${isHalf(f) ? ' is-half' : ''}`} data-item={f.id} tabIndex={-1} aria-label={`${META[f.type].label}: ${f.label}`}
105
+ onClick={e => { onSelect({ kind: 'field', id: f.id }); if (!(e.target as HTMLElement).closest('.fb-inline, button')) e.currentTarget.focus({ preventScroll: true }); }}>
106
+ {body}
107
+ {on && (
108
+ <div className="fb-item-tools" onClick={e => e.stopPropagation()}>
109
+ <button type="button" className="fb-icon" title="Add a question below" aria-label="Add a question below" onClick={() => onInsertAfter(f.id)}>+</button>
110
+ <button type="button" className="fb-icon" title="Duplicate (⌘D)" aria-label="Duplicate question" onClick={() => onDuplicate(f.id)}>
111
+ <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" /></svg>
112
+ </button>
113
+ <button type="button" className="fb-icon danger" title="Delete (⌫)" aria-label="Delete question" onClick={() => onRemove(f.id)}>
114
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>
115
+ </button>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ /** Interactive preview: type, tick and press the button to check validation. Nothing is sent. */
123
+ export function Preview({ value, system, event, device }: { value: FormDefinition; system: SystemField[]; event: boolean; device: 'desktop' | 'phone' }) {
124
+ const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
125
+ const formRef = useRef<HTMLFormElement>(null);
126
+ const e = escapeForm;
127
+ const sys = system.map((f, i) => renderField({ id: `sys${i}`, type: f.type, label: f.label, required: f.type !== 'select', help: '', options: f.type === 'select' ? ['1', '2'] : [] }, '', 'pv-sys')).join('');
128
+ const html = `${value.title ? `<h2>${e(value.title)}</h2>` : ''}${value.intro ? `<p class="fb-pv-intro">${e(value.intro)}</p>` : ''}${sys}${renderFields(value, {}, 'pv')}`;
129
+ const key = JSON.stringify(value);
130
+ return (
131
+ <div className={`fb-pv-frame ${device}`}>
132
+ <form
133
+ ref={formRef} key={key} className="fb-paper fb-pv" onInput={() => setResult(null)}
134
+ onSubmit={ev => {
135
+ ev.preventDefault();
136
+ const data = new FormData(ev.currentTarget);
137
+ try {
138
+ const error = firstError(value, answersFrom(name => data.getAll(name), value));
139
+ setResult(error ? { ok: false, text: error } : { ok: true, text: value.confirmation || (event ? 'Your seat is saved.' : 'Thank you. Your response has been received.') });
140
+ } catch (err) { setResult({ ok: false, text: err instanceof Error ? err.message : 'Invalid answer.' }); }
141
+ }}
142
+ >
143
+ <div dangerouslySetInnerHTML={{ __html: html }} />
144
+ {result && <p className={`fb-pv-result ${result.ok ? 'ok' : 'bad'}`} role="status">{result.ok ? <><b>Looks good.</b> Visitors would see: “{result.text}” Nothing was sent.</> : result.text}</p>}
145
+ <button type="submit" className="pc-bk-submit">{value.submitLabel || (event ? 'Confirm my seat' : 'Submit')}</button>
146
+ </form>
147
+ </div>
148
+ );
149
+ }