@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,232 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+ import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
3
+ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
4
+ import { FIELD_ID, type FormDefinition, type FormField } from '../../lib/forms/schema';
5
+ import { verticalOnly } from './dnd';
6
+ import { META, TYPE_CHOICES, canOther, fieldIssue, isChoice, takesPlaceholder, type FieldType, type Selection, type SystemField } from './meta';
7
+
8
+ type Props = {
9
+ value: FormDefinition; selection: Selection; system: SystemField[]; event: boolean; phone: boolean;
10
+ name?: string; onNameChange?: (name: string) => void; lockedKeys: Set<string>;
11
+ patch: (next: Partial<FormDefinition>, key: string) => void;
12
+ patchField: (id: string, next: Partial<FormField>, key: string) => void;
13
+ setType: (id: string, type: FieldType) => void; onLabelDone: (id: string) => void; setKey: (id: string, key: string) => void;
14
+ actions: { move: (id: string, dir: -1 | 1) => void; duplicate: (id: string) => void; remove: (id: string) => void };
15
+ onAdd: () => void;
16
+ };
17
+
18
+ /** Settings for whatever is selected: a question, the title/intro, the button, the thank-you message or a locked system question. */
19
+ export function Inspector(p: Props) {
20
+ const { value, selection } = p;
21
+ if (selection?.kind === 'field') {
22
+ const field = value.fields.find(f => f.id === selection.id);
23
+ if (field) return <FieldSettings {...p} field={field} />;
24
+ }
25
+ if (selection?.kind === 'form') return (
26
+ <div className="fb-insp-body">
27
+ <Head title="Title & introduction" />
28
+ {p.onNameChange && <Text label="Name in the admin" hint="Only you see this" value={p.name ?? ''} max={120} onChange={p.onNameChange} />}
29
+ <Text label="Form title" value={value.title} max={120} onChange={v => p.patch({ title: v }, 'title')} />
30
+ <Text label="Introduction" value={value.intro} max={1000} rows={4} onChange={v => p.patch({ intro: v }, 'intro')} />
31
+ {p.event && <p className="fb-note">On the event page, the title and introduction appear above the registration fields.</p>}
32
+ </div>
33
+ );
34
+ if (selection?.kind === 'submit') return (
35
+ <div className="fb-insp-body">
36
+ <Head title="Submit button" />
37
+ <Text label="Button label" value={value.submitLabel} max={80} placeholder={p.event ? 'Confirm my seat / Request a seat' : 'Submit'} onChange={v => p.patch({ submitLabel: v }, 'submit')} />
38
+ {p.event && <p className="fb-note">Leave empty to use “Confirm my seat”, or “Request a seat” when RSVPs need approval.</p>}
39
+ </div>
40
+ );
41
+ if (selection?.kind === 'thanks') return (
42
+ <div className="fb-insp-body">
43
+ <Head title="Thank-you message" />
44
+ <Text label="Shown after someone submits" value={value.confirmation} max={1000} rows={5} placeholder={p.event ? 'Uses the booking confirmation message' : 'Thank you. Your response has been received.'} onChange={v => p.patch({ confirmation: v }, 'confirmation')} />
45
+ </div>
46
+ );
47
+ if (selection?.kind === 'system') {
48
+ const f = p.system[selection.index];
49
+ if (f) return (
50
+ <div className="fb-insp-body">
51
+ <Head title={f.label} sub={META[f.type].label} />
52
+ <p className="fb-note"><b>Always asked.</b> {f.note || 'This question is part of every registration and can’t be changed here.'}</p>
53
+ </div>
54
+ );
55
+ }
56
+ return (
57
+ <div className="fb-insp-body fb-insp-none">
58
+ <b>Nothing selected</b>
59
+ <p>Click a question on the form or in the list to edit it. Press <kbd>/</kbd> to add one.</p>
60
+ <button type="button" className="fb-btn sm" onClick={p.onAdd} disabled={value.fields.length >= 30}>+ Add question</button>
61
+ {!p.phone && <dl className="fb-keys">
62
+ <div><dt>⌥↑ ⌥↓</dt><dd>Move</dd></div><div><dt>⌘D</dt><dd>Duplicate</dd></div><div><dt>⌫</dt><dd>Delete</dd></div><div><dt>⌘Z ⇧⌘Z</dt><dd>Undo, redo</dd></div>
63
+ </dl>}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ function FieldSettings(p: Props & { field: FormField }) {
69
+ const { field: f, value, patchField } = p;
70
+ const index = value.fields.findIndex(x => x.id === f.id);
71
+ const issue = fieldIssue(f, value.fields);
72
+ const [key, setKey] = useState(f.id);
73
+ useEffect(() => setKey(f.id), [f.id]);
74
+ const keyError = key === f.id ? '' : !FIELD_ID.test(key) ? 'Start with a letter; use letters, numbers, - or _.' : value.fields.some(x => x.id === key) ? 'Another question uses this key.' : '';
75
+ const locked = p.lockedKeys.has(f.id);
76
+ const several = f.type === 'checkboxes';
77
+ return (
78
+ <div className="fb-insp-body">
79
+ <Head title={f.label || META[f.type].label} sub={META[f.type].label}>
80
+ <Menu items={[
81
+ { label: 'Move up', disabled: index <= 0, run: () => p.actions.move(f.id, -1) },
82
+ { label: 'Move down', disabled: index >= value.fields.length - 1, run: () => p.actions.move(f.id, 1) },
83
+ { label: 'Duplicate', disabled: value.fields.length >= 30, run: () => p.actions.duplicate(f.id) },
84
+ { label: 'Delete', danger: true, run: () => p.actions.remove(f.id) },
85
+ ]} />
86
+ </Head>
87
+ {issue && <p className="fb-warn" role="alert">{issue}</p>}
88
+ <label className="fb-f"><span className="fb-lab">Type</span>
89
+ <select value={f.type} onChange={e => p.setType(f.id, e.target.value as FieldType)}>
90
+ {(TYPE_CHOICES.includes(f.type) ? TYPE_CHOICES : [f.type, ...TYPE_CHOICES]).map(t => <option key={t} value={t}>{META[t].label}</option>)}
91
+ </select>
92
+ </label>
93
+ <Text label={f.type === 'heading' ? 'Heading' : 'Label'} value={f.label} max={200} onChange={v => patchField(f.id, { label: v }, `label:${f.id}`)} onBlur={() => p.onLabelDone(f.id)} />
94
+ <Text label={f.type === 'heading' ? 'Description' : 'Help text'} value={f.help} max={300} rows={2} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} />
95
+ {takesPlaceholder(f) && <Text label={canOther(f.type) ? '“Other” placeholder' : f.type === 'select' ? 'Empty choice text' : 'Placeholder'} value={f.placeholder ?? ''} max={120} placeholder={f.type === 'select' ? 'Choose…' : canOther(f.type) ? 'Type your answer' : ''} onChange={v => patchField(f.id, { placeholder: v }, `ph:${f.id}`)} />}
96
+ {isChoice(f.type) && <Choices options={f.options} onChange={(options, key) => patchField(f.id, { options }, key)} />}
97
+ {f.type !== 'heading' && (
98
+ <div className="fb-opts">
99
+ <Switch label="Required" hint={f.type === 'consent' || f.type === 'checkbox' ? 'People must tick it to send' : f.type === 'checkboxes' ? 'At least one choice' : undefined} on={f.required} onChange={v => patchField(f.id, { required: v }, `req:${f.id}`)} />
100
+ {(f.type === 'radio' || f.type === 'checkboxes') && <Switch label="Allow several" hint="Checkboxes instead of one choice" on={several} onChange={v => p.setType(f.id, v ? 'checkboxes' : 'radio')} />}
101
+ {canOther(f.type) && <Switch label="“Other” with text" hint="Adds an Other option with a text box" on={Boolean(f.allowOther)} onChange={v => patchField(f.id, { allowOther: v }, `other:${f.id}`)} />}
102
+ </div>
103
+ )}
104
+ {f.type !== 'heading' && (
105
+ <div className="fb-f"><span className="fb-lab" id={`w-${f.id}`}>Width</span>
106
+ <div className="fb-seg" role="group" aria-labelledby={`w-${f.id}`}>
107
+ <button type="button" aria-pressed={f.width !== 'half'} onClick={() => patchField(f.id, { width: undefined }, `w:${f.id}`)}>Full</button>
108
+ <button type="button" aria-pressed={f.width === 'half'} onClick={() => patchField(f.id, { width: 'half' }, `w:${f.id}`)}>Half</button>
109
+ </div>
110
+ <small className="fb-hint">Two half-width questions in a row sit side by side on wider screens.</small>
111
+ </div>
112
+ )}
113
+ <label className="fb-f"><span className="fb-lab">Answer key <small>· used in CSV and webhooks</small></span>
114
+ <input className="fb-mono" value={key} maxLength={80} readOnly={locked} aria-invalid={Boolean(keyError) || undefined}
115
+ onChange={e => setKey(e.target.value.replace(/\s+/g, '_'))}
116
+ onBlur={() => { if (!keyError && key !== f.id) p.setKey(f.id, key); else setKey(f.id); }}
117
+ onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setKey(f.id); } }} />
118
+ {keyError ? <small className="fb-err">{keyError}</small> : locked ? <small className="fb-hint">Locked because this form already has responses.</small> : null}
119
+ </label>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function Head({ title, sub, children }: { title: string; sub?: string; children?: React.ReactNode }) {
125
+ return <div className="fb-insp-h"><div><b>{title}</b>{sub && <small>{sub}</small>}</div>{children}</div>;
126
+ }
127
+
128
+ function Text({ label, hint, value, onChange, onBlur, max, rows, placeholder }: { label: string; hint?: string; value: string; onChange: (v: string) => void; onBlur?: () => void; max: number; rows?: number; placeholder?: string }) {
129
+ return (
130
+ <label className="fb-f"><span className="fb-lab">{label}{hint && <small> · {hint}</small>}</span>
131
+ {rows ? <textarea rows={rows} maxLength={max} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
132
+ : <input maxLength={max} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)} onBlur={onBlur} />}
133
+ </label>
134
+ );
135
+ }
136
+
137
+ function Switch({ label, hint, on, onChange }: { label: string; hint?: string; on: boolean; onChange: (v: boolean) => void }) {
138
+ return (
139
+ <label className="fb-sw-row"><span><b>{label}</b>{hint && <small>{hint}</small>}</span>
140
+ <button type="button" role="switch" aria-checked={on} className="fb-switch" onClick={() => onChange(!on)}><i /></button>
141
+ </label>
142
+ );
143
+ }
144
+
145
+ export function Menu({ items }: { items: { label: string; run: () => void; disabled?: boolean; danger?: boolean }[] }) {
146
+ const [open, setOpen] = useState(false);
147
+ const ref = useRef<HTMLDivElement>(null);
148
+ useEffect(() => {
149
+ if (!open) return;
150
+ const close = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
151
+ const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
152
+ document.addEventListener('pointerdown', close); document.addEventListener('keydown', esc);
153
+ return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', esc); };
154
+ }, [open]);
155
+ return (
156
+ <div className="fb-more" ref={ref}>
157
+ <button type="button" className="fb-icon" aria-label="More actions" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(o => !o)}>⋯</button>
158
+ {open && <div className="fb-menu" role="menu">{items.map(i => <button key={i.label} type="button" role="menuitem" className={i.danger ? 'danger' : undefined} disabled={i.disabled} onClick={() => { setOpen(false); i.run(); }}>{i.label}</button>)}</div>}
159
+ </div>
160
+ );
161
+ }
162
+
163
+ /** Choice list: drag or ⌥↑↓ to reorder, Enter adds below, Backspace on an empty choice removes it, pasting several lines adds many. */
164
+ function Choices({ options, onChange }: { options: string[]; onChange: (options: string[], key: string) => void }) {
165
+ const ids = useRef<string[]>([]);
166
+ // Stable ids for drag + focus while texts change.
167
+ while (ids.current.length < options.length) ids.current.push(Math.random().toString(36).slice(2));
168
+ ids.current.length = options.length;
169
+ const listRef = useRef<HTMLDivElement>(null);
170
+ const focusAt = (i: number) => requestAnimationFrame(() => listRef.current?.querySelectorAll<HTMLInputElement>('input')[i]?.focus());
171
+ const dndId = useId();
172
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
173
+ const set = (next: string[], key = 'choices', idList?: string[]) => { if (idList) ids.current = idList; onChange(next.slice(0, 50), key); };
174
+ const insertAt = (i: number, values: string[]) => {
175
+ const next = [...options]; next.splice(i, 0, ...values);
176
+ const nextIds = [...ids.current]; nextIds.splice(i, 0, ...values.map(() => Math.random().toString(36).slice(2)));
177
+ set(next, 'choices-add', nextIds);
178
+ };
179
+ const removeAt = (i: number) => { const nextIds = ids.current.filter((_, j) => j !== i); set(options.filter((_, j) => j !== i), 'choices-remove', nextIds); };
180
+ const onDragEnd = ({ active, over }: DragEndEvent) => {
181
+ if (!over || active.id === over.id) return;
182
+ const from = ids.current.indexOf(String(active.id)), to = ids.current.indexOf(String(over.id));
183
+ set(arrayMove(options, from, to), 'choices-move', arrayMove(ids.current, from, to));
184
+ };
185
+ const dupes = new Set(options.filter((o, i) => o.trim() && options.findIndex(x => x.trim() === o.trim()) !== i).map(o => o.trim()));
186
+ return (
187
+ <div className="fb-f"><span className="fb-lab">Choices <small>· {options.length} of 50</small></span>
188
+ <DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd} modifiers={[verticalOnly]}>
189
+ <SortableContext items={ids.current} strategy={verticalListSortingStrategy}>
190
+ <div className="fb-choices" ref={listRef}>
191
+ {options.map((o, i) => (
192
+ <Choice key={ids.current[i]} id={ids.current[i]} value={o} index={i} invalid={!o.trim() || dupes.has(o.trim())}
193
+ onChange={v => { const next = [...options]; next[i] = v; set(next, `choice:${ids.current[i]}`); }}
194
+ onEnter={() => { if (options.length < 50) { insertAt(i + 1, ['']); focusAt(i + 1); } }}
195
+ onRemove={(focusPrev) => { if (options.length > 1) { removeAt(i); if (focusPrev) focusAt(Math.max(0, i - 1)); } }}
196
+ onMove={dir => { const j = i + dir; if (j < 0 || j >= options.length) return; set(arrayMove(options, i, j), 'choices-move', arrayMove(ids.current, i, j)); focusAt(j); }}
197
+ onPasteLines={lines => { const next = [...options]; const empty = !o.trim(); const fresh = lines.filter(l => !options.includes(l)); if (empty) { next.splice(i, 1, ...fresh); const nextIds = [...ids.current]; nextIds.splice(i, 1, ...fresh.map(() => Math.random().toString(36).slice(2))); set(next, 'choices-add', nextIds); } else insertAt(i + 1, fresh); }}
198
+ onBlurEmpty={() => { if (options.length > 1 && !o.trim()) removeAt(i); }} />
199
+ ))}
200
+ </div>
201
+ </SortableContext>
202
+ </DndContext>
203
+ <p className="fb-choice-add"><button type="button" className="fb-link" disabled={options.length >= 50} onClick={() => { insertAt(options.length, ['']); focusAt(options.length); }}>+ Add choice</button> <small>· paste a list to add many</small></p>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ function Choice({ id, value, index, invalid, onChange, onEnter, onRemove, onMove, onPasteLines, onBlurEmpty }: {
209
+ id: string; value: string; index: number; invalid: boolean; onChange: (v: string) => void; onEnter: () => void; onRemove: (focusPrev: boolean) => void;
210
+ onMove: (dir: -1 | 1) => void; onPasteLines: (lines: string[]) => void; onBlurEmpty: () => void;
211
+ }) {
212
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
213
+ const { role: _r, tabIndex: _t, ...a11y } = attributes;
214
+ return (
215
+ <div ref={setNodeRef} className={`fb-choice${isDragging ? ' dragging' : ''}`} style={{ transform: transform ? `translate3d(0, ${Math.round(transform.y)}px, 0)` : undefined, transition }}>
216
+ <span className="fb-grip" aria-hidden="true" {...a11y} {...listeners}>⋮⋮</span>
217
+ <input value={value} maxLength={200} aria-label={`Choice ${index + 1}`} aria-invalid={invalid || undefined}
218
+ onChange={e => onChange(e.target.value)}
219
+ onKeyDown={e => {
220
+ if (e.key === 'Enter') { e.preventDefault(); onEnter(); }
221
+ else if (e.key === 'Backspace' && !value) { e.preventDefault(); onRemove(true); }
222
+ else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); e.stopPropagation(); onMove(e.key === 'ArrowUp' ? -1 : 1); }
223
+ }}
224
+ onPaste={e => {
225
+ const lines = e.clipboardData.getData('text/plain').split(/\r?\n/).map(l => l.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').trim()).filter(Boolean);
226
+ if (lines.length > 1) { e.preventDefault(); onPasteLines([...new Set(lines)].map(l => l.slice(0, 200))); }
227
+ }}
228
+ onBlur={onBlurEmpty} />
229
+ <button type="button" className="fb-icon sm" aria-label={`Remove ${value || 'choice'}`} onClick={() => onRemove(false)}>✕</button>
230
+ </div>
231
+ );
232
+ }
@@ -0,0 +1,93 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+ import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
3
+ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
4
+ import { verticalOnly } from './dnd';
5
+ import type { FormField } from '../../lib/forms/schema';
6
+ import { TypeIcon } from './TypeIcon';
7
+ import { META, fieldIssue, type Selection, type SystemField } from './meta';
8
+
9
+ type RowActions = { move: (id: string, dir: -1 | 1) => void; duplicate: (id: string) => void; remove: (id: string) => void };
10
+
11
+ /** Question list: locked system rows, sortable questions, add button and the "after submit" item. Also the phone layout's main view. */
12
+ export function Outline({ fields, system, selection, onSelect, onReorder, onAdd, actions, phone, labels }: {
13
+ fields: FormField[]; system: SystemField[]; selection: Selection; onSelect: (s: Selection) => void;
14
+ onReorder: (from: number, to: number) => void; onAdd: () => void; actions: RowActions; phone: boolean;
15
+ labels: { title: string; submit: string };
16
+ }) {
17
+ const dndId = useId();
18
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
19
+ const onDragEnd = ({ active, over }: DragEndEvent) => {
20
+ if (!over || active.id === over.id) return;
21
+ const from = fields.findIndex(f => f.id === active.id), to = fields.findIndex(f => f.id === over.id);
22
+ if (from >= 0 && to >= 0) onReorder(from, to);
23
+ };
24
+ const is = (s: Selection) => JSON.stringify(s) === JSON.stringify(selection);
25
+ return (
26
+ <nav className="fb-out" aria-label="Questions">
27
+ {phone && <>
28
+ <p className="fb-out-h"><span>Form</span></p>
29
+ <button type="button" className={`fb-q fb-q-plain${is({ kind: 'form' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'form' })}><span /><TypeIcon type="title" /><span className="fb-q-l">{labels.title || 'Title & introduction'}</span><span /></button>
30
+ </>}
31
+ <p className="fb-out-h"><span>Questions</span><span>{fields.length + system.length}</span></p>
32
+ {system.map((f, i) => (
33
+ <button key={`sys-${i}`} type="button" className={`fb-q sys${is({ kind: 'system', index: i }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'system', index: i })} aria-label={`${f.label} (always asked)`}>
34
+ <span className="fb-grip" aria-hidden="true" /><TypeIcon type={f.type} /><span className="fb-q-l">{f.label}</span>
35
+ <svg className="fb-lock" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg>
36
+ </button>
37
+ ))}
38
+ <DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd} modifiers={[verticalOnly]}>
39
+ <SortableContext items={fields.map(f => f.id)} strategy={verticalListSortingStrategy}>
40
+ <div className="fb-q-list">
41
+ {fields.map((f, i) => <Row key={f.id} field={f} index={i} count={fields.length} on={is({ kind: 'field', id: f.id })} issue={fieldIssue(f, fields)} onSelect={() => onSelect({ kind: 'field', id: f.id })} actions={actions} phone={phone} />)}
42
+ </div>
43
+ </SortableContext>
44
+ </DndContext>
45
+ {!fields.length && <p className="fb-out-empty">{system.length ? "No extra questions yet." : "No questions yet."}</p>}
46
+ <button type="button" className="fb-add" onClick={onAdd} disabled={fields.length >= 30}>
47
+ <span aria-hidden="true">+</span> Add question {!phone && <kbd>/</kbd>}
48
+ </button>
49
+ {fields.length >= 30 && <p className="fb-out-empty">A form can have up to 30 questions.</p>}
50
+ {phone && <button type="button" className={`fb-q fb-q-plain${is({ kind: 'submit' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'submit' })}><span /><TypeIcon type="submit" /><span className="fb-q-l">Button · {labels.submit}</span><span /></button>}
51
+ <p className="fb-out-h fb-out-after"><span>After submit</span></p>
52
+ <button type="button" className={`fb-q fb-q-plain${is({ kind: 'thanks' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'thanks' })}><span /><TypeIcon type="thanks" /><span className="fb-q-l">Thank-you message</span><span /></button>
53
+ </nav>
54
+ );
55
+ }
56
+
57
+ function Row({ field, index, count, on, issue, onSelect, actions, phone }: { field: FormField; index: number; count: number; on: boolean; issue: string; onSelect: () => void; actions: RowActions; phone: boolean }) {
58
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id });
59
+ const [menu, setMenu] = useState(false);
60
+ const menuRef = useRef<HTMLDivElement>(null);
61
+ useEffect(() => {
62
+ if (!menu) return;
63
+ const close = (e: PointerEvent) => { if (!menuRef.current?.contains(e.target as Node)) setMenu(false); };
64
+ document.addEventListener('pointerdown', close);
65
+ return () => document.removeEventListener('pointerdown', close);
66
+ }, [menu]);
67
+ const style = { transform: transform ? `translate3d(0, ${Math.round(transform.y)}px, 0)` : undefined, transition };
68
+ // Desktop: the whole row drags (after 5px). Phone: only the grip, so the list still scrolls.
69
+ const { role: _role, tabIndex: _tab, ...a11y } = attributes;
70
+ return (
71
+ <div ref={setNodeRef} style={style} className={`fb-q-wrap${isDragging ? ' dragging' : ''}`}>
72
+ <button type="button" className={`fb-q${on ? ' on' : ''}`} data-field={field.id} onClick={onSelect} aria-current={on || undefined} {...a11y} {...(phone ? {} : listeners)} aria-roledescription="sortable question">
73
+ <span className="fb-grip" aria-hidden="true" {...(phone ? listeners : {})}>⋮⋮</span>
74
+ <TypeIcon type={field.type} />
75
+ <span className="fb-q-l">{field.label || META[field.type].label}</span>
76
+ <span className="fb-q-m">{issue ? <span className="fb-issue" title={issue} aria-label={issue}>!</span> : field.required ? <span className="fb-req" aria-label="required">*</span> : null}</span>
77
+ </button>
78
+ {phone && (
79
+ <div className="fb-q-more" ref={menuRef}>
80
+ <button type="button" className="fb-icon" aria-label={`More for ${field.label}`} aria-expanded={menu} onClick={() => setMenu(m => !m)}>⋯</button>
81
+ {menu && (
82
+ <div className="fb-menu" role="menu">
83
+ <button type="button" role="menuitem" disabled={!index} onClick={() => { setMenu(false); actions.move(field.id, -1); }}>Move up</button>
84
+ <button type="button" role="menuitem" disabled={index === count - 1} onClick={() => { setMenu(false); actions.move(field.id, 1); }}>Move down</button>
85
+ <button type="button" role="menuitem" disabled={count >= 30} onClick={() => { setMenu(false); actions.duplicate(field.id); }}>Duplicate</button>
86
+ <button type="button" role="menuitem" className="danger" onClick={() => { setMenu(false); actions.remove(field.id); }}>Delete</button>
87
+ </div>
88
+ )}
89
+ </div>
90
+ )}
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,123 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ export interface ResponseRow {
4
+ id: string;
5
+ when: string; // "Today 10:42"
6
+ full: string; // "Thu, Oct 1, 2026, 10:42 AM"
7
+ version: string; // "v3"
8
+ cells: string[]; // one per current question
9
+ answers: [string, string][];
10
+ older: [string, string][];
11
+ }
12
+
13
+ // POST back to the page with a throwaway form so the server handlers (and
14
+ // their origin check) stay the single source of truth.
15
+ function post(fields: [string, string][]) {
16
+ const f = document.createElement('form');
17
+ f.method = 'post'; f.hidden = true;
18
+ for (const [k, v] of fields) { const i = document.createElement('input'); i.type = 'hidden'; i.name = k; i.value = v; f.append(i); }
19
+ document.body.append(f); f.submit(); setTimeout(() => f.remove(), 1000);
20
+ }
21
+
22
+ export function ResponsesTable({ columns, rows, formName }: { columns: { id: string; label: string }[]; rows: ResponseRow[]; formName: string }) {
23
+ const [open, setOpen] = useState<number | null>(null);
24
+ const [picked, setPicked] = useState<Set<string>>(new Set());
25
+ const [ask, setAsk] = useState<{ text: string; run: () => void } | null>(null);
26
+ const [copied, setCopied] = useState(false);
27
+ const confirmRef = useRef<HTMLDialogElement>(null);
28
+ const drawerRef = useRef<HTMLElement>(null);
29
+ const row = open === null ? null : rows[open] ?? null;
30
+
31
+ useEffect(() => {
32
+ const d = confirmRef.current; if (!d) return;
33
+ if (ask && !d.open) d.showModal(); else if (!ask && d.open) d.close();
34
+ }, [ask]);
35
+ useEffect(() => { if (row) drawerRef.current?.focus(); setCopied(false); }, [open]);
36
+ useEffect(() => {
37
+ if (open === null) return;
38
+ const onKey = (e: KeyboardEvent) => {
39
+ if (ask || (e.target instanceof HTMLElement && e.target.matches('input, textarea, select'))) return;
40
+ if (e.key === 'Escape') setOpen(null);
41
+ if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); setOpen(i => Math.min(rows.length - 1, (i ?? 0) + 1)); }
42
+ if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); setOpen(i => Math.max(0, (i ?? 0) - 1)); }
43
+ };
44
+ document.addEventListener('keydown', onKey);
45
+ return () => document.removeEventListener('keydown', onKey);
46
+ }, [open, ask, rows.length]);
47
+
48
+ const toggle = (id: string) => setPicked(p => { const n = new Set(p); n.has(id) ? n.delete(id) : n.add(id); return n; });
49
+ const all = picked.size === rows.length;
50
+ const title = (r: ResponseRow) => r.answers.find(([, v]) => v && v !== '—')?.[1]?.split('\n')[0]?.slice(0, 80) || 'Response';
51
+ const copy = async (r: ResponseRow) => {
52
+ const text = [`${formName} · ${r.full} · ${r.version} · ref ${r.id.slice(0, 8)}`, '', ...[...r.answers, ...r.older].map(([q, a]) => `${q}: ${a}`)].join('\n');
53
+ try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1600); } catch { /* clipboard blocked */ }
54
+ };
55
+ const remove = (ids: string[]) => setAsk({
56
+ text: ids.length === 1 ? 'Permanently delete this response? This can’t be undone.' : `Permanently delete ${ids.length} responses? This can’t be undone.`,
57
+ run: () => post(ids.length === 1 ? [['op', 'delete'], ['id', ids[0]]] : [['op', 'bulkDelete'], ...ids.map(id => ['ids', id] as [string, string])]),
58
+ });
59
+
60
+ return <div className={`fr${row ? ' has-drawer' : ''}`}>
61
+ <div className="fr-table-wrap">
62
+ <table className="fr-table">
63
+ <thead><tr>
64
+ <th className="fr-check"><input type="checkbox" aria-label="Select all on this page" checked={all && rows.length > 0} ref={el => { if (el) el.indeterminate = picked.size > 0 && !all; }} onChange={() => setPicked(all ? new Set() : new Set(rows.map(r => r.id)))} /></th>
65
+ <th>Submitted</th>
66
+ {columns.map(c => <th key={c.id} title={c.label}>{c.label}</th>)}
67
+ </tr></thead>
68
+ <tbody>
69
+ {rows.map((r, i) => <tr key={r.id} tabIndex={0} aria-selected={open === i} className={`${open === i ? 'is-open' : ''}${picked.has(r.id) ? ' is-picked' : ''}`}
70
+ onClick={e => { if (!(e.target as HTMLElement).closest('.fr-check')) setOpen(i); }}
71
+ onKeyDown={e => { if (e.key === 'Enter' && e.target === e.currentTarget) setOpen(i); }}>
72
+ <td className="fr-check" onClick={e => e.stopPropagation()}><input type="checkbox" aria-label={`Select response from ${r.when}`} checked={picked.has(r.id)} onChange={() => toggle(r.id)} /></td>
73
+ <td className="fr-when" title={r.full}>{r.when}</td>
74
+ {r.cells.map((c, j) => <td key={j} title={c.length > 24 ? c : undefined} className={!c || c === '—' ? 'is-empty' : ''}>{c || '—'}</td>)}
75
+ </tr>)}
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+
80
+ {row && <aside className="fr-drawer" ref={drawerRef} tabIndex={-1} role="dialog" aria-label={`Response: ${title(row)}`}>
81
+ <div className="fr-drawer-head">
82
+ <b>{title(row)}</b>
83
+ <div className="fr-drawer-nav">
84
+ <button type="button" className="icon-btn" aria-label="Previous response" disabled={open === 0} onClick={() => setOpen(i => Math.max(0, (i ?? 0) - 1))}>‹</button>
85
+ <button type="button" className="icon-btn" aria-label="Next response" disabled={open === rows.length - 1} onClick={() => setOpen(i => Math.min(rows.length - 1, (i ?? 0) + 1))}>›</button>
86
+ <button type="button" className="icon-btn" aria-label="Close" onClick={() => setOpen(null)}>✕</button>
87
+ </div>
88
+ </div>
89
+ <p className="fr-drawer-meta">{row.full} · form {row.version} · ref {row.id.slice(0, 8)}</p>
90
+ <div className="fr-drawer-body">
91
+ <dl className="fr-dl">{row.answers.map(([q, a]) => <div key={q}><dt>{q}</dt><dd className={a === '—' ? 'is-empty' : ''}>{a}</dd></div>)}</dl>
92
+ {row.older.length > 0 && <>
93
+ <p className="fr-older">Older questions <small>Removed or changed since this response</small></p>
94
+ <dl className="fr-dl">{row.older.map(([q, a]) => <div key={q}><dt>{q}</dt><dd className={a === '—' ? 'is-empty' : ''}>{a}</dd></div>)}</dl>
95
+ </>}
96
+ </div>
97
+ <div className="fr-drawer-foot">
98
+ <button type="button" className="btn" onClick={() => copy(row)}>{copied ? 'Copied' : 'Copy'}</button>
99
+ <button type="button" className="btn danger" onClick={() => remove([row.id])}>Delete</button>
100
+ </div>
101
+ </aside>}
102
+
103
+ <div className="bulkbar fr-bulk" hidden={!picked.size}>
104
+ <b>{picked.size} selected</b>
105
+ <button type="button" className="btn sm" onClick={() => post([['op', 'export'], ...[...picked].map(id => ['ids', id] as [string, string])])}>Export</button>
106
+ <button type="button" className="btn sm danger" onClick={() => remove([...picked])}>Delete</button>
107
+ <button type="button" className="icon-btn" aria-label="Clear selection" onClick={() => setPicked(new Set())}>
108
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg>
109
+ </button>
110
+ </div>
111
+
112
+ <dialog className="modal" ref={confirmRef} onClose={() => setAsk(null)} onClick={e => { if (e.target === e.currentTarget) setAsk(null); }}>
113
+ <div className="modal-head"><span>Delete response{ask?.text.includes('responses') ? 's' : ''}?</span><button type="button" className="modal-x" aria-label="Close" onClick={() => setAsk(null)}>✕</button></div>
114
+ <div className="modal-body">
115
+ <p className="fr-confirm">{ask?.text}</p>
116
+ <div className="modal-foot">
117
+ <button type="button" className="btn" onClick={() => setAsk(null)}>Cancel</button>
118
+ <button type="button" className="btn fr-danger" onClick={() => { ask?.run(); }}>Delete</button>
119
+ </div>
120
+ </div>
121
+ </dialog>
122
+ </div>;
123
+ }
@@ -0,0 +1,24 @@
1
+ import type { FieldType } from './meta';
2
+
3
+ // 24-unit line icons (DESIGN.md: stroke 1.8, round caps), one per question type.
4
+ const PATHS: Record<FieldType | 'title' | 'submit' | 'thanks', string> = {
5
+ text: 'M5 7V5h14v2M12 5v14M9 19h6',
6
+ textarea: 'M4 6h16M4 10h16M4 14h16M4 18h10',
7
+ radio: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
8
+ checkboxes: 'M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM8.5 12l2.5 2.5 4.5-5',
9
+ select: 'M5 6h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2ZM14 11l2 2 2-2',
10
+ email: 'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM16 12v1.5a2.5 2.5 0 0 0 5 0V12a9 9 0 1 0-3.6 7.2',
11
+ phone: 'M8 3H5.5A1.5 1.5 0 0 0 4 4.6 16 16 0 0 0 19.4 20a1.5 1.5 0 0 0 1.6-1.5V16l-4-1.5-2 2a12 12 0 0 1-5-5l2-2L10.5 5 8 3Z',
12
+ url: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1',
13
+ number: 'M5 9h14M5 15h14M10 4 8 20M16 4l-2 16',
14
+ date: 'M5 5h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM3 10h18M8 3v4M16 3v4',
15
+ consent: 'M5 12.5 9.5 17 19 7',
16
+ checkbox: 'M5 12.5 9.5 17 19 7',
17
+ heading: 'M6 4v16M18 4v16M6 12h12',
18
+ title: 'M4 6h16M4 11h10M4 16h13',
19
+ submit: 'M5 12h13M13 7l5 5-5 5',
20
+ thanks: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM8 12.5l2.5 2.5L16 9.5',
21
+ };
22
+ export function TypeIcon({ type }: { type: keyof typeof PATHS }) {
23
+ return <i className="fb-ty" aria-hidden="true"><svg viewBox="0 0 24 24"><path d={PATHS[type]} /></svg></i>;
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { Modifier } from '@dnd-kit/core';
2
+ /** Lists only reorder vertically (keeps drags from wandering sideways). */
3
+ export const verticalOnly: Modifier = ({ transform }) => ({ ...transform, x: 0 });
@@ -0,0 +1,84 @@
1
+ // Question types, defaults and small pure helpers shared by the builder panes.
2
+ import { CHOICE_TYPES, FIELD_ID, type FormDefinition, type FormField } from '../../lib/forms/schema';
3
+
4
+ export type FieldType = FormField['type'];
5
+ export type Group = 'Basics' | 'Contact' | 'Other';
6
+ export type Selection = { kind: 'field'; id: string } | { kind: 'form' } | { kind: 'submit' } | { kind: 'thanks' } | { kind: 'system'; index: number } | null;
7
+ export type SystemField = { label: string; type: FieldType; note?: string };
8
+
9
+ export const META: Record<FieldType, { label: string; group: Group; desc: string; fresh: string }> = {
10
+ text: { label: 'Short text', group: 'Basics', desc: 'A single line, like a name or a title.', fresh: 'Short answer' },
11
+ textarea: { label: 'Long text', group: 'Basics', desc: 'A few sentences or a message.', fresh: 'Long answer' },
12
+ radio: { label: 'Multiple choice', group: 'Basics', desc: 'People pick one option. Turn on “Allow several” for checkboxes.', fresh: 'Pick one' },
13
+ checkboxes: { label: 'Checkboxes', group: 'Basics', desc: 'People tick as many options as apply.', fresh: 'Pick any that apply' },
14
+ select: { label: 'Dropdown', group: 'Basics', desc: 'One option from a compact list. Good for long lists.', fresh: 'Choose one' },
15
+ email: { label: 'Email', group: 'Contact', desc: 'Checks that the address looks real.', fresh: 'Email' },
16
+ phone: { label: 'Phone', group: 'Contact', desc: 'Digits, spaces, + ( ) and dashes.', fresh: 'Phone' },
17
+ url: { label: 'Website', group: 'Contact', desc: 'A web address. “example.org” is accepted too.', fresh: 'Website' },
18
+ number: { label: 'Number', group: 'Other', desc: 'Any number, like a count or an age.', fresh: 'Number' },
19
+ date: { label: 'Date', group: 'Other', desc: 'A calendar date picker.', fresh: 'Date' },
20
+ consent: { label: 'Agree checkbox', group: 'Other', desc: 'One box to tick. When required, people must tick it to send.', fresh: 'I agree to the terms' },
21
+ heading: { label: 'Section heading', group: 'Other', desc: 'A title and description that splits a long form into parts. Not a question.', fresh: 'New section' },
22
+ checkbox: { label: 'Checkbox', group: 'Other', desc: 'One box to tick.', fresh: 'Checkbox' },
23
+ };
24
+ /** Insert menu order (`checkbox` stays valid for old data but isn't offered). */
25
+ export const INSERT: FieldType[] = ['text', 'textarea', 'radio', 'checkboxes', 'select', 'email', 'phone', 'url', 'number', 'date', 'consent', 'heading'];
26
+ export const TYPE_CHOICES: FieldType[] = [...INSERT];
27
+
28
+ export const isChoice = (t: FieldType) => CHOICE_TYPES.includes(t);
29
+ export const canOther = (t: FieldType) => t === 'radio' || t === 'checkboxes';
30
+ export const takesPlaceholder = (f: Pick<FormField, 'type' | 'allowOther'>) => ['text', 'textarea', 'email', 'phone', 'number', 'url', 'select'].includes(f.type) || (canOther(f.type) && Boolean(f.allowOther));
31
+
32
+ /** Readable, unique answer key from a label: "Which team?" → "which_team". */
33
+ export function keyFrom(label: string, taken: Iterable<string>): string {
34
+ const used = new Set(taken);
35
+ let base = label.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40).replace(/_+$/, '');
36
+ if (!base) base = 'question';
37
+ if (!/^[a-z]/.test(base)) base = `q_${base}`;
38
+ let key = base, n = 2;
39
+ while (used.has(key)) key = `${base}_${n++}`;
40
+ return key;
41
+ }
42
+
43
+ /** Drops optional props that are at their default so stored JSON stays lean. */
44
+ export function clean(f: FormField): FormField {
45
+ const out: FormField = { id: f.id, type: f.type, label: f.label, required: f.type === 'heading' ? false : f.required, help: f.help, options: isChoice(f.type) ? f.options : [] };
46
+ if (f.placeholder && takesPlaceholder(f)) out.placeholder = f.placeholder;
47
+ if (f.width === 'half' && f.type !== 'heading') out.width = 'half';
48
+ if (f.allowOther && canOther(f.type)) out.allowOther = true;
49
+ return out;
50
+ }
51
+
52
+ export function newField(type: FieldType, taken: string[]): FormField {
53
+ const label = META[type].fresh;
54
+ return clean({ id: keyFrom(label, taken), type, label, required: false, help: '', options: isChoice(type) ? ['Option 1', 'Option 2'] : [] });
55
+ }
56
+
57
+ /** Switching type keeps the label/help; choice lists survive between choice types. */
58
+ export function switchType(f: FormField, type: FieldType, remembered?: string[]): FormField {
59
+ const options = isChoice(type) ? (f.options.length ? f.options : remembered?.length ? remembered : ['Option 1', 'Option 2']) : [];
60
+ return clean({ ...f, type, options });
61
+ }
62
+
63
+ export function fieldIssue(f: FormField, all: FormField[]): string {
64
+ if (!f.label.trim()) return 'Needs a label.';
65
+ if (!FIELD_ID.test(f.id)) return 'Answer key must start with a letter and use letters, numbers, - or _.';
66
+ if (all.filter(x => x.id === f.id).length > 1) return 'Answer key is used twice.';
67
+ if (isChoice(f.type)) {
68
+ if (!f.options.length) return 'Add at least one choice.';
69
+ if (f.options.some(o => !o.trim())) return 'A choice is empty.';
70
+ if (new Set(f.options.map(o => o.trim())).size !== f.options.length) return 'Two choices are the same.';
71
+ }
72
+ return '';
73
+ }
74
+
75
+ export function definitionIssue(d: FormDefinition): string {
76
+ for (const f of d.fields) { const issue = fieldIssue(f, d.fields); if (issue) return `“${f.label || META[f.type].label}”: ${issue}`; }
77
+ return '';
78
+ }
79
+
80
+ export const DEFAULT_EVENT_SYSTEM: SystemField[] = [
81
+ { label: 'Your name', type: 'text', note: 'Bookings uses this to list who is coming.' },
82
+ { label: 'Email', type: 'email', note: 'Confirmations and changes are sent here.' },
83
+ { label: 'Seats', type: 'select', note: 'Follows the event’s “Max per person” setting.' },
84
+ ];
@@ -0,0 +1,41 @@
1
+ import { useCallback, useReducer, useRef } from 'react';
2
+ import type { FormDefinition } from '../../lib/forms/schema';
3
+
4
+ /**
5
+ * Undo/redo for a controlled definition. Commits with the same `key` inside
6
+ * a second are merged, so typing a label is one undo step, not one per letter.
7
+ */
8
+ export function useFormHistory(value: FormDefinition, onChange: (v: FormDefinition) => void) {
9
+ const past = useRef<FormDefinition[]>([]);
10
+ const future = useRef<FormDefinition[]>([]);
11
+ const last = useRef<{ key?: string; at: number }>({ at: 0 });
12
+ const current = useRef(value);
13
+ const emit = useRef(onChange);
14
+ current.current = value;
15
+ emit.current = onChange;
16
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
17
+
18
+ const commit = useCallback((next: FormDefinition, key?: string) => {
19
+ const now = Date.now();
20
+ const merge = key && last.current.key === key && now - last.current.at < 1000;
21
+ if (!merge) { past.current.push(current.current); if (past.current.length > 150) past.current.shift(); }
22
+ last.current = { key, at: now };
23
+ future.current = [];
24
+ current.current = next;
25
+ emit.current(next);
26
+ rerender();
27
+ }, []);
28
+ const step = useCallback((from: typeof past, to: typeof past) => {
29
+ const target = from.current.pop();
30
+ if (!target) return false;
31
+ to.current.push(current.current);
32
+ last.current = { at: 0 };
33
+ current.current = target;
34
+ emit.current(target);
35
+ rerender();
36
+ return true;
37
+ }, []);
38
+ const undo = useCallback(() => step(past, future), [step]);
39
+ const redo = useCallback(() => step(future, past), [step]);
40
+ return { commit, undo, redo, canUndo: past.current.length > 0, canRedo: future.current.length > 0 };
41
+ }