@bygghammar/cms-platform 0.7.0 → 0.8.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -79,7 +79,12 @@
79
79
  "./backoffice/media/ConfirmDialog": "./src/backoffice/media/ConfirmDialog.tsx",
80
80
  "./backoffice/media/MediaCleanup": "./src/backoffice/media/MediaCleanup.tsx",
81
81
  "./backoffice/media/MediaReplaceDialog": "./src/backoffice/media/MediaReplaceDialog.tsx",
82
- "./backoffice/media/ReuploadOverwrite": "./src/backoffice/media/ReuploadOverwrite.tsx"
82
+ "./backoffice/media/ReuploadOverwrite": "./src/backoffice/media/ReuploadOverwrite.tsx",
83
+ "./backoffice/forms/FieldEditor": "./src/backoffice/forms/FieldEditor.tsx",
84
+ "./backoffice/forms/FormPreview": "./src/backoffice/forms/FormPreview.tsx",
85
+ "./backoffice/forms/VersionHistory": "./src/backoffice/forms/VersionHistory.tsx",
86
+ "./backoffice/forms/TestDeliveryPanel": "./src/backoffice/forms/TestDeliveryPanel.tsx",
87
+ "./backoffice/SaveGlobalDialog": "./src/backoffice/SaveGlobalDialog.tsx"
83
88
  },
84
89
  "dependencies": {
85
90
  "fflate": "^0.8.3"
@@ -0,0 +1,221 @@
1
+ import { useEffect, useState } from "react";
2
+ import { X, Globe, Layers, BookmarkPlus } from "lucide-react";
3
+
4
+ export type SaveGlobalMode = "global" | "template";
5
+ export type SaveGlobalResult = {
6
+ name: string;
7
+ key: string;
8
+ kind: "block" | "section";
9
+ description: string;
10
+ };
11
+
12
+ export function slugifyKey(value: string) {
13
+ return (
14
+ value
15
+ .toLowerCase()
16
+ .normalize("NFD")
17
+ .replace(/[\u0300-\u036f]/g, "")
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "")
20
+ .slice(0, 48) || "section"
21
+ );
22
+ }
23
+
24
+ export function SaveGlobalDialog({
25
+ open,
26
+ mode,
27
+ defaultName,
28
+ error,
29
+ busy = false,
30
+ onCancel,
31
+ onSubmit,
32
+ }: {
33
+ open: boolean;
34
+ mode: SaveGlobalMode;
35
+ defaultName: string;
36
+ error?: string | null;
37
+ busy?: boolean;
38
+ onCancel: () => void;
39
+ onSubmit: (result: SaveGlobalResult) => void;
40
+ }) {
41
+ const [name, setName] = useState(defaultName);
42
+ const [key, setKey] = useState(slugifyKey(defaultName));
43
+ const [keyTouched, setKeyTouched] = useState(false);
44
+ const [kind, setKind] = useState<"block" | "section">("section");
45
+ const [description, setDescription] = useState("");
46
+
47
+ useEffect(() => {
48
+ if (open) {
49
+ setName(defaultName);
50
+ setKey(slugifyKey(defaultName));
51
+ setKeyTouched(false);
52
+ setKind("section");
53
+ setDescription("");
54
+ }
55
+ }, [open, defaultName]);
56
+
57
+ useEffect(() => {
58
+ if (!open) return;
59
+ const onKey = (e: KeyboardEvent) => {
60
+ if (e.key === "Escape") onCancel();
61
+ };
62
+ document.addEventListener("keydown", onKey);
63
+ const prev = document.body.style.overflow;
64
+ document.body.style.overflow = "hidden";
65
+ return () => {
66
+ document.removeEventListener("keydown", onKey);
67
+ document.body.style.overflow = prev;
68
+ };
69
+ }, [open, onCancel]);
70
+
71
+ if (!open) return null;
72
+
73
+ const isGlobal = mode === "global";
74
+ const keyInvalid = isGlobal && !/^[a-z0-9_-]+$/.test(key);
75
+ const canSave = name.trim().length > 0 && (!isGlobal || (!keyInvalid && key.length > 0));
76
+
77
+ const submit = () => {
78
+ if (!canSave || busy) return;
79
+ onSubmit({ name: name.trim(), key, kind, description: description.trim() });
80
+ };
81
+
82
+ return (
83
+ <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
84
+ <button aria-label="Close" onClick={onCancel} className="absolute inset-0 bg-ink/50" />
85
+ <div className="relative w-full max-w-lg bg-white border border-ink/10 shadow-xl">
86
+ <div className="flex items-start justify-between border-b border-ink/10 px-5 py-4">
87
+ <div>
88
+ <div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50">
89
+ {isGlobal ? "Reusable" : "Blocks library"}
90
+ </div>
91
+ <h2 className="text-lg font-medium text-ink flex items-center gap-2">
92
+ {isGlobal ? <Globe className="size-4" /> : <BookmarkPlus className="size-4" />}
93
+ {isGlobal ? "Save as reusable" : "Save one-off copy"}
94
+ </h2>
95
+ </div>
96
+ <button onClick={onCancel} className="text-ink/40 hover:text-ink" title="Close">
97
+ <X className="size-4" />
98
+ </button>
99
+ </div>
100
+
101
+ <div className="px-5 py-4 space-y-4">
102
+ <p className="text-xs text-ink/60 leading-relaxed">
103
+ {isGlobal
104
+ ? "This page will switch to a live reference. Edit it once and every page or section using it updates."
105
+ : "Stores an independent duplicate in the Blocks library. Later edits only affect this page."}
106
+ </p>
107
+
108
+ <label className="block">
109
+ <span className="text-[0.65rem] uppercase tracking-[0.18em] text-ink/55">Name</span>
110
+ <input
111
+ autoFocus
112
+ value={name}
113
+ onChange={(e) => {
114
+ setName(e.target.value);
115
+ if (!keyTouched) setKey(slugifyKey(e.target.value));
116
+ }}
117
+ onKeyDown={(e) => e.key === "Enter" && submit()}
118
+ className="mt-1 w-full border border-ink/15 px-3 py-2 text-sm focus:border-sea outline-none"
119
+ />
120
+ </label>
121
+
122
+ {isGlobal ? (
123
+ <>
124
+ <label className="block">
125
+ <span className="text-[0.65rem] uppercase tracking-[0.18em] text-ink/55">
126
+ Key (url-safe, unique)
127
+ </span>
128
+ <input
129
+ value={key}
130
+ onChange={(e) => {
131
+ setKeyTouched(true);
132
+ setKey(e.target.value.trim());
133
+ }}
134
+ onKeyDown={(e) => e.key === "Enter" && submit()}
135
+ className="mt-1 w-full border border-ink/15 px-3 py-2 text-sm font-mono focus:border-sea outline-none"
136
+ />
137
+ {keyInvalid && (
138
+ <span className="mt-1 block text-[0.7rem] text-red-600">
139
+ Use lowercase letters, numbers, hyphen or underscore only.
140
+ </span>
141
+ )}
142
+ </label>
143
+
144
+ <div>
145
+ <span className="text-[0.65rem] uppercase tracking-[0.18em] text-ink/55">
146
+ Save to
147
+ </span>
148
+ <div className="mt-1 grid grid-cols-2 gap-2">
149
+ {[
150
+ {
151
+ v: "section" as const,
152
+ icon: Layers,
153
+ title: "Global sections",
154
+ hint: "Shows in Global Sections",
155
+ },
156
+ {
157
+ v: "block" as const,
158
+ icon: Globe,
159
+ title: "Global block",
160
+ hint: "Shows in Block library",
161
+ },
162
+ ].map((o) => {
163
+ const Icon = o.icon;
164
+ const active = kind === o.v;
165
+ return (
166
+ <button
167
+ key={o.v}
168
+ type="button"
169
+ onClick={() => setKind(o.v)}
170
+ className={`text-left border px-3 py-2 ${active ? "border-sea bg-ice/40" : "border-ink/15 hover:border-ink/30"}`}
171
+ >
172
+ <span className="flex items-center gap-1.5 text-sm text-ink">
173
+ <Icon className="size-3.5" /> {o.title}
174
+ </span>
175
+ <span className="block text-[0.7rem] text-ink/55 mt-0.5">{o.hint}</span>
176
+ </button>
177
+ );
178
+ })}
179
+ </div>
180
+ </div>
181
+ </>
182
+ ) : (
183
+ <label className="block">
184
+ <span className="text-[0.65rem] uppercase tracking-[0.18em] text-ink/55">
185
+ Description (optional)
186
+ </span>
187
+ <input
188
+ value={description}
189
+ onChange={(e) => setDescription(e.target.value)}
190
+ onKeyDown={(e) => e.key === "Enter" && submit()}
191
+ className="mt-1 w-full border border-ink/15 px-3 py-2 text-sm focus:border-sea outline-none"
192
+ />
193
+ </label>
194
+ )}
195
+
196
+ {error && (
197
+ <div className="border border-red-300 bg-red-50 px-3 py-2 text-xs text-red-700">
198
+ {error}
199
+ </div>
200
+ )}
201
+ </div>
202
+
203
+ <div className="flex justify-end gap-2 border-t border-ink/10 px-5 py-3">
204
+ <button
205
+ onClick={onCancel}
206
+ className="border border-ink/15 px-3 py-1.5 text-sm hover:border-ink/40"
207
+ >
208
+ Cancel
209
+ </button>
210
+ <button
211
+ onClick={submit}
212
+ disabled={!canSave || busy}
213
+ className="bg-ink text-white px-4 py-1.5 text-sm disabled:opacity-40"
214
+ >
215
+ {busy ? "Saving…" : "Save"}
216
+ </button>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ );
221
+ }
@@ -0,0 +1,611 @@
1
+ import { useState } from "react";
2
+ import { ChevronDown, ChevronUp, Copy, GripVertical, Plus, RotateCcw, Trash2 } from "lucide-react";
3
+ import {
4
+ AUTOCOMPLETE_OPTIONS,
5
+ fieldTypes,
6
+ tripFieldOptions,
7
+ tripYears,
8
+ makeFieldKey,
9
+ type TripSource,
10
+ type FormFieldDef,
11
+ type FormFieldType,
12
+ } from "../../forms/field-sets";
13
+ import { FIELD_FORMATS } from "../../forms/conditions";
14
+ import { LocalizedField, TextField } from "../inspectors/fields";
15
+ import { ConditionBuilder, RuleSummary } from "./ConditionBuilder";
16
+ import { getTripTerminology } from "../../trips";
17
+
18
+ const TRIP_TERMINOLOGY = getTripTerminology();
19
+
20
+ /** Labels depend on registered site terminology, so resolve after startup. */
21
+ const FIELD_TYPES = fieldTypes();
22
+
23
+ /** Trip fields use the shared trips source and the site's terminology. */
24
+ function TripOptions({
25
+ field,
26
+ trips,
27
+ onChange,
28
+ }: {
29
+ field: FormFieldDef;
30
+ trips: TripSource[];
31
+ onChange: (patch: Partial<FormFieldDef>) => void;
32
+ }) {
33
+ const years = tripYears(trips);
34
+ const options = tripFieldOptions(trips, field.tripYear ?? field.courseYear);
35
+ return (
36
+ <div className="space-y-2">
37
+ <div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
38
+ {TRIP_TERMINOLOGY.plural}
39
+ </div>
40
+ <label className="block">
41
+ <span className="mb-1 block text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
42
+ Year filter
43
+ </span>
44
+ <select
45
+ value={field.tripYear ?? field.courseYear ?? ""}
46
+ onChange={(e) => onChange({ tripYear: e.target.value || undefined })}
47
+ className="w-full border border-ink/15 bg-white px-3 py-2 text-sm"
48
+ >
49
+ <option value="">All years</option>
50
+ {years.map((y) => (
51
+ <option key={y} value={y}>
52
+ {y}
53
+ </option>
54
+ ))}
55
+ </select>
56
+ </label>
57
+ <p className="text-xs text-ink/50">
58
+ Choices are generated from the {TRIP_TERMINOLOGY.plural} section, so they stay in sync
59
+ automatically.
60
+ </p>
61
+ {options.length === 0 ? (
62
+ <p className="text-xs text-ink/45">
63
+ No {TRIP_TERMINOLOGY.plural.toLowerCase()} match this filter yet.
64
+ </p>
65
+ ) : (
66
+ <ul className="space-y-1 border border-ink/10 bg-white px-3 py-2 text-xs text-ink/70">
67
+ {options.map((o) => (
68
+ <li key={o.value}>
69
+ {o.label.sv || o.label.en}
70
+ <span className="text-ink/35"> · saved as “{o.value}”</span>
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ )}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function Toggle({
80
+ label,
81
+ checked,
82
+ onChange,
83
+ }: {
84
+ label: string;
85
+ checked: boolean;
86
+ onChange: (v: boolean) => void;
87
+ }) {
88
+ return (
89
+ <label className="flex items-center gap-2 text-xs text-ink/70">
90
+ <input
91
+ type="checkbox"
92
+ checked={checked}
93
+ onChange={(e) => onChange(e.target.checked)}
94
+ className="size-4 accent-sea"
95
+ />
96
+ {label}
97
+ </label>
98
+ );
99
+ }
100
+
101
+ function OptionsEditor({
102
+ field,
103
+ onChange,
104
+ }: {
105
+ field: FormFieldDef;
106
+ onChange: (patch: Partial<FormFieldDef>) => void;
107
+ }) {
108
+ const options = field.options ?? [];
109
+ const patchOption = (i: number, patch: Partial<(typeof options)[number]>) =>
110
+ onChange({ options: options.map((o, idx) => (idx === i ? { ...o, ...patch } : o)) });
111
+
112
+ return (
113
+ <div className="space-y-2">
114
+ <div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Choices</div>
115
+ {options.length === 0 ? <p className="text-xs text-ink/45">No choices yet.</p> : null}
116
+ {options.map((o, i) => (
117
+ <div key={i} className="flex flex-wrap items-center gap-2">
118
+ <input
119
+ value={o.value}
120
+ onChange={(e) => patchOption(i, { value: e.target.value })}
121
+ placeholder="value"
122
+ className="w-32 border border-ink/15 px-2 py-1 text-xs"
123
+ />
124
+ <input
125
+ value={o.label?.sv ?? ""}
126
+ onChange={(e) => patchOption(i, { label: { ...o.label, sv: e.target.value } })}
127
+ placeholder="Swedish label"
128
+ className="flex-1 min-w-[140px] border border-ink/15 px-2 py-1 text-xs"
129
+ />
130
+ <input
131
+ value={o.label?.en ?? ""}
132
+ onChange={(e) => patchOption(i, { label: { ...o.label, en: e.target.value } })}
133
+ placeholder="English label"
134
+ className="flex-1 min-w-[140px] border border-ink/15 px-2 py-1 text-xs"
135
+ />
136
+ <button
137
+ type="button"
138
+ onClick={() => onChange({ options: options.filter((_, idx) => idx !== i) })}
139
+ className="text-ink/35 hover:text-red-500"
140
+ title="Remove choice"
141
+ >
142
+ <Trash2 className="size-3.5" />
143
+ </button>
144
+ </div>
145
+ ))}
146
+ <button
147
+ type="button"
148
+ onClick={() =>
149
+ onChange({ options: [...options, { value: `option_${options.length + 1}`, label: {} }] })
150
+ }
151
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
152
+ >
153
+ <Plus className="size-3.5" />
154
+ Add choice
155
+ </button>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ export function FieldEditor({
161
+ fields,
162
+ usedKeys,
163
+ onChange,
164
+ trips: tripRows,
165
+ }: {
166
+ fields: FormFieldDef[];
167
+ /** Keys that already have stored answers — locked against deletion. */
168
+ usedKeys: string[];
169
+ onChange: (next: FormFieldDef[]) => void;
170
+ /** Trip/course rows from the host site, used to preview trip field options. */
171
+ trips?: TripSource[];
172
+ }) {
173
+ const [open, setOpen] = useState<string | null>(null);
174
+ const [showRemoved, setShowRemoved] = useState(false);
175
+ const [newLabel, setNewLabel] = useState("");
176
+ const [newType, setNewType] = useState<FormFieldType>("text");
177
+ const used = new Set(usedKeys);
178
+ const trips = (tripRows ?? []) as TripSource[];
179
+
180
+ const patch = (key: string, p: Partial<FormFieldDef>) =>
181
+ onChange(fields.map((f) => (f.key === key ? { ...f, ...p } : f)));
182
+
183
+ /** Reorder within the visible fields, then write the order back in full. */
184
+ const move = (key: string, dir: -1 | 1) => {
185
+ const visible = fields.filter((f) => !f.removed);
186
+ const i = visible.findIndex((f) => f.key === key);
187
+ const j = i + dir;
188
+ if (i < 0 || j < 0 || j >= visible.length) return;
189
+ const reordered = [...visible];
190
+ [reordered[i], reordered[j]] = [reordered[j], reordered[i]];
191
+ onChange([...reordered, ...fields.filter((f) => f.removed)]);
192
+ };
193
+
194
+ const addField = () => {
195
+ const label = newLabel.trim();
196
+ if (!label) return;
197
+ const key = makeFieldKey(
198
+ label,
199
+ fields.map((f) => f.key),
200
+ );
201
+ onChange([
202
+ ...fields,
203
+ {
204
+ key,
205
+ type: newType,
206
+ label: { sv: label, en: label },
207
+ enabled: true,
208
+ required: false,
209
+ custom: true,
210
+ options: newType === "select" ? [{ value: "option_1", label: {} }] : undefined,
211
+ },
212
+ ]);
213
+ setNewLabel("");
214
+ setOpen(key);
215
+ };
216
+
217
+ const duplicate = (f: FormFieldDef) => {
218
+ const key = makeFieldKey(
219
+ `${f.key}_copy`,
220
+ fields.map((x) => x.key),
221
+ );
222
+ onChange([...fields, { ...f, key, custom: true }]);
223
+ setOpen(key);
224
+ };
225
+
226
+ /**
227
+ * Custom fields are dropped outright. Built-in fields keep a tombstone so
228
+ * the canonical set does not rebuild them on the next load, and so old
229
+ * answers still resolve their label.
230
+ */
231
+ const removeField = (f: FormFieldDef) => {
232
+ if (used.has(f.key)) return;
233
+ if (
234
+ !window.confirm(
235
+ f.custom
236
+ ? `Remove the field "${f.label.sv || f.label.en || f.key}"? It has no answers, so nothing is lost.`
237
+ : `Remove the built-in field "${f.label.sv || f.label.en || f.key}"? You can restore it again from "Removed built-in fields" below.`,
238
+ )
239
+ )
240
+ return;
241
+ if (f.custom) {
242
+ onChange(fields.filter((x) => x.key !== f.key));
243
+ } else {
244
+ onChange(fields.map((x) => (x.key === f.key ? { ...x, removed: true, enabled: false } : x)));
245
+ }
246
+ setOpen(null);
247
+ };
248
+
249
+ const restoreField = (key: string) =>
250
+ onChange(fields.map((x) => (x.key === key ? { ...x, removed: false, enabled: true } : x)));
251
+
252
+ const visibleFields = fields.filter((f) => !f.removed);
253
+ const removedFields = fields.filter((f) => f.removed);
254
+
255
+ return (
256
+ <div className="space-y-3">
257
+ <p className="text-xs text-ink/50">
258
+ Reorder, rename, add your own fields. A field key never changes.{" "}
259
+ <strong className="font-normal text-ink/70">Off</strong> keeps the field and its history but
260
+ hides it from the site; <strong className="font-normal text-ink/70">Delete</strong> takes it
261
+ out of the form entirely. A field that already has answers can only be switched off, so
262
+ historic submissions stay readable.
263
+ </p>
264
+
265
+ <div className="space-y-2">
266
+ {visibleFields.map((f) => {
267
+ const locked = used.has(f.key);
268
+ const isOpen = open === f.key;
269
+ return (
270
+ <div
271
+ key={f.key}
272
+ className={
273
+ f.enabled
274
+ ? "border border-ink/10"
275
+ : "border border-dashed border-ink/15 bg-ink/[0.02]"
276
+ }
277
+ >
278
+ <div className="flex flex-wrap items-center gap-3 px-3 py-2">
279
+ <GripVertical className="size-4 shrink-0 text-ink/25" />
280
+ <div className="flex flex-col">
281
+ <button
282
+ type="button"
283
+ onClick={() => setOpen(isOpen ? null : f.key)}
284
+ className="text-left text-sm text-ink hover:text-sea"
285
+ >
286
+ {f.label.sv || f.label.en || f.key}
287
+ </button>
288
+ <span className="text-[0.6rem] uppercase tracking-[0.18em] text-ink/40">
289
+ {f.key} · {f.type}
290
+ {f.custom ? " · custom" : ""}
291
+ {used.has(f.key) ? " · has answers" : ""}
292
+ </span>
293
+ {!f.enabled ? (
294
+ <span className="mt-1 w-fit border border-ink/15 px-1.5 py-0.5 text-[0.55rem] uppercase tracking-[0.18em] text-ink/45">
295
+ Off — not shown on the site
296
+ </span>
297
+ ) : null}
298
+ </div>
299
+ <div className="flex flex-wrap items-center gap-1.5">
300
+ <RuleSummary rule={f.showWhen} fields={fields} prefix="Shown when" />
301
+ <RuleSummary rule={f.requireWhen} fields={fields} prefix="Required when" />
302
+ </div>
303
+
304
+ <div className="ml-auto flex items-center gap-3">
305
+ <Toggle
306
+ label="On"
307
+ checked={f.enabled}
308
+ onChange={(v) => patch(f.key, { enabled: v })}
309
+ />
310
+ <Toggle
311
+ label="Required"
312
+ checked={f.required}
313
+ onChange={(v) => patch(f.key, { required: v })}
314
+ />
315
+ <Toggle
316
+ label="Full width"
317
+ checked={f.wide === true}
318
+ onChange={(v) => patch(f.key, { wide: v })}
319
+ />
320
+ <button
321
+ type="button"
322
+ onClick={() => move(f.key, -1)}
323
+ className="text-ink/35 hover:text-sea"
324
+ title="Move up"
325
+ >
326
+ <ChevronUp className="size-4" />
327
+ </button>
328
+ <button
329
+ type="button"
330
+ onClick={() => move(f.key, 1)}
331
+ className="text-ink/35 hover:text-sea"
332
+ title="Move down"
333
+ >
334
+ <ChevronDown className="size-4" />
335
+ </button>
336
+ <button
337
+ type="button"
338
+ onClick={() => duplicate(f)}
339
+ className="text-ink/35 hover:text-sea"
340
+ title="Duplicate field"
341
+ >
342
+ <Copy className="size-4" />
343
+ </button>
344
+ <button
345
+ type="button"
346
+ disabled={locked}
347
+ onClick={() => removeField(f)}
348
+ className="text-ink/35 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-30"
349
+ title={
350
+ locked
351
+ ? "This field already has answers — switch it off instead"
352
+ : "Remove field"
353
+ }
354
+ >
355
+ <Trash2 className="size-4" />
356
+ </button>
357
+ </div>
358
+ </div>
359
+
360
+ {isOpen ? (
361
+ <div className="space-y-4 border-t border-ink/10 bg-ink/[0.015] px-4 py-4">
362
+ {f.custom ? (
363
+ <label className="block">
364
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
365
+ Type
366
+ </span>
367
+ <select
368
+ value={f.type}
369
+ onChange={(e) => patch(f.key, { type: e.target.value as FormFieldType })}
370
+ className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
371
+ >
372
+ {FIELD_TYPES.map((t) => (
373
+ <option key={t.value} value={t.value}>
374
+ {t.label}
375
+ </option>
376
+ ))}
377
+ </select>
378
+ </label>
379
+ ) : null}
380
+
381
+ <LocalizedField
382
+ label="Label"
383
+ value={f.label}
384
+ onChange={(v) => patch(f.key, { label: v })}
385
+ />
386
+ <LocalizedField
387
+ label="Placeholder"
388
+ value={f.placeholder}
389
+ onChange={(v) => patch(f.key, { placeholder: v })}
390
+ />
391
+ <LocalizedField
392
+ label="Help text"
393
+ value={f.help}
394
+ onChange={(v) => patch(f.key, { help: v })}
395
+ />
396
+
397
+ {f.type === "select" ? (
398
+ <OptionsEditor field={f} onChange={(p) => patch(f.key, p)} />
399
+ ) : null}
400
+ {f.type === "trip" || f.type === "course" ? (
401
+ <TripOptions field={f} trips={trips} onChange={(p) => patch(f.key, p)} />
402
+ ) : null}
403
+
404
+ <div className="grid gap-3 sm:grid-cols-3">
405
+ <label className="block">
406
+ <span className="mb-1 block text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
407
+ Autofill
408
+ </span>
409
+ <select
410
+ value={f.autocomplete ?? ""}
411
+ onChange={(e) =>
412
+ patch(f.key, { autocomplete: e.target.value || undefined })
413
+ }
414
+ className="w-full border border-ink/15 bg-white px-3 py-2 text-sm"
415
+ >
416
+ <option value="">Automatic</option>
417
+ {AUTOCOMPLETE_OPTIONS.map((o) => (
418
+ <option key={o.value} value={o.value}>
419
+ {o.label}
420
+ </option>
421
+ ))}
422
+ </select>
423
+ </label>
424
+ <label className="block">
425
+ <span className="mb-1 block text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
426
+ Format
427
+ </span>
428
+ <select
429
+ value={f.format ?? ""}
430
+ onChange={(e) =>
431
+ patch(f.key, {
432
+ format: (e.target.value || undefined) as FormFieldDef["format"],
433
+ })
434
+ }
435
+ className="w-full border border-ink/15 bg-white px-3 py-2 text-sm"
436
+ >
437
+ <option value="">No extra format check</option>
438
+ {FIELD_FORMATS.map((o) => (
439
+ <option key={o.value} value={o.value}>
440
+ {o.label}
441
+ </option>
442
+ ))}
443
+ </select>
444
+ </label>
445
+ <TextField
446
+ label="Min length"
447
+ value={f.minLength ? String(f.minLength) : ""}
448
+ onChange={(v) => patch(f.key, { minLength: Number(v) || undefined })}
449
+ />
450
+ <TextField
451
+ label="Max length"
452
+ value={f.maxLength ? String(f.maxLength) : ""}
453
+ onChange={(v) => patch(f.key, { maxLength: Number(v) || undefined })}
454
+ />
455
+ </div>
456
+
457
+ <div className="grid gap-3 sm:grid-cols-3">
458
+ <TextField
459
+ label="Pattern (regex)"
460
+ value={f.pattern ?? ""}
461
+ onChange={(v) => patch(f.key, { pattern: v || undefined })}
462
+ />
463
+ {f.type === "date" ? (
464
+ <>
465
+ <TextField
466
+ label="Earliest date (YYYY-MM-DD)"
467
+ value={f.minDate ?? ""}
468
+ onChange={(v) => patch(f.key, { minDate: v || undefined })}
469
+ />
470
+ <TextField
471
+ label="Latest date (YYYY-MM-DD)"
472
+ value={f.maxDate ?? ""}
473
+ onChange={(v) => patch(f.key, { maxDate: v || undefined })}
474
+ />
475
+ </>
476
+ ) : (
477
+ <>
478
+ <TextField
479
+ label="Min value"
480
+ value={f.min === undefined ? "" : String(f.min)}
481
+ onChange={(v) => patch(f.key, { min: v === "" ? undefined : Number(v) })}
482
+ />
483
+ <TextField
484
+ label="Max value"
485
+ value={f.max === undefined ? "" : String(f.max)}
486
+ onChange={(v) => patch(f.key, { max: v === "" ? undefined : Number(v) })}
487
+ />
488
+ </>
489
+ )}
490
+ </div>
491
+
492
+ <LocalizedField
493
+ label="Custom error message"
494
+ value={f.errorMessage}
495
+ onChange={(v) => patch(f.key, { errorMessage: v })}
496
+ />
497
+
498
+ <div className="grid gap-3 lg:grid-cols-2">
499
+ <ConditionBuilder
500
+ title="Show this field when"
501
+ description="Leave empty to always show it."
502
+ rule={f.showWhen}
503
+ selfKey={f.key}
504
+ fields={fields}
505
+ onChange={(r) => patch(f.key, { showWhen: r })}
506
+ />
507
+ <ConditionBuilder
508
+ title="Make it required when"
509
+ description="Required only while these conditions match."
510
+ rule={f.requireWhen}
511
+ selfKey={f.key}
512
+ fields={fields}
513
+ onChange={(r) => patch(f.key, { requireWhen: r })}
514
+ />
515
+ </div>
516
+ </div>
517
+ ) : null}
518
+ </div>
519
+ );
520
+ })}
521
+ </div>
522
+
523
+ {removedFields.length ? (
524
+ <div className="border border-dashed border-ink/20 p-4">
525
+ <button
526
+ type="button"
527
+ onClick={() => setShowRemoved((v) => !v)}
528
+ className="flex w-full items-center justify-between gap-3 text-left"
529
+ >
530
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/45">
531
+ Removed built-in fields ({removedFields.length})
532
+ </span>
533
+ {showRemoved ? (
534
+ <ChevronUp className="size-4 text-ink/35" />
535
+ ) : (
536
+ <ChevronDown className="size-4 text-ink/35" />
537
+ )}
538
+ </button>
539
+ {showRemoved ? (
540
+ <>
541
+ <p className="mt-1 text-xs text-ink/45">
542
+ These are not part of the form any more. Restore one to bring it back with its
543
+ original key.
544
+ </p>
545
+ <div className="mt-3 flex flex-wrap gap-2">
546
+ {removedFields.map((f) => (
547
+ <button
548
+ key={f.key}
549
+ type="button"
550
+ onClick={() => restoreField(f.key)}
551
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-3 py-2 text-xs text-ink/70 hover:border-sea hover:text-ink"
552
+ >
553
+ <RotateCcw className="size-3.5" />
554
+ {f.label.sv || f.label.en || f.key}
555
+ <span className="text-[0.6rem] uppercase tracking-[0.18em] text-ink/40">
556
+ {f.key}
557
+ </span>
558
+ </button>
559
+ ))}
560
+ </div>
561
+ </>
562
+ ) : null}
563
+ </div>
564
+ ) : null}
565
+
566
+ <div className="flex flex-wrap items-end gap-3 border border-dashed border-ink/20 p-4">
567
+ <label className="flex-1 min-w-[180px]">
568
+ <span className="text-xs text-ink/55">New field label</span>
569
+ <input
570
+ value={newLabel}
571
+ onChange={(e) => setNewLabel(e.target.value)}
572
+ placeholder="Company"
573
+ className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
574
+ />
575
+ </label>
576
+ <label className="min-w-[160px]">
577
+ <span className="text-xs text-ink/55">Type</span>
578
+ <select
579
+ value={newType}
580
+ onChange={(e) => setNewType(e.target.value as FormFieldType)}
581
+ className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
582
+ >
583
+ {FIELD_TYPES.map((t) => (
584
+ <option key={t.value} value={t.value}>
585
+ {t.label}
586
+ </option>
587
+ ))}
588
+ </select>
589
+ </label>
590
+ <button
591
+ type="button"
592
+ onClick={addField}
593
+ disabled={!newLabel.trim()}
594
+ className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea disabled:opacity-40"
595
+ >
596
+ <Plus className="size-4" />
597
+ Add field
598
+ </button>
599
+ {newLabel.trim() ? (
600
+ <span className="text-xs text-ink/45">
601
+ Key:{" "}
602
+ {makeFieldKey(
603
+ newLabel,
604
+ fields.map((f) => f.key),
605
+ )}
606
+ </span>
607
+ ) : null}
608
+ </div>
609
+ </div>
610
+ );
611
+ }
@@ -0,0 +1,108 @@
1
+ import type React from "react";
2
+ import { useState } from "react";
3
+ import { LocaleProvider, SUPPORTED_LOCALES, type Locale } from "../../i18n";
4
+ import type {
5
+ FormDefaults,
6
+ FormFieldDef,
7
+ FormPrivateSettings,
8
+ FormPublicSettings,
9
+ PublicForm,
10
+ } from "../../forms/field-sets";
11
+
12
+ /**
13
+ * Renders the unsaved editor state with the exact public form component,
14
+ * including conditional show/hide and live validation errors.
15
+ * Submitting only runs the rules — nothing is stored or sent.
16
+ */
17
+ export function FormPreview({
18
+ formKey,
19
+ name,
20
+ formType,
21
+ fields,
22
+ pub,
23
+ priv,
24
+ defaults,
25
+ dark = false,
26
+ renderForm,
27
+ }: {
28
+ formKey: string;
29
+ name: string;
30
+ formType: string;
31
+ fields: FormFieldDef[];
32
+ pub: FormPublicSettings;
33
+ priv: FormPrivateSettings;
34
+ defaults?: FormDefaults | null;
35
+ dark?: boolean;
36
+ /** Host site's public form renderer (kept out of the platform on purpose). */
37
+ renderForm: (args: { form: PublicForm; dark: boolean; preview: boolean }) => React.ReactNode;
38
+ }) {
39
+ const [locale, setLocale] = useState<Locale>("sv");
40
+ const [tone, setTone] = useState<"light" | "dark">(dark ? "dark" : "light");
41
+
42
+ const form: PublicForm = {
43
+ id: "preview",
44
+ key: formKey,
45
+ name,
46
+ form_type: formType,
47
+ fields,
48
+ title: pub.title,
49
+ intro: pub.intro,
50
+ submitLabel: pub.submitLabel ?? { sv: "Skicka", en: "Send" },
51
+ consentText: pub.consentText ?? defaults?.consent_text ?? {},
52
+ privacyPolicyUrl: defaults?.privacy_policy_url ?? "",
53
+ requireConsent: pub.requireConsent !== false,
54
+ thankyouMode:
55
+ (pub.thankyouMode ?? (defaults?.thankyou_mode as "inline" | "redirect") ?? "inline") ===
56
+ "redirect"
57
+ ? "redirect"
58
+ : "inline",
59
+ thankyouMessage: pub.thankyouMessage ??
60
+ defaults?.thankyou_message ?? { sv: "Tack! Vi hör av oss snart.", en: "Thank you!" },
61
+ thankyouRedirect: pub.thankyouRedirect ?? defaults?.thankyou_redirect ?? "",
62
+ honeypot: false,
63
+ minSeconds: 0,
64
+ savePartials: priv.savePartials ?? defaults?.save_partials ?? true,
65
+ };
66
+
67
+ return (
68
+ <div className="space-y-4">
69
+ <div className="flex flex-wrap items-center justify-between gap-3">
70
+ <div>
71
+ <h3 className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Live preview</h3>
72
+ <p className="mt-1 text-xs text-ink/45">
73
+ Exactly what visitors see. Submitting here only checks the rules.
74
+ </p>
75
+ </div>
76
+ <div className="flex flex-wrap gap-2">
77
+ <select
78
+ value={locale}
79
+ onChange={(e) => setLocale(e.target.value as Locale)}
80
+ className="border border-ink/15 px-2 py-1.5 text-xs"
81
+ >
82
+ {SUPPORTED_LOCALES.map((l: Locale) => (
83
+ <option key={l} value={l}>
84
+ {l.toUpperCase()}
85
+ </option>
86
+ ))}
87
+ </select>
88
+ <button
89
+ type="button"
90
+ onClick={() => setTone(tone === "dark" ? "light" : "dark")}
91
+ className="border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
92
+ >
93
+ {tone === "dark" ? "Light background" : "Dark background"}
94
+ </button>
95
+ </div>
96
+ </div>
97
+
98
+ <div className={tone === "dark" ? "bg-ink p-6" : "bg-paper p-6"}>
99
+ <LocaleProvider locale={locale}>
100
+ {/* Remount on locale/tone change so state starts clean. */}
101
+ <div key={`${locale}-${tone}`}>
102
+ {renderForm({ form, dark: tone === "dark", preview: true })}
103
+ </div>
104
+ </LocaleProvider>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,201 @@
1
+ import { useState } from "react";
2
+ import { toast } from "sonner";
3
+ import { Send, TestTube } from "lucide-react";
4
+
5
+ import type { FormFieldDef, FormPrivateSettings } from "../../forms/field-sets";
6
+ import { fieldLabel } from "../../forms/conditions";
7
+
8
+ export type TestNotificationResult = {
9
+ ok: boolean;
10
+ recipients?: string[];
11
+ error?: string | null;
12
+ [key: string]: unknown;
13
+ };
14
+
15
+ /**
16
+ * Verifies notification templates, sender/recipient settings and conditional
17
+ * routing against example answers. Nothing is stored as a submission.
18
+ */
19
+ export function TestDeliveryPanel({
20
+ formKey,
21
+ fields,
22
+ priv,
23
+ sendTest,
24
+ }: {
25
+ formKey: string;
26
+ fields: FormFieldDef[];
27
+ priv: FormPrivateSettings;
28
+ /** Host site's test-delivery server function. */
29
+ sendTest: (input: { data: Record<string, unknown> }) => Promise<TestNotificationResult>;
30
+ }) {
31
+ const test = sendTest;
32
+ const [what, setWhat] = useState<"notification" | "autoreply">("notification");
33
+ const [locale, setLocale] = useState("sv");
34
+ const [to, setTo] = useState("");
35
+ const [values, setValues] = useState<Record<string, string>>({});
36
+ const [busy, setBusy] = useState(false);
37
+ const [result, setResult] = useState<TestNotificationResult | null>(null);
38
+
39
+ const run = async (send: boolean) => {
40
+ setBusy(true);
41
+ try {
42
+ const res = await test({
43
+ data: {
44
+ formKey,
45
+ locale,
46
+ what,
47
+ send,
48
+ to: to.trim() || undefined,
49
+ fields: fields as unknown as Record<string, unknown>[],
50
+ private_settings: priv as Record<string, unknown>,
51
+ values,
52
+ },
53
+ });
54
+ setResult(res);
55
+ if (res.error) toast.error(res.error);
56
+ else if (send && res.sent) toast.success("Test email sent");
57
+ else if (send) toast.error("Nothing was sent — see the report below");
58
+ else toast.success("Dry run complete");
59
+ } catch (e) {
60
+ toast.error(e instanceof Error ? e.message : "Test failed");
61
+ } finally {
62
+ setBusy(false);
63
+ }
64
+ };
65
+
66
+ const active = fields.filter((f) => f.enabled);
67
+
68
+ return (
69
+ <div className="space-y-4">
70
+ <div>
71
+ <h3 className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
72
+ Send test notification
73
+ </h3>
74
+ <p className="mt-1 text-xs text-ink/45">
75
+ Uses the settings currently on screen. No submission is created.
76
+ </p>
77
+ </div>
78
+
79
+ <div className="grid gap-3 sm:grid-cols-3">
80
+ <label className="block">
81
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Email</span>
82
+ <select
83
+ value={what}
84
+ onChange={(e) => setWhat(e.target.value as "notification" | "autoreply")}
85
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
86
+ >
87
+ <option value="notification">Admin notification</option>
88
+ <option value="autoreply">Autoreply to sender</option>
89
+ </select>
90
+ </label>
91
+ <label className="block">
92
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Language</span>
93
+ <select
94
+ value={locale}
95
+ onChange={(e) => setLocale(e.target.value)}
96
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
97
+ >
98
+ <option value="sv">SV</option>
99
+ <option value="en">EN</option>
100
+ </select>
101
+ </label>
102
+ <label className="block">
103
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
104
+ Send to (optional override)
105
+ </span>
106
+ <input
107
+ value={to}
108
+ onChange={(e) => setTo(e.target.value)}
109
+ placeholder="you@example.com"
110
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
111
+ />
112
+ </label>
113
+ </div>
114
+
115
+ <div className="space-y-2">
116
+ <div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
117
+ Example answers (blank = generated sample)
118
+ </div>
119
+ <div className="grid gap-2 sm:grid-cols-2">
120
+ {active.map((f) => (
121
+ <label key={f.key} className="block">
122
+ <span className="text-[0.6rem] uppercase tracking-[0.16em] text-ink/45">
123
+ {fieldLabel(f, "sv")} · {f.key}
124
+ </span>
125
+ <input
126
+ value={values[f.key] ?? ""}
127
+ onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
128
+ className="mt-1 w-full border border-ink/15 px-2 py-1 text-xs"
129
+ />
130
+ </label>
131
+ ))}
132
+ </div>
133
+ </div>
134
+
135
+ <div className="flex flex-wrap gap-2">
136
+ <button
137
+ type="button"
138
+ disabled={busy}
139
+ onClick={() => run(false)}
140
+ className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
141
+ >
142
+ <TestTube className="size-4" />
143
+ Dry run (no email)
144
+ </button>
145
+ <button
146
+ type="button"
147
+ disabled={busy}
148
+ onClick={() => run(true)}
149
+ className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-xs uppercase tracking-[0.2em] text-paper hover:bg-sea hover:text-ink disabled:opacity-50"
150
+ >
151
+ <Send className="size-4" />
152
+ Send test
153
+ </button>
154
+ </div>
155
+
156
+ {result ? (
157
+ <div className="space-y-2 border border-ink/10 bg-paper/60 p-4 text-xs text-ink/70">
158
+ <div>
159
+ <strong>Status:</strong>{" "}
160
+ {result.error
161
+ ? `Failed — ${result.error}`
162
+ : result.sent
163
+ ? "Sent"
164
+ : "Not sent (report only)"}
165
+ </div>
166
+ <div>
167
+ <strong>Recipients:</strong> {result.recipients.join(", ") || "—"}
168
+ </div>
169
+ <div>
170
+ <strong>Subject:</strong> {result.subject || "(standard subject)"}
171
+ </div>
172
+ <div>
173
+ <strong>From:</strong> {result.from || "(provider default)"} ·{" "}
174
+ <strong>Reply-to:</strong> {result.replyTo || "(submitter email)"}
175
+ </div>
176
+ <div>
177
+ <strong>Matched routing rules:</strong>{" "}
178
+ {result.matchedRules.length
179
+ ? result.matchedRules
180
+ .map((r) => `${r.to}${r.exclusive ? " (only these)" : ""}`)
181
+ .join(", ")
182
+ : "none"}
183
+ </div>
184
+ {result.warnings.length ? (
185
+ <ul className="list-disc pl-5 text-amber-700">
186
+ {result.warnings.map((w, i) => (
187
+ <li key={i}>{w}</li>
188
+ ))}
189
+ </ul>
190
+ ) : null}
191
+ <details>
192
+ <summary className="cursor-pointer text-ink/50">Example answers used</summary>
193
+ <pre className="mt-2 overflow-auto whitespace-pre-wrap text-[0.7rem]">
194
+ {JSON.stringify(result.sampleValues, null, 2)}
195
+ </pre>
196
+ </details>
197
+ </div>
198
+ ) : null}
199
+ </div>
200
+ );
201
+ }
@@ -0,0 +1,142 @@
1
+ import type React from "react";
2
+ import { useState } from "react";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { toast } from "sonner";
5
+ import { History, RotateCcw } from "lucide-react";
6
+
7
+ import type {
8
+ FormFieldDef,
9
+ FormPrivateSettings,
10
+ FormPublicSettings,
11
+ } from "../../forms/field-sets";
12
+
13
+
14
+ /**
15
+ * Published history for one form. Every save stores an immutable snapshot of
16
+ * the definition (never of submissions), so any earlier state can be previewed
17
+ * and rolled back without touching received data.
18
+ */
19
+ export type FormVersionRow = {
20
+ id: string;
21
+ version_no: number;
22
+ name: string;
23
+ form_type: string;
24
+ created_at: string;
25
+ created_by_email?: string | null;
26
+ note?: string | null;
27
+ fields: FormFieldDef[];
28
+ public_settings: FormPublicSettings;
29
+ private_settings: FormPrivateSettings;
30
+ };
31
+
32
+ export function VersionHistory({
33
+ formKey,
34
+ listVersions,
35
+ restoreVersion,
36
+ renderPreview,
37
+ }: {
38
+ formKey: string;
39
+ listVersions: (input: { data: { formKey: string } }) => Promise<FormVersionRow[]>;
40
+ restoreVersion: (input: {
41
+ data: { versionId: string };
42
+ }) => Promise<{ carriedFields: string[] }>;
43
+ /** Host renders the preview so the platform stays free of site form UI. */
44
+ renderPreview: (version: FormVersionRow) => React.ReactNode;
45
+ }) {
46
+ const qc = useQueryClient();
47
+ const list = listVersions;
48
+ const restore = restoreVersion;
49
+ const [openId, setOpenId] = useState<string | null>(null);
50
+ const [busy, setBusy] = useState(false);
51
+
52
+ const { data: versions, isLoading } = useQuery({
53
+ queryKey: ["form-versions", formKey],
54
+ queryFn: () => list({ data: { formKey } }),
55
+ });
56
+
57
+ const selected = (versions ?? []).find((v) => v.id === openId) ?? null;
58
+
59
+ const onRestore = async (id: string, no: number) => {
60
+ if (!window.confirm(`Roll this form back to version ${no}? Submissions are untouched.`)) return;
61
+ setBusy(true);
62
+ try {
63
+ const res = await restore({ data: { versionId: id } });
64
+ await qc.invalidateQueries({ queryKey: ["backoffice-forms"] });
65
+ await qc.invalidateQueries({ queryKey: ["form-versions", formKey] });
66
+ toast.success(
67
+ res.carriedFields.length
68
+ ? `Rolled back. Kept ${res.carriedFields.length} field(s) with existing answers (disabled).`
69
+ : "Rolled back",
70
+ );
71
+ } catch (e) {
72
+ toast.error(e instanceof Error ? e.message : "Rollback failed");
73
+ } finally {
74
+ setBusy(false);
75
+ }
76
+ };
77
+
78
+ return (
79
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_1fr]">
80
+ <div className="border border-ink/10 bg-white p-6">
81
+ <h2 className="flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-ink/50">
82
+ <History className="size-4" />
83
+ Version history
84
+ </h2>
85
+ {isLoading ? (
86
+ <p className="mt-4 text-sm text-ink/45">Loading…</p>
87
+ ) : !versions?.length ? (
88
+ <p className="mt-4 text-sm text-ink/45">
89
+ No snapshots yet. The next save creates version 1.
90
+ </p>
91
+ ) : (
92
+ <ul className="mt-4 divide-y divide-ink/10">
93
+ {versions.map((v) => (
94
+ <li key={v.id} className="flex items-start gap-3 py-3">
95
+ <button
96
+ onClick={() => setOpenId(v.id)}
97
+ className={`flex-1 text-left ${openId === v.id ? "text-ink" : "text-ink/70"}`}
98
+ >
99
+ <span className="text-sm font-medium">
100
+ v{v.version_no} · {v.name}
101
+ </span>
102
+ <span className="mt-0.5 block text-xs text-ink/45">
103
+ {new Date(v.created_at).toLocaleString("sv-SE")}
104
+ {v.created_by_email ? ` · ${v.created_by_email}` : ""}
105
+ </span>
106
+ {v.note ? (
107
+ <span className="mt-0.5 block text-xs text-ink/45">{v.note}</span>
108
+ ) : null}
109
+ <span className="mt-0.5 block text-[0.65rem] uppercase tracking-[0.18em] text-ink/35">
110
+ {v.fields.filter((f) => f.enabled !== false).length} active field(s)
111
+ </span>
112
+ </button>
113
+ <button
114
+ onClick={() => onRestore(v.id, v.version_no)}
115
+ disabled={busy}
116
+ title="Roll back to this version"
117
+ className="inline-flex items-center gap-2 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.18em] hover:border-sea disabled:opacity-50"
118
+ >
119
+ <RotateCcw className="size-3.5" />
120
+ Restore
121
+ </button>
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ )}
126
+ </div>
127
+
128
+ <div className="border border-ink/10 bg-white p-6">
129
+ <h2 className="text-xs uppercase tracking-[0.22em] text-ink/50">
130
+ {selected ? `Preview of v${selected.version_no}` : "Preview"}
131
+ </h2>
132
+ {selected ? (
133
+ <div className="mt-4">
134
+ {renderPreview(selected)}
135
+ </div>
136
+ ) : (
137
+ <p className="mt-4 text-sm text-ink/45">Pick a version on the left to preview it.</p>
138
+ )}
139
+ </div>
140
+ </div>
141
+ );
142
+ }