@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.
|
|
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
|
+
}
|