@bygghammar/cms-platform 0.17.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.
@@ -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
+ }