@bygghammar/cms-platform 0.16.0 → 0.18.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 +9 -3
- package/src/backoffice/FormEditorView.tsx +839 -0
- package/src/backoffice/FormsIndexView.tsx +435 -0
- package/src/backoffice/MediaLibraryView.tsx +1090 -0
- package/src/backoffice/RedirectsView.tsx +223 -0
- package/src/media/library-types.ts +22 -0
- package/src/media/pick-video-poster.tsx +128 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { ArrowLeft, Copy, ExternalLink, Inbox, RefreshCw, Save } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
isInherited,
|
|
8
|
+
type FormDefaults,
|
|
9
|
+
type FormFieldDef,
|
|
10
|
+
type FormPrivateSettings,
|
|
11
|
+
type FormPublicSettings,
|
|
12
|
+
type NotificationRule,
|
|
13
|
+
} from "../forms/field-sets";
|
|
14
|
+
import { InspectorSection, LocalizedField, TextField } from "./inspectors/fields";
|
|
15
|
+
import { RuleSimulator } from "./forms/RuleSimulator";
|
|
16
|
+
import { ConditionBuilder } from "./forms/ConditionBuilder";
|
|
17
|
+
import { SmartTagTextField, SmartTagLocalizedField } from "./forms/SmartTags";
|
|
18
|
+
|
|
19
|
+
/** One managed form row as the host's list server function returns it. */
|
|
20
|
+
export type FormEditorRow = {
|
|
21
|
+
id: string;
|
|
22
|
+
key: string;
|
|
23
|
+
name: string;
|
|
24
|
+
form_type: string;
|
|
25
|
+
is_active: boolean;
|
|
26
|
+
fields: FormFieldDef[];
|
|
27
|
+
public_settings?: FormPublicSettings | null;
|
|
28
|
+
private_settings?: FormPrivateSettings | null;
|
|
29
|
+
submissions: number;
|
|
30
|
+
lastSubmissionAt?: string | null;
|
|
31
|
+
usedFieldKeys: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Everything site-specific the shared form editor needs injected. */
|
|
35
|
+
export type FormEditorDeps = {
|
|
36
|
+
form: FormEditorRow | undefined;
|
|
37
|
+
defaults: FormDefaults | null | undefined;
|
|
38
|
+
isLoading: boolean;
|
|
39
|
+
/** Persist the whole form. */
|
|
40
|
+
saveForm: (input: {
|
|
41
|
+
data: {
|
|
42
|
+
id: string;
|
|
43
|
+
key: string;
|
|
44
|
+
name: string;
|
|
45
|
+
form_type: string;
|
|
46
|
+
is_active: boolean;
|
|
47
|
+
fields: Record<string, unknown>[];
|
|
48
|
+
public_settings: Record<string, unknown>;
|
|
49
|
+
private_settings: Record<string, unknown>;
|
|
50
|
+
};
|
|
51
|
+
}) => Promise<unknown>;
|
|
52
|
+
duplicateForm: (input: {
|
|
53
|
+
data: { id: string; key: string; name: string };
|
|
54
|
+
}) => Promise<unknown>;
|
|
55
|
+
syncSubmissions: (input: { data: { formKey: string } }) => Promise<{ synced: number }>;
|
|
56
|
+
testWebhook: (input: {
|
|
57
|
+
data: { formKey: string; url?: string | undefined; secret?: string | undefined };
|
|
58
|
+
}) => Promise<{ ok: boolean; status?: number | null; error?: string | null }>;
|
|
59
|
+
/** Host router link. */
|
|
60
|
+
LinkComponent: (props: {
|
|
61
|
+
to: string;
|
|
62
|
+
search?: Record<string, unknown>;
|
|
63
|
+
className?: string;
|
|
64
|
+
children: React.ReactNode;
|
|
65
|
+
}) => React.ReactElement;
|
|
66
|
+
/** Host-adapted panels (site form UI, server functions). */
|
|
67
|
+
FieldEditorComponent: (props: {
|
|
68
|
+
fields: FormFieldDef[];
|
|
69
|
+
usedKeys: string[];
|
|
70
|
+
onChange: (next: FormFieldDef[]) => void;
|
|
71
|
+
}) => React.ReactNode;
|
|
72
|
+
FormPreviewComponent: (props: {
|
|
73
|
+
formKey: string;
|
|
74
|
+
name: string;
|
|
75
|
+
formType: string;
|
|
76
|
+
fields: FormFieldDef[];
|
|
77
|
+
pub: FormPublicSettings;
|
|
78
|
+
priv: FormPrivateSettings;
|
|
79
|
+
defaults?: FormDefaults | null;
|
|
80
|
+
}) => React.ReactNode;
|
|
81
|
+
TestDeliveryPanelComponent: (props: {
|
|
82
|
+
formKey: string;
|
|
83
|
+
fields: FormFieldDef[];
|
|
84
|
+
priv: FormPrivateSettings;
|
|
85
|
+
}) => React.ReactNode;
|
|
86
|
+
VersionHistoryComponent: (props: {
|
|
87
|
+
formKey: string;
|
|
88
|
+
defaults?: FormDefaults | null;
|
|
89
|
+
}) => React.ReactNode;
|
|
90
|
+
ActivityLogComponent: (props: { formKey?: string }) => React.ReactNode;
|
|
91
|
+
/** Backoffice route paths, so hosts keep control over their URL scheme. */
|
|
92
|
+
paths?: {
|
|
93
|
+
forms?: string;
|
|
94
|
+
submissions?: string;
|
|
95
|
+
settings?: string;
|
|
96
|
+
deliveries?: string;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function Inherited({ from }: { from: string }) {
|
|
101
|
+
return (
|
|
102
|
+
<p className="-mt-2 text-[0.65rem] uppercase tracking-[0.18em] text-sea">
|
|
103
|
+
Inherited from shared defaults: {from || "—"}
|
|
104
|
+
</p>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const TABS = [
|
|
109
|
+
{ id: "fields", label: "Fields" },
|
|
110
|
+
{ id: "presentation", label: "Presentation" },
|
|
111
|
+
{ id: "delivery", label: "Notifications & sync" },
|
|
112
|
+
{ id: "preview", label: "Preview & test" },
|
|
113
|
+
{ id: "history", label: "Version history" },
|
|
114
|
+
{ id: "activity", label: "Activity log" },
|
|
115
|
+
] as const;
|
|
116
|
+
|
|
117
|
+
type TabId = (typeof TABS)[number]["id"];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Host-agnostic form editor: fields, presentation, notification/sync delivery,
|
|
121
|
+
* live preview, version history and activity log for one managed form.
|
|
122
|
+
*/
|
|
123
|
+
export function FormEditorView({ deps }: { deps: FormEditorDeps }) {
|
|
124
|
+
const {
|
|
125
|
+
form,
|
|
126
|
+
defaults: d,
|
|
127
|
+
isLoading,
|
|
128
|
+
saveForm,
|
|
129
|
+
duplicateForm,
|
|
130
|
+
syncSubmissions,
|
|
131
|
+
testWebhook,
|
|
132
|
+
LinkComponent: Link,
|
|
133
|
+
FieldEditorComponent: FieldEditor,
|
|
134
|
+
FormPreviewComponent: FormPreview,
|
|
135
|
+
TestDeliveryPanelComponent: TestDeliveryPanel,
|
|
136
|
+
VersionHistoryComponent: VersionHistory,
|
|
137
|
+
ActivityLogComponent: ActivityLog,
|
|
138
|
+
} = deps;
|
|
139
|
+
const paths = {
|
|
140
|
+
forms: deps.paths?.forms ?? "/kontoret/forms",
|
|
141
|
+
submissions: deps.paths?.submissions ?? "/kontoret/forms/submissions",
|
|
142
|
+
settings: deps.paths?.settings ?? "/kontoret/forms/settings",
|
|
143
|
+
deliveries: deps.paths?.deliveries ?? "/kontoret/forms/deliveries",
|
|
144
|
+
};
|
|
145
|
+
const qc = useQueryClient();
|
|
146
|
+
|
|
147
|
+
const [name, setName] = useState("");
|
|
148
|
+
const [active, setActive] = useState(true);
|
|
149
|
+
const [fields, setFields] = useState<FormFieldDef[]>([]);
|
|
150
|
+
const [pub, setPub] = useState<FormPublicSettings>({});
|
|
151
|
+
const [priv, setPriv] = useState<FormPrivateSettings>({});
|
|
152
|
+
const [busy, setBusy] = useState(false);
|
|
153
|
+
const [tab, setTab] = useState<TabId>("fields");
|
|
154
|
+
|
|
155
|
+
/** Removed built-in fields stay in state as tombstones but never render. */
|
|
156
|
+
const activeFields = useMemo(() => fields.filter((f) => !f.removed), [fields]);
|
|
157
|
+
|
|
158
|
+
/** Update one conditional notification-routing rule in place. */
|
|
159
|
+
const patchRule = (index: number, p: Partial<NotificationRule>) =>
|
|
160
|
+
setPriv((prev) => ({
|
|
161
|
+
...prev,
|
|
162
|
+
notificationRules: (prev.notificationRules ?? []).map((r, i) =>
|
|
163
|
+
i === index ? { ...r, ...p } : r,
|
|
164
|
+
),
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!form) return;
|
|
169
|
+
setName(form.name);
|
|
170
|
+
setActive(form.is_active);
|
|
171
|
+
setFields(form.fields);
|
|
172
|
+
setPub(form.public_settings ?? {});
|
|
173
|
+
setPriv(form.private_settings ?? {});
|
|
174
|
+
}, [form]);
|
|
175
|
+
|
|
176
|
+
if (isLoading) return <div className="p-8 text-sm text-ink/50">Loading…</div>;
|
|
177
|
+
if (!form) return <div className="p-8 text-sm text-ink/50">Form not found.</div>;
|
|
178
|
+
|
|
179
|
+
const onSave = async () => {
|
|
180
|
+
if (
|
|
181
|
+
(pub.thankyouMode ?? d?.thankyou_mode) === "redirect" &&
|
|
182
|
+
!(pub.thankyouRedirect ?? d?.thankyou_redirect ?? "").trim()
|
|
183
|
+
) {
|
|
184
|
+
toast.error("Add a redirect URL, or switch back to the thank-you message.");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
setBusy(true);
|
|
188
|
+
try {
|
|
189
|
+
await saveForm({
|
|
190
|
+
data: {
|
|
191
|
+
id: form.id,
|
|
192
|
+
key: form.key,
|
|
193
|
+
name,
|
|
194
|
+
form_type: form.form_type,
|
|
195
|
+
is_active: active,
|
|
196
|
+
fields: fields as unknown as Record<string, unknown>[],
|
|
197
|
+
public_settings: pub as Record<string, unknown>,
|
|
198
|
+
private_settings: priv as Record<string, unknown>,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
await qc.invalidateQueries({ queryKey: ["backoffice-forms"] });
|
|
202
|
+
toast.success("Form saved");
|
|
203
|
+
} catch (e) {
|
|
204
|
+
toast.error(e instanceof Error ? e.message : "Could not save");
|
|
205
|
+
} finally {
|
|
206
|
+
setBusy(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div className="p-8 md:p-12 w-full">
|
|
212
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
213
|
+
<div>
|
|
214
|
+
<Link
|
|
215
|
+
to={paths.forms}
|
|
216
|
+
className="inline-flex items-center gap-2 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 hover:text-sea"
|
|
217
|
+
>
|
|
218
|
+
<ArrowLeft className="size-3.5" />
|
|
219
|
+
Back to forms
|
|
220
|
+
</Link>
|
|
221
|
+
<h1 className="mt-3 font-serif text-2xl text-ink">{form.name}</h1>
|
|
222
|
+
<p className="mt-1 text-xs text-ink/50">
|
|
223
|
+
{form.key} · {form.form_type} · {form.submissions} submission
|
|
224
|
+
{form.submissions === 1 ? "" : "s"}
|
|
225
|
+
{form.lastSubmissionAt
|
|
226
|
+
? ` · last ${new Date(form.lastSubmissionAt).toLocaleString("sv-SE")}`
|
|
227
|
+
: ""}
|
|
228
|
+
</p>
|
|
229
|
+
</div>
|
|
230
|
+
<div className="flex flex-wrap gap-2">
|
|
231
|
+
<Link
|
|
232
|
+
to={paths.submissions}
|
|
233
|
+
search={{ form: form.key }}
|
|
234
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
235
|
+
>
|
|
236
|
+
<Inbox className="size-4" />
|
|
237
|
+
Submissions ({form.submissions})
|
|
238
|
+
</Link>
|
|
239
|
+
<button
|
|
240
|
+
onClick={async () => {
|
|
241
|
+
try {
|
|
242
|
+
const copyKey = `${form.key}-copy`.slice(0, 64);
|
|
243
|
+
await duplicateForm({
|
|
244
|
+
data: { id: form.id, key: copyKey, name: `${form.name} (copy)` },
|
|
245
|
+
});
|
|
246
|
+
await qc.invalidateQueries({ queryKey: ["backoffice-forms"] });
|
|
247
|
+
toast.success(`Copied to “${form.name} (copy)”`);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
toast.error(e instanceof Error ? e.message : "Could not duplicate");
|
|
250
|
+
}
|
|
251
|
+
}}
|
|
252
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
253
|
+
>
|
|
254
|
+
<Copy className="size-4" />
|
|
255
|
+
Duplicate
|
|
256
|
+
</button>
|
|
257
|
+
<Link
|
|
258
|
+
to={paths.settings}
|
|
259
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
260
|
+
>
|
|
261
|
+
<ExternalLink className="size-4" />
|
|
262
|
+
Shared defaults
|
|
263
|
+
</Link>
|
|
264
|
+
<button
|
|
265
|
+
onClick={onSave}
|
|
266
|
+
disabled={busy}
|
|
267
|
+
className="inline-flex items-center gap-2 bg-ink px-5 py-2.5 text-xs uppercase tracking-[0.2em] text-paper hover:bg-sea hover:text-ink disabled:opacity-50"
|
|
268
|
+
>
|
|
269
|
+
<Save className="size-4" />
|
|
270
|
+
Save
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div className="mt-8 flex gap-1 border-b border-ink/10">
|
|
276
|
+
{TABS.map((t) => (
|
|
277
|
+
<button
|
|
278
|
+
key={t.id}
|
|
279
|
+
onClick={() => setTab(t.id)}
|
|
280
|
+
className={`-mb-px border-b-2 px-4 py-2.5 text-xs uppercase tracking-[0.2em] ${
|
|
281
|
+
tab === t.id ? "border-sea text-ink" : "border-transparent text-ink/45 hover:text-ink"
|
|
282
|
+
}`}
|
|
283
|
+
>
|
|
284
|
+
{t.label}
|
|
285
|
+
</button>
|
|
286
|
+
))}
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{tab === "fields" ? (
|
|
290
|
+
<div className="mt-6 grid gap-6 lg:grid-cols-[320px_1fr]">
|
|
291
|
+
<div className="border border-ink/10 bg-white p-6 space-y-4 h-fit">
|
|
292
|
+
<InspectorSection title="General">
|
|
293
|
+
<TextField label="Name" value={name} onChange={setName} />
|
|
294
|
+
<label className="flex items-center gap-3 text-sm text-ink/70">
|
|
295
|
+
<input
|
|
296
|
+
type="checkbox"
|
|
297
|
+
checked={active}
|
|
298
|
+
onChange={(e) => setActive(e.target.checked)}
|
|
299
|
+
className="size-4 accent-sea"
|
|
300
|
+
/>
|
|
301
|
+
Active (visible on the site)
|
|
302
|
+
</label>
|
|
303
|
+
<p className="text-xs text-ink/45">
|
|
304
|
+
Inactive forms stop accepting submissions but keep everything already received.
|
|
305
|
+
</p>
|
|
306
|
+
</InspectorSection>
|
|
307
|
+
</div>
|
|
308
|
+
<div className="space-y-6">
|
|
309
|
+
<div className="border border-ink/10 bg-white p-6">
|
|
310
|
+
<FieldEditor fields={fields} usedKeys={form.usedFieldKeys} onChange={setFields} />
|
|
311
|
+
</div>
|
|
312
|
+
<div className="border border-ink/10 bg-white p-6">
|
|
313
|
+
<RuleSimulator fields={activeFields} />
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
) : null}
|
|
318
|
+
|
|
319
|
+
{tab === "preview" ? (
|
|
320
|
+
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
|
321
|
+
<div className="border border-ink/10 bg-white p-6">
|
|
322
|
+
<FormPreview
|
|
323
|
+
formKey={form.key}
|
|
324
|
+
name={name}
|
|
325
|
+
formType={form.form_type}
|
|
326
|
+
fields={activeFields}
|
|
327
|
+
pub={pub}
|
|
328
|
+
priv={priv}
|
|
329
|
+
defaults={d}
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
<div className="border border-ink/10 bg-white p-6">
|
|
333
|
+
<TestDeliveryPanel formKey={form.key} fields={activeFields} priv={priv} />
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
) : null}
|
|
337
|
+
|
|
338
|
+
{tab === "presentation" ? (
|
|
339
|
+
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
|
340
|
+
<div className="border border-ink/10 bg-white p-6 space-y-4">
|
|
341
|
+
<InspectorSection title="Copy">
|
|
342
|
+
<LocalizedField
|
|
343
|
+
label="Title"
|
|
344
|
+
value={pub.title}
|
|
345
|
+
onChange={(v) => setPub({ ...pub, title: v })}
|
|
346
|
+
/>
|
|
347
|
+
<LocalizedField
|
|
348
|
+
label="Intro"
|
|
349
|
+
value={pub.intro}
|
|
350
|
+
multiline
|
|
351
|
+
onChange={(v) => setPub({ ...pub, intro: v })}
|
|
352
|
+
/>
|
|
353
|
+
<LocalizedField
|
|
354
|
+
label="Submit button"
|
|
355
|
+
value={pub.submitLabel}
|
|
356
|
+
onChange={(v) => setPub({ ...pub, submitLabel: v })}
|
|
357
|
+
/>
|
|
358
|
+
</InspectorSection>
|
|
359
|
+
|
|
360
|
+
<InspectorSection title="Consent">
|
|
361
|
+
<label className="flex items-center gap-3 text-sm text-ink/70">
|
|
362
|
+
<input
|
|
363
|
+
type="checkbox"
|
|
364
|
+
checked={pub.requireConsent !== false}
|
|
365
|
+
onChange={(e) => setPub({ ...pub, requireConsent: e.target.checked })}
|
|
366
|
+
className="size-4 accent-sea"
|
|
367
|
+
/>
|
|
368
|
+
Require GDPR consent checkbox
|
|
369
|
+
</label>
|
|
370
|
+
<LocalizedField
|
|
371
|
+
label="Consent text"
|
|
372
|
+
value={pub.consentText}
|
|
373
|
+
multiline
|
|
374
|
+
onChange={(v) => setPub({ ...pub, consentText: v })}
|
|
375
|
+
/>
|
|
376
|
+
<p className="text-xs text-ink/50">
|
|
377
|
+
Link the policy with <code>[integritetspolicyn](privacy)</code> (uses the shared
|
|
378
|
+
privacy policy URL) or a path like <code>[privacy policy](/integritetspolicy)</code>
|
|
379
|
+
.
|
|
380
|
+
</p>
|
|
381
|
+
{isInherited(pub.consentText) ? <Inherited from={d?.consent_text?.sv ?? ""} /> : null}
|
|
382
|
+
</InspectorSection>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<div className="border border-ink/10 bg-white p-6 space-y-4">
|
|
386
|
+
<InspectorSection title="After submit">
|
|
387
|
+
<label className="block">
|
|
388
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Mode</span>
|
|
389
|
+
<select
|
|
390
|
+
value={pub.thankyouMode ?? ""}
|
|
391
|
+
onChange={(e) =>
|
|
392
|
+
setPub({
|
|
393
|
+
...pub,
|
|
394
|
+
thankyouMode: (e.target.value || undefined) as
|
|
395
|
+
| "inline"
|
|
396
|
+
| "redirect"
|
|
397
|
+
| undefined,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
401
|
+
>
|
|
402
|
+
<option value="">Use shared default ({d?.thankyou_mode ?? "inline"})</option>
|
|
403
|
+
<option value="inline">Show thank-you message</option>
|
|
404
|
+
<option value="redirect">Redirect to a page</option>
|
|
405
|
+
</select>
|
|
406
|
+
</label>
|
|
407
|
+
<LocalizedField
|
|
408
|
+
label="Thank-you message"
|
|
409
|
+
value={pub.thankyouMessage}
|
|
410
|
+
multiline
|
|
411
|
+
onChange={(v) => setPub({ ...pub, thankyouMessage: v })}
|
|
412
|
+
/>
|
|
413
|
+
{isInherited(pub.thankyouMessage) ? (
|
|
414
|
+
<Inherited from={d?.thankyou_message?.sv ?? ""} />
|
|
415
|
+
) : null}
|
|
416
|
+
<TextField
|
|
417
|
+
label="Redirect URL"
|
|
418
|
+
value={pub.thankyouRedirect ?? ""}
|
|
419
|
+
onChange={(v) => setPub({ ...pub, thankyouRedirect: v })}
|
|
420
|
+
/>
|
|
421
|
+
{isInherited(pub.thankyouRedirect) ? (
|
|
422
|
+
<Inherited from={d?.thankyou_redirect ?? ""} />
|
|
423
|
+
) : null}
|
|
424
|
+
{(pub.thankyouMode ?? d?.thankyou_mode) === "redirect" &&
|
|
425
|
+
!(pub.thankyouRedirect ?? d?.thankyou_redirect ?? "").trim() ? (
|
|
426
|
+
<p className="text-xs text-red-600">
|
|
427
|
+
Redirect mode needs a URL — without one the thank-you message is shown instead.
|
|
428
|
+
</p>
|
|
429
|
+
) : null}
|
|
430
|
+
</InspectorSection>
|
|
431
|
+
|
|
432
|
+
<InspectorSection title="Spam protection">
|
|
433
|
+
<TextField
|
|
434
|
+
label="Minimum seconds before submit (empty = shared default)"
|
|
435
|
+
value={priv.spamMinSeconds ? String(priv.spamMinSeconds) : ""}
|
|
436
|
+
onChange={(v) =>
|
|
437
|
+
setPriv({ ...priv, spamMinSeconds: v ? Number(v) || undefined : undefined })
|
|
438
|
+
}
|
|
439
|
+
/>
|
|
440
|
+
{priv.spamMinSeconds == null ? (
|
|
441
|
+
<Inherited from={String(d?.spam_min_seconds ?? 3)} />
|
|
442
|
+
) : null}
|
|
443
|
+
<TextField
|
|
444
|
+
label="Max submissions per IP per hour (empty = shared default)"
|
|
445
|
+
value={priv.spamMaxPerHour ? String(priv.spamMaxPerHour) : ""}
|
|
446
|
+
onChange={(v) =>
|
|
447
|
+
setPriv({ ...priv, spamMaxPerHour: v ? Number(v) || undefined : undefined })
|
|
448
|
+
}
|
|
449
|
+
/>
|
|
450
|
+
{priv.spamMaxPerHour == null ? (
|
|
451
|
+
<Inherited from={String(d?.spam_max_per_hour ?? 10)} />
|
|
452
|
+
) : null}
|
|
453
|
+
</InspectorSection>
|
|
454
|
+
|
|
455
|
+
<InspectorSection title="Incomplete submissions">
|
|
456
|
+
<label className="block">
|
|
457
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
458
|
+
Save what visitors type even if they never submit
|
|
459
|
+
</span>
|
|
460
|
+
<select
|
|
461
|
+
value={priv.savePartials === undefined ? "" : priv.savePartials ? "on" : "off"}
|
|
462
|
+
onChange={(e) =>
|
|
463
|
+
setPriv({
|
|
464
|
+
...priv,
|
|
465
|
+
savePartials: e.target.value === "" ? undefined : e.target.value === "on",
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
469
|
+
>
|
|
470
|
+
<option value="">
|
|
471
|
+
Use shared default ({d?.save_partials === false ? "off" : "on"})
|
|
472
|
+
</option>
|
|
473
|
+
<option value="on">On</option>
|
|
474
|
+
<option value="off">Off</option>
|
|
475
|
+
</select>
|
|
476
|
+
</label>
|
|
477
|
+
<p className="text-xs text-ink/45">
|
|
478
|
+
Drafts are stored separately from real submissions and are marked as completed when
|
|
479
|
+
the visitor finally sends the form. See them under Submissions → Incomplete.
|
|
480
|
+
</p>
|
|
481
|
+
</InspectorSection>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
) : null}
|
|
485
|
+
|
|
486
|
+
{tab === "delivery" ? (
|
|
487
|
+
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
|
488
|
+
<div className="border border-ink/10 bg-white p-6 space-y-4">
|
|
489
|
+
<InspectorSection title="Admin notification">
|
|
490
|
+
<label className="block">
|
|
491
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
492
|
+
Send notification
|
|
493
|
+
</span>
|
|
494
|
+
<select
|
|
495
|
+
value={priv.notifyEnabled === undefined ? "" : priv.notifyEnabled ? "on" : "off"}
|
|
496
|
+
onChange={(e) =>
|
|
497
|
+
setPriv({
|
|
498
|
+
...priv,
|
|
499
|
+
notifyEnabled: e.target.value === "" ? undefined : e.target.value === "on",
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
503
|
+
>
|
|
504
|
+
<option value="">
|
|
505
|
+
Use shared default ({d?.notify_enabled === false ? "off" : "on"})
|
|
506
|
+
</option>
|
|
507
|
+
<option value="on">On</option>
|
|
508
|
+
<option value="off">Off</option>
|
|
509
|
+
</select>
|
|
510
|
+
</label>
|
|
511
|
+
<TextField
|
|
512
|
+
label="Notify these addresses (comma separated)"
|
|
513
|
+
value={priv.notifyTo ?? ""}
|
|
514
|
+
onChange={(v) => setPriv({ ...priv, notifyTo: v })}
|
|
515
|
+
/>
|
|
516
|
+
{isInherited(priv.notifyTo) ? <Inherited from={d?.notify_to ?? ""} /> : null}
|
|
517
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
518
|
+
<SmartTagTextField
|
|
519
|
+
label="Sender name"
|
|
520
|
+
value={priv.notifyFromName ?? ""}
|
|
521
|
+
fields={activeFields}
|
|
522
|
+
onChange={(v) => setPriv({ ...priv, notifyFromName: v })}
|
|
523
|
+
/>
|
|
524
|
+
<TextField
|
|
525
|
+
label="Sender address"
|
|
526
|
+
value={priv.notifyFromEmail ?? ""}
|
|
527
|
+
onChange={(v) => setPriv({ ...priv, notifyFromEmail: v })}
|
|
528
|
+
/>
|
|
529
|
+
</div>
|
|
530
|
+
<SmartTagTextField
|
|
531
|
+
label="Reply-to"
|
|
532
|
+
value={priv.notifyReplyTo ?? ""}
|
|
533
|
+
fields={activeFields}
|
|
534
|
+
placeholder="{{email}}"
|
|
535
|
+
onChange={(v) => setPriv({ ...priv, notifyReplyTo: v })}
|
|
536
|
+
/>
|
|
537
|
+
<p className="text-xs text-ink/45">
|
|
538
|
+
Use {"{{"}email{"}}"} to reply straight to the person who submitted the form.
|
|
539
|
+
</p>
|
|
540
|
+
{isInherited(priv.notifyReplyTo) ? (
|
|
541
|
+
<Inherited from={d?.notify_reply_to ?? ""} />
|
|
542
|
+
) : null}
|
|
543
|
+
<SmartTagLocalizedField
|
|
544
|
+
label="Subject"
|
|
545
|
+
value={priv.notifySubject}
|
|
546
|
+
fields={activeFields}
|
|
547
|
+
onChange={(v) => setPriv({ ...priv, notifySubject: v })}
|
|
548
|
+
/>
|
|
549
|
+
{isInherited(priv.notifySubject) ? (
|
|
550
|
+
<Inherited from={d?.notify_subject?.sv ?? ""} />
|
|
551
|
+
) : null}
|
|
552
|
+
<SmartTagLocalizedField
|
|
553
|
+
label="Body (empty = standard answer table)"
|
|
554
|
+
value={priv.notifyBody}
|
|
555
|
+
fields={activeFields}
|
|
556
|
+
multiline
|
|
557
|
+
onChange={(v) => setPriv({ ...priv, notifyBody: v })}
|
|
558
|
+
/>
|
|
559
|
+
{isInherited(priv.notifyBody) ? (
|
|
560
|
+
<Inherited from={(d?.notify_body?.sv ?? "").slice(0, 60)} />
|
|
561
|
+
) : null}
|
|
562
|
+
</InspectorSection>
|
|
563
|
+
|
|
564
|
+
<InspectorSection title="Conditional routing">
|
|
565
|
+
<p className="text-xs text-ink/50">
|
|
566
|
+
Send this form to different people depending on the answers. Rules add recipients; a
|
|
567
|
+
rule marked “only these” replaces the addresses above.
|
|
568
|
+
</p>
|
|
569
|
+
{(priv.notificationRules ?? []).map((rule, i) => (
|
|
570
|
+
<div key={i} className="space-y-2 border border-ink/10 p-3">
|
|
571
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
572
|
+
<input
|
|
573
|
+
value={rule.to}
|
|
574
|
+
placeholder="who@example.com"
|
|
575
|
+
onChange={(v) => patchRule(i, { to: v.target.value })}
|
|
576
|
+
className="min-w-[180px] flex-1 border border-ink/15 px-2 py-1 text-xs"
|
|
577
|
+
/>
|
|
578
|
+
<label className="flex items-center gap-1.5 text-[0.65rem] uppercase tracking-[0.16em] text-ink/55">
|
|
579
|
+
<input
|
|
580
|
+
type="checkbox"
|
|
581
|
+
checked={rule.exclusive ?? false}
|
|
582
|
+
onChange={(e) => patchRule(i, { exclusive: e.target.checked })}
|
|
583
|
+
className="size-3.5 accent-sea"
|
|
584
|
+
/>
|
|
585
|
+
Only these
|
|
586
|
+
</label>
|
|
587
|
+
<label className="flex items-center gap-1.5 text-[0.65rem] uppercase tracking-[0.16em] text-ink/55">
|
|
588
|
+
<input
|
|
589
|
+
type="checkbox"
|
|
590
|
+
checked={rule.enabled !== false}
|
|
591
|
+
onChange={(e) => patchRule(i, { enabled: e.target.checked })}
|
|
592
|
+
className="size-3.5 accent-sea"
|
|
593
|
+
/>
|
|
594
|
+
Active
|
|
595
|
+
</label>
|
|
596
|
+
<button
|
|
597
|
+
type="button"
|
|
598
|
+
onClick={() =>
|
|
599
|
+
setPriv({
|
|
600
|
+
...priv,
|
|
601
|
+
notificationRules: (priv.notificationRules ?? []).filter(
|
|
602
|
+
(_, idx) => idx !== i,
|
|
603
|
+
),
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
className="text-[0.65rem] uppercase tracking-[0.16em] text-ink/45 hover:text-red-500"
|
|
607
|
+
>
|
|
608
|
+
Remove
|
|
609
|
+
</button>
|
|
610
|
+
</div>
|
|
611
|
+
<ConditionBuilder
|
|
612
|
+
title="When"
|
|
613
|
+
description="Rule applies while these answers match."
|
|
614
|
+
rule={rule.when}
|
|
615
|
+
fields={activeFields}
|
|
616
|
+
onChange={(r) => patchRule(i, { when: r })}
|
|
617
|
+
/>
|
|
618
|
+
</div>
|
|
619
|
+
))}
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
onClick={() =>
|
|
623
|
+
setPriv({
|
|
624
|
+
...priv,
|
|
625
|
+
notificationRules: [
|
|
626
|
+
...(priv.notificationRules ?? []),
|
|
627
|
+
{
|
|
628
|
+
id:
|
|
629
|
+
typeof crypto !== "undefined" && crypto.randomUUID
|
|
630
|
+
? crypto.randomUUID()
|
|
631
|
+
: String(Date.now()),
|
|
632
|
+
to: "",
|
|
633
|
+
enabled: true,
|
|
634
|
+
exclusive: false,
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
className="border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
|
|
640
|
+
>
|
|
641
|
+
Add routing rule
|
|
642
|
+
</button>
|
|
643
|
+
</InspectorSection>
|
|
644
|
+
|
|
645
|
+
<InspectorSection title="Autoreply to the sender">
|
|
646
|
+
<label className="block">
|
|
647
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
648
|
+
Autoreply
|
|
649
|
+
</span>
|
|
650
|
+
<select
|
|
651
|
+
value={
|
|
652
|
+
priv.autoreplyEnabled === undefined ? "" : priv.autoreplyEnabled ? "on" : "off"
|
|
653
|
+
}
|
|
654
|
+
onChange={(e) =>
|
|
655
|
+
setPriv({
|
|
656
|
+
...priv,
|
|
657
|
+
autoreplyEnabled: e.target.value === "" ? undefined : e.target.value === "on",
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
661
|
+
>
|
|
662
|
+
<option value="">
|
|
663
|
+
Use shared default ({d?.autoreply_enabled ? "on" : "off"})
|
|
664
|
+
</option>
|
|
665
|
+
<option value="on">On</option>
|
|
666
|
+
<option value="off">Off</option>
|
|
667
|
+
</select>
|
|
668
|
+
</label>
|
|
669
|
+
<SmartTagLocalizedField
|
|
670
|
+
label="Subject"
|
|
671
|
+
value={priv.autoreplySubject}
|
|
672
|
+
fields={activeFields}
|
|
673
|
+
onChange={(v) => setPriv({ ...priv, autoreplySubject: v })}
|
|
674
|
+
/>
|
|
675
|
+
{isInherited(priv.autoreplySubject) ? (
|
|
676
|
+
<Inherited from={d?.autoreply_subject?.sv ?? ""} />
|
|
677
|
+
) : null}
|
|
678
|
+
<SmartTagLocalizedField
|
|
679
|
+
label="Body"
|
|
680
|
+
value={priv.autoreplyBody}
|
|
681
|
+
fields={activeFields}
|
|
682
|
+
multiline
|
|
683
|
+
onChange={(v) => setPriv({ ...priv, autoreplyBody: v })}
|
|
684
|
+
/>
|
|
685
|
+
|
|
686
|
+
{isInherited(priv.autoreplyBody) ? (
|
|
687
|
+
<Inherited from={(d?.autoreply_body?.sv ?? "").slice(0, 60)} />
|
|
688
|
+
) : null}
|
|
689
|
+
<p className="text-xs text-ink/45">
|
|
690
|
+
Use {"{{"}field_key{"}}"} to insert an answer, for example {"{{"}name{"}}"} or{" "}
|
|
691
|
+
{"{{"}email{"}}"}. Available keys:{" "}
|
|
692
|
+
{fields
|
|
693
|
+
.filter((f) => f.enabled)
|
|
694
|
+
.map((f) => f.key)
|
|
695
|
+
.join(", ")}
|
|
696
|
+
.
|
|
697
|
+
</p>
|
|
698
|
+
</InspectorSection>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<div className="border border-ink/10 bg-white p-6 space-y-4">
|
|
702
|
+
<InspectorSection title="Google Sheets">
|
|
703
|
+
<TextField
|
|
704
|
+
label="Spreadsheet ID"
|
|
705
|
+
value={priv.sheetId ?? ""}
|
|
706
|
+
onChange={(v) => setPriv({ ...priv, sheetId: v })}
|
|
707
|
+
/>
|
|
708
|
+
{isInherited(priv.sheetId) ? <Inherited from={d?.sheet_id ?? ""} /> : null}
|
|
709
|
+
<TextField
|
|
710
|
+
label="Tab name"
|
|
711
|
+
value={priv.sheetTab ?? ""}
|
|
712
|
+
onChange={(v) => setPriv({ ...priv, sheetTab: v })}
|
|
713
|
+
/>
|
|
714
|
+
{isInherited(priv.sheetTab) ? <Inherited from={d?.sheet_tab ?? ""} /> : null}
|
|
715
|
+
<label className="block">
|
|
716
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
717
|
+
Append every new submission live
|
|
718
|
+
</span>
|
|
719
|
+
<select
|
|
720
|
+
value={
|
|
721
|
+
priv.sheetLiveAppend === undefined ? "" : priv.sheetLiveAppend ? "on" : "off"
|
|
722
|
+
}
|
|
723
|
+
onChange={(e) =>
|
|
724
|
+
setPriv({
|
|
725
|
+
...priv,
|
|
726
|
+
sheetLiveAppend: e.target.value === "" ? undefined : e.target.value === "on",
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
730
|
+
>
|
|
731
|
+
<option value="">
|
|
732
|
+
Use shared default ({d?.sheet_live_append ? "on" : "off"})
|
|
733
|
+
</option>
|
|
734
|
+
<option value="on">On</option>
|
|
735
|
+
<option value="off">Off</option>
|
|
736
|
+
</select>
|
|
737
|
+
</label>
|
|
738
|
+
{(priv.sheetLiveAppend ?? d?.sheet_live_append) &&
|
|
739
|
+
!(priv.sheetId ?? d?.sheet_id ?? "").trim() ? (
|
|
740
|
+
<p className="text-xs text-red-600">
|
|
741
|
+
Live append does nothing until a spreadsheet ID is set here or in the shared
|
|
742
|
+
settings.
|
|
743
|
+
</p>
|
|
744
|
+
) : null}
|
|
745
|
+
<button
|
|
746
|
+
onClick={async () => {
|
|
747
|
+
try {
|
|
748
|
+
const res = await syncSubmissions({ data: { formKey: form.key } });
|
|
749
|
+
toast.success(`Synced ${res.synced} submission(s)`);
|
|
750
|
+
await qc.invalidateQueries({ queryKey: ["form-submissions"] });
|
|
751
|
+
} catch (e) {
|
|
752
|
+
toast.error(e instanceof Error ? e.message : "Sync failed");
|
|
753
|
+
}
|
|
754
|
+
}}
|
|
755
|
+
className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
756
|
+
>
|
|
757
|
+
<RefreshCw className="size-4" />
|
|
758
|
+
Sync pending submissions now
|
|
759
|
+
</button>
|
|
760
|
+
<p className="text-xs text-ink/45">
|
|
761
|
+
Submissions are always stored first — a Sheets outage never loses data.
|
|
762
|
+
</p>
|
|
763
|
+
</InspectorSection>
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<div className="border border-ink/10 bg-white p-6 space-y-4">
|
|
767
|
+
<InspectorSection title="Webhook">
|
|
768
|
+
<label className="flex items-center gap-3 text-sm text-ink/70">
|
|
769
|
+
<input
|
|
770
|
+
type="checkbox"
|
|
771
|
+
checked={priv.webhookEnabled ?? false}
|
|
772
|
+
onChange={(e) => setPriv({ ...priv, webhookEnabled: e.target.checked })}
|
|
773
|
+
className="size-4 accent-sea"
|
|
774
|
+
/>
|
|
775
|
+
POST every new submission to an external endpoint
|
|
776
|
+
</label>
|
|
777
|
+
<TextField
|
|
778
|
+
label="Endpoint URL"
|
|
779
|
+
value={priv.webhookUrl ?? ""}
|
|
780
|
+
onChange={(v) => setPriv({ ...priv, webhookUrl: v })}
|
|
781
|
+
/>
|
|
782
|
+
<TextField
|
|
783
|
+
label="Signing secret (optional)"
|
|
784
|
+
value={priv.webhookSecret ?? ""}
|
|
785
|
+
onChange={(v) => setPriv({ ...priv, webhookSecret: v })}
|
|
786
|
+
/>
|
|
787
|
+
<button
|
|
788
|
+
onClick={async () => {
|
|
789
|
+
try {
|
|
790
|
+
const res = await testWebhook({
|
|
791
|
+
data: {
|
|
792
|
+
formKey: form.key,
|
|
793
|
+
url: priv.webhookUrl || undefined,
|
|
794
|
+
secret: priv.webhookSecret || undefined,
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
if (res.ok) toast.success(`Webhook responded ${res.status ?? 200}`);
|
|
798
|
+
else
|
|
799
|
+
toast.error(res.error ?? `Webhook failed (${res.status ?? "no response"})`);
|
|
800
|
+
await qc.invalidateQueries({ queryKey: ["form-deliveries"] });
|
|
801
|
+
} catch (e) {
|
|
802
|
+
toast.error(e instanceof Error ? e.message : "Webhook test failed");
|
|
803
|
+
}
|
|
804
|
+
}}
|
|
805
|
+
className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
806
|
+
>
|
|
807
|
+
<RefreshCw className="size-4" />
|
|
808
|
+
Send test payload
|
|
809
|
+
</button>
|
|
810
|
+
<p className="text-xs text-ink/45">
|
|
811
|
+
Requests are signed with HMAC-SHA256 in the <code>X-Signature</code> header. Failed
|
|
812
|
+
calls appear in the delivery monitor and can be retried there.
|
|
813
|
+
</p>
|
|
814
|
+
<Link
|
|
815
|
+
to={paths.deliveries}
|
|
816
|
+
className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-ink/50 hover:text-ink"
|
|
817
|
+
>
|
|
818
|
+
<ExternalLink className="size-4" />
|
|
819
|
+
Delivery monitor
|
|
820
|
+
</Link>
|
|
821
|
+
</InspectorSection>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
) : null}
|
|
825
|
+
|
|
826
|
+
{tab === "history" ? (
|
|
827
|
+
<div className="mt-6">
|
|
828
|
+
<VersionHistory formKey={form.key} defaults={d ?? null} />
|
|
829
|
+
</div>
|
|
830
|
+
) : null}
|
|
831
|
+
|
|
832
|
+
{tab === "activity" ? (
|
|
833
|
+
<div className="mt-6">
|
|
834
|
+
<ActivityLog formKey={form.key} />
|
|
835
|
+
</div>
|
|
836
|
+
) : null}
|
|
837
|
+
</div>
|
|
838
|
+
);
|
|
839
|
+
}
|