@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/docs/POPUPS.md +102 -0
  5. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  6. package/package.json +31 -3
  7. package/scripts/create-cms-site.mjs +5 -4
  8. package/scripts/sync-migrations.mjs +27 -6
  9. package/src/backoffice/ContactDetailsView.tsx +19 -5
  10. package/src/backoffice/DomainsView.tsx +152 -25
  11. package/src/backoffice/EditLock.tsx +513 -0
  12. package/src/backoffice/EmailSettingsView.tsx +99 -0
  13. package/src/backoffice/FormEditorView.tsx +531 -512
  14. package/src/backoffice/FormSettingsView.tsx +16 -4
  15. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  16. package/src/backoffice/NavigationView.tsx +35 -11
  17. package/src/backoffice/PageBuilderView.tsx +51 -16
  18. package/src/backoffice/RichTextEditor.tsx +50 -14
  19. package/src/backoffice/SectionEditorView.tsx +18 -6
  20. package/src/backoffice/SectionFormView.tsx +184 -31
  21. package/src/backoffice/SiteSettingsView.tsx +14 -15
  22. package/src/backoffice/TrackingView.tsx +247 -208
  23. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  24. package/src/backoffice/inspectors/blocks.tsx +22 -1
  25. package/src/backoffice/inspectors/fields.tsx +21 -0
  26. package/src/backoffice/routes/host.ts +58 -7
  27. package/src/backoffice/routes/index.tsx +117 -15
  28. package/src/backoffice/routes/pages.tsx +10 -6
  29. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  30. package/src/backoffice/shell/domain-guard.ts +27 -14
  31. package/src/backoffice/shell/index.ts +5 -0
  32. package/src/backoffice/shell/nav-model.ts +32 -23
  33. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  34. package/src/backoffice/tracking-drafts.ts +150 -0
  35. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  36. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  37. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  38. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  39. package/src/blocks/components/RichTextBlock.tsx +26 -4
  40. package/src/blocks/index.ts +8 -0
  41. package/src/blocks/optional-content.ts +82 -0
  42. package/src/blocks/registry.tsx +1 -0
  43. package/src/blocks/types.ts +6 -0
  44. package/src/cms/edit-lock.ts +491 -0
  45. package/src/config/modules.ts +208 -0
  46. package/src/config/site-config.ts +9 -31
  47. package/src/forms/FormRenderer.tsx +62 -3
  48. package/src/forms/submission-result.ts +35 -0
  49. package/src/forms/success-listeners.ts +46 -0
  50. package/src/host/index.tsx +27 -1
  51. package/src/modules/index.ts +28 -0
  52. package/src/modules/public-runtime.tsx +159 -0
  53. package/src/navigation/index.ts +29 -0
  54. package/src/popups/admin.tsx +123 -0
  55. package/src/popups/cms-bridges.ts +240 -0
  56. package/src/popups/destinations.tsx +147 -0
  57. package/src/popups/host.ts +91 -0
  58. package/src/popups/media-bridge.tsx +84 -0
  59. package/src/popups/register.ts +23 -0
  60. package/src/popups/runtime.tsx +128 -0
  61. package/src/runtime/server-entry.ts +8 -2
  62. package/src/server/content-sections-core.ts +22 -5
  63. package/src/server/domains-core.ts +124 -107
  64. package/src/server/edit-lock-adapter.ts +91 -0
  65. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  66. package/src/server/edit-locks-core.ts +286 -0
  67. package/src/server/forms-core.ts +37 -2
  68. package/src/server/injections-core.ts +230 -49
  69. package/src/server/page-seo-save-core.ts +27 -0
  70. package/src/server/pages-core.ts +108 -11
  71. package/src/server/popup-optin-trigger.ts +111 -0
  72. package/src/server/popup-optin-webhook-core.ts +363 -0
  73. package/src/server/provider-secrets.ts +100 -4
  74. package/src/server/schemas.ts +74 -9
  75. package/src/server/webhook-url-guard.ts +199 -0
  76. package/src/site/SiteHeader.tsx +11 -5
  77. package/src/site/host.ts +172 -0
  78. package/src/site/locale-domains.ts +500 -0
  79. package/src/site/routes.ts +206 -38
  80. package/src/site/site-url.ts +80 -17
  81. package/src/tracking/injections.ts +52 -9
  82. package/src/types/css-modules.d.ts +6 -0
  83. package/src/ui/Reveal.tsx +74 -9
@@ -15,6 +15,8 @@ import { InspectorSection, LocalizedField, TextField } from "./inspectors/fields
15
15
  import { RuleSimulator } from "./forms/RuleSimulator";
16
16
  import { ConditionBuilder } from "./forms/ConditionBuilder";
17
17
  import { SmartTagTextField, SmartTagLocalizedField } from "./forms/SmartTags";
18
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
19
+ import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
18
20
 
19
21
  /** One managed form row as the host's list server function returns it. */
20
22
  export type FormEditorRow = {
@@ -40,6 +42,8 @@ export type FormEditorDeps = {
40
42
  saveForm: (input: {
41
43
  data: {
42
44
  id: string;
45
+ /** Editing session holding the lock; the server checks it before writing. */
46
+ editSessionId?: string;
43
47
  key: string;
44
48
  name: string;
45
49
  form_type: string;
@@ -86,6 +90,7 @@ export type FormEditorDeps = {
86
90
  VersionHistoryComponent: (props: {
87
91
  formKey: string;
88
92
  defaults?: FormDefaults | null;
93
+ editSessionId?: string;
89
94
  }) => React.ReactNode;
90
95
  ActivityLogComponent: (props: { formKey?: string }) => React.ReactNode;
91
96
  /** Backoffice route paths, so hosts keep control over their URL scheme. */
@@ -152,6 +157,11 @@ export function FormEditorView({ deps }: { deps: FormEditorDeps }) {
152
157
  const [busy, setBusy] = useState(false);
153
158
  const [tab, setTab] = useState<TabId>("fields");
154
159
 
160
+ const lock = useEditLock({
161
+ resourceType: EDIT_LOCK_TYPES.form,
162
+ resourceId: deps.form?.id,
163
+ });
164
+
155
165
  /** Removed built-in fields stay in state as tombstones but never render. */
156
166
  const activeFields = useMemo(() => fields.filter((f) => !f.removed), [fields]);
157
167
 
@@ -189,6 +199,7 @@ export function FormEditorView({ deps }: { deps: FormEditorDeps }) {
189
199
  await saveForm({
190
200
  data: {
191
201
  id: form.id,
202
+ editSessionId: lock.sessionId,
192
203
  key: form.key,
193
204
  name,
194
205
  form_type: form.form_type,
@@ -263,7 +274,7 @@ export function FormEditorView({ deps }: { deps: FormEditorDeps }) {
263
274
  </Link>
264
275
  <button
265
276
  onClick={onSave}
266
- disabled={busy}
277
+ disabled={busy || lock.blocked}
267
278
  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
279
  >
269
280
  <Save className="size-4" />
@@ -286,554 +297,562 @@ export function FormEditorView({ deps }: { deps: FormEditorDeps }) {
286
297
  ))}
287
298
  </div>
288
299
 
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>
300
+ <EditLockBanner lock={lock} className="mt-6" />
301
+
302
+ <EditLockFence lock={lock} className="block">
303
+ {tab === "fields" ? (
304
+ <div className="mt-6 grid gap-6 lg:grid-cols-[320px_1fr]">
305
+ <div className="border border-ink/10 bg-white p-6 space-y-4 h-fit">
306
+ <InspectorSection title="General">
307
+ <TextField label="Name" value={name} onChange={setName} />
308
+ <label className="flex items-center gap-3 text-sm text-ink/70">
309
+ <input
310
+ type="checkbox"
311
+ checked={active}
312
+ onChange={(e) => setActive(e.target.checked)}
313
+ className="size-4 accent-sea"
314
+ />
315
+ Active (visible on the site)
316
+ </label>
317
+ <p className="text-xs text-ink/45">
318
+ Inactive forms stop accepting submissions but keep everything already received.
319
+ </p>
320
+ </InspectorSection>
321
+ </div>
322
+ <div className="space-y-6">
323
+ <div className="border border-ink/10 bg-white p-6">
324
+ <FieldEditor fields={fields} usedKeys={form.usedFieldKeys} onChange={setFields} />
325
+ </div>
326
+ <div className="border border-ink/10 bg-white p-6">
327
+ <RuleSimulator fields={activeFields} />
328
+ </div>
329
+ </div>
307
330
  </div>
308
- <div className="space-y-6">
331
+ ) : null}
332
+
333
+ {tab === "preview" ? (
334
+ <div className="mt-6 grid gap-6 lg:grid-cols-2">
309
335
  <div className="border border-ink/10 bg-white p-6">
310
- <FieldEditor fields={fields} usedKeys={form.usedFieldKeys} onChange={setFields} />
336
+ <FormPreview
337
+ formKey={form.key}
338
+ name={name}
339
+ formType={form.form_type}
340
+ fields={activeFields}
341
+ pub={pub}
342
+ priv={priv}
343
+ defaults={d}
344
+ />
311
345
  </div>
312
346
  <div className="border border-ink/10 bg-white p-6">
313
- <RuleSimulator fields={activeFields} />
347
+ <TestDeliveryPanel formKey={form.key} fields={activeFields} priv={priv} />
314
348
  </div>
315
349
  </div>
316
- </div>
317
- ) : null}
350
+ ) : null}
318
351
 
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"
352
+ {tab === "presentation" ? (
353
+ <div className="mt-6 grid gap-6 lg:grid-cols-2">
354
+ <div className="border border-ink/10 bg-white p-6 space-y-4">
355
+ <InspectorSection title="Copy">
356
+ <LocalizedField
357
+ label="Title"
358
+ value={pub.title}
359
+ onChange={(v) => setPub({ ...pub, title: v })}
367
360
  />
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>
361
+ <LocalizedField
362
+ label="Intro"
363
+ value={pub.intro}
364
+ multiline
365
+ onChange={(v) => setPub({ ...pub, intro: v })}
366
+ />
367
+ <LocalizedField
368
+ label="Submit button"
369
+ value={pub.submitLabel}
370
+ onChange={(v) => setPub({ ...pub, submitLabel: v })}
371
+ />
372
+ </InspectorSection>
384
373
 
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.
374
+ <InspectorSection title="Consent">
375
+ <label className="flex items-center gap-3 text-sm text-ink/70">
376
+ <input
377
+ type="checkbox"
378
+ checked={pub.requireConsent !== false}
379
+ onChange={(e) => setPub({ ...pub, requireConsent: e.target.checked })}
380
+ className="size-4 accent-sea"
381
+ />
382
+ Require GDPR consent checkbox
383
+ </label>
384
+ <LocalizedField
385
+ label="Consent text"
386
+ value={pub.consentText}
387
+ multiline
388
+ onChange={(v) => setPub({ ...pub, consentText: v })}
389
+ />
390
+ <p className="text-xs text-ink/50">
391
+ Link the policy with <code>[integritetspolicyn](privacy)</code> (uses the shared
392
+ privacy policy URL) or a path like <code>[privacy policy](/integritetspolicy)</code>
393
+ .
428
394
  </p>
429
- ) : null}
430
- </InspectorSection>
395
+ {isInherited(pub.consentText) ? <Inherited from={d?.consent_text?.sv ?? ""} /> : null}
396
+ </InspectorSection>
397
+ </div>
431
398
 
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>
399
+ <div className="border border-ink/10 bg-white p-6 space-y-4">
400
+ <InspectorSection title="After submit">
401
+ <label className="block">
402
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Mode</span>
403
+ <select
404
+ value={pub.thankyouMode ?? ""}
405
+ onChange={(e) =>
406
+ setPub({
407
+ ...pub,
408
+ thankyouMode: (e.target.value || undefined) as
409
+ | "inline"
410
+ | "redirect"
411
+ | undefined,
412
+ })
413
+ }
414
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
415
+ >
416
+ <option value="">Use shared default ({d?.thankyou_mode ?? "inline"})</option>
417
+ <option value="inline">Show thank-you message</option>
418
+ <option value="redirect">Redirect to a page</option>
419
+ </select>
420
+ </label>
421
+ <LocalizedField
422
+ label="Thank-you message"
423
+ value={pub.thankyouMessage}
424
+ multiline
425
+ onChange={(v) => setPub({ ...pub, thankyouMessage: v })}
426
+ />
427
+ {isInherited(pub.thankyouMessage) ? (
428
+ <Inherited from={d?.thankyou_message?.sv ?? ""} />
429
+ ) : null}
430
+ <TextField
431
+ label="Redirect URL"
432
+ value={pub.thankyouRedirect ?? ""}
433
+ onChange={(v) => setPub({ ...pub, thankyouRedirect: v })}
434
+ />
435
+ {isInherited(pub.thankyouRedirect) ? (
436
+ <Inherited from={d?.thankyou_redirect ?? ""} />
437
+ ) : null}
438
+ {(pub.thankyouMode ?? d?.thankyou_mode) === "redirect" &&
439
+ !(pub.thankyouRedirect ?? d?.thankyou_redirect ?? "").trim() ? (
440
+ <p className="text-xs text-red-600">
441
+ Redirect mode needs a URL — without one the thank-you message is shown instead.
442
+ </p>
443
+ ) : null}
444
+ </InspectorSection>
454
445
 
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
- })
446
+ <InspectorSection title="Spam protection">
447
+ <TextField
448
+ label="Minimum seconds before submit (empty = shared default)"
449
+ value={priv.spamMinSeconds ? String(priv.spamMinSeconds) : ""}
450
+ onChange={(v) =>
451
+ setPriv({ ...priv, spamMinSeconds: v ? Number(v) || undefined : undefined })
467
452
  }
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>
453
+ />
454
+ {priv.spamMinSeconds == null ? (
455
+ <Inherited from={String(d?.spam_min_seconds ?? 3)} />
456
+ ) : null}
457
+ <TextField
458
+ label="Max submissions per IP per hour (empty = shared default)"
459
+ value={priv.spamMaxPerHour ? String(priv.spamMaxPerHour) : ""}
460
+ onChange={(v) =>
461
+ setPriv({ ...priv, spamMaxPerHour: v ? Number(v) || undefined : undefined })
462
+ }
463
+ />
464
+ {priv.spamMaxPerHour == null ? (
465
+ <Inherited from={String(d?.spam_max_per_hour ?? 10)} />
466
+ ) : null}
467
+ </InspectorSection>
468
+
469
+ <InspectorSection title="Incomplete submissions">
470
+ <label className="block">
471
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
472
+ Save what visitors type even if they never submit
473
+ </span>
474
+ <select
475
+ value={priv.savePartials === undefined ? "" : priv.savePartials ? "on" : "off"}
476
+ onChange={(e) =>
477
+ setPriv({
478
+ ...priv,
479
+ savePartials: e.target.value === "" ? undefined : e.target.value === "on",
480
+ })
481
+ }
482
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
483
+ >
484
+ <option value="">
485
+ Use shared default ({d?.save_partials === false ? "off" : "on"})
486
+ </option>
487
+ <option value="on">On</option>
488
+ <option value="off">Off</option>
489
+ </select>
490
+ </label>
491
+ <p className="text-xs text-ink/45">
492
+ Drafts are stored separately from real submissions and are marked as completed when
493
+ the visitor finally sends the form. See them under Submissions → Incomplete.
494
+ </p>
495
+ </InspectorSection>
496
+ </div>
482
497
  </div>
483
- </div>
484
- ) : null}
498
+ ) : null}
485
499
 
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">
500
+ {tab === "delivery" ? (
501
+ <div className="mt-6 grid gap-6 lg:grid-cols-2">
502
+ <div className="border border-ink/10 bg-white p-6 space-y-4">
503
+ <InspectorSection title="Admin notification">
504
+ <label className="block">
505
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
506
+ Send notification
507
+ </span>
508
+ <select
509
+ value={priv.notifyEnabled === undefined ? "" : priv.notifyEnabled ? "on" : "off"}
510
+ onChange={(e) =>
511
+ setPriv({
512
+ ...priv,
513
+ notifyEnabled: e.target.value === "" ? undefined : e.target.value === "on",
514
+ })
515
+ }
516
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
517
+ >
518
+ <option value="">
519
+ Use shared default ({d?.notify_enabled === false ? "off" : "on"})
520
+ </option>
521
+ <option value="on">On</option>
522
+ <option value="off">Off</option>
523
+ </select>
524
+ </label>
525
+ <TextField
526
+ label="Notify these addresses (comma separated)"
527
+ value={priv.notifyTo ?? ""}
528
+ onChange={(v) => setPriv({ ...priv, notifyTo: v })}
529
+ />
530
+ {isInherited(priv.notifyTo) ? <Inherited from={d?.notify_to ?? ""} /> : null}
531
+ <div className="grid gap-3 sm:grid-cols-2">
532
+ <SmartTagTextField
533
+ label="Sender name"
534
+ value={priv.notifyFromName ?? ""}
535
+ fields={activeFields}
536
+ onChange={(v) => setPriv({ ...priv, notifyFromName: v })}
537
+ />
538
+ <TextField
539
+ label="Sender address"
540
+ value={priv.notifyFromEmail ?? ""}
541
+ onChange={(v) => setPriv({ ...priv, notifyFromEmail: v })}
542
+ />
543
+ </div>
518
544
  <SmartTagTextField
519
- label="Sender name"
520
- value={priv.notifyFromName ?? ""}
545
+ label="Reply-to"
546
+ value={priv.notifyReplyTo ?? ""}
521
547
  fields={activeFields}
522
- onChange={(v) => setPriv({ ...priv, notifyFromName: v })}
548
+ placeholder="{{email}}"
549
+ onChange={(v) => setPriv({ ...priv, notifyReplyTo: v })}
523
550
  />
524
- <TextField
525
- label="Sender address"
526
- value={priv.notifyFromEmail ?? ""}
527
- onChange={(v) => setPriv({ ...priv, notifyFromEmail: v })}
551
+ <p className="text-xs text-ink/45">
552
+ Use {"{{"}email{"}}"} to reply straight to the person who submitted the form.
553
+ </p>
554
+ {isInherited(priv.notifyReplyTo) ? (
555
+ <Inherited from={d?.notify_reply_to ?? ""} />
556
+ ) : null}
557
+ <SmartTagLocalizedField
558
+ label="Subject"
559
+ value={priv.notifySubject}
560
+ fields={activeFields}
561
+ onChange={(v) => setPriv({ ...priv, notifySubject: v })}
528
562
  />
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
+ {isInherited(priv.notifySubject) ? (
564
+ <Inherited from={d?.notify_subject?.sv ?? ""} />
565
+ ) : null}
566
+ <SmartTagLocalizedField
567
+ label="Body (empty = standard answer table)"
568
+ value={priv.notifyBody}
569
+ fields={activeFields}
570
+ multiline
571
+ onChange={(v) => setPriv({ ...priv, notifyBody: v })}
572
+ />
573
+ {isInherited(priv.notifyBody) ? (
574
+ <Inherited from={(d?.notify_body?.sv ?? "").slice(0, 60)} />
575
+ ) : null}
576
+ </InspectorSection>
563
577
 
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">
578
+ <InspectorSection title="Conditional routing">
579
+ <p className="text-xs text-ink/50">
580
+ Send this form to different people depending on the answers. Rules add recipients; a
581
+ rule marked “only these” replaces the addresses above.
582
+ </p>
583
+ {(priv.notificationRules ?? []).map((rule, i) => (
584
+ <div key={i} className="space-y-2 border border-ink/10 p-3">
585
+ <div className="flex flex-wrap items-center gap-2">
588
586
  <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"
587
+ value={rule.to}
588
+ placeholder="who@example.com"
589
+ onChange={(v) => patchRule(i, { to: v.target.value })}
590
+ className="min-w-[180px] flex-1 border border-ink/15 px-2 py-1 text-xs"
593
591
  />
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>
592
+ <label className="flex items-center gap-1.5 text-[0.65rem] uppercase tracking-[0.16em] text-ink/55">
593
+ <input
594
+ type="checkbox"
595
+ checked={rule.exclusive ?? false}
596
+ onChange={(e) => patchRule(i, { exclusive: e.target.checked })}
597
+ className="size-3.5 accent-sea"
598
+ />
599
+ Only these
600
+ </label>
601
+ <label className="flex items-center gap-1.5 text-[0.65rem] uppercase tracking-[0.16em] text-ink/55">
602
+ <input
603
+ type="checkbox"
604
+ checked={rule.enabled !== false}
605
+ onChange={(e) => patchRule(i, { enabled: e.target.checked })}
606
+ className="size-3.5 accent-sea"
607
+ />
608
+ Active
609
+ </label>
610
+ <button
611
+ type="button"
612
+ onClick={() =>
613
+ setPriv({
614
+ ...priv,
615
+ notificationRules: (priv.notificationRules ?? []).filter(
616
+ (_, idx) => idx !== i,
617
+ ),
618
+ })
619
+ }
620
+ className="text-[0.65rem] uppercase tracking-[0.16em] text-ink/45 hover:text-red-500"
621
+ >
622
+ Remove
623
+ </button>
624
+ </div>
625
+ <ConditionBuilder
626
+ title="When"
627
+ description="Rule applies while these answers match."
628
+ rule={rule.when}
629
+ fields={activeFields}
630
+ onChange={(r) => patchRule(i, { when: r })}
631
+ />
610
632
  </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) =>
633
+ ))}
634
+ <button
635
+ type="button"
636
+ onClick={() =>
655
637
  setPriv({
656
638
  ...priv,
657
- autoreplyEnabled: e.target.value === "" ? undefined : e.target.value === "on",
639
+ notificationRules: [
640
+ ...(priv.notificationRules ?? []),
641
+ {
642
+ id:
643
+ typeof crypto !== "undefined" && crypto.randomUUID
644
+ ? crypto.randomUUID()
645
+ : String(Date.now()),
646
+ to: "",
647
+ enabled: true,
648
+ exclusive: false,
649
+ },
650
+ ],
658
651
  })
659
652
  }
660
- className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
653
+ className="border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
661
654
  >
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
- />
655
+ Add routing rule
656
+ </button>
657
+ </InspectorSection>
685
658
 
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>
659
+ <InspectorSection title="Autoreply to the sender">
660
+ <label className="block">
661
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
662
+ Autoreply
663
+ </span>
664
+ <select
665
+ value={
666
+ priv.autoreplyEnabled === undefined ? "" : priv.autoreplyEnabled ? "on" : "off"
667
+ }
668
+ onChange={(e) =>
669
+ setPriv({
670
+ ...priv,
671
+ autoreplyEnabled: e.target.value === "" ? undefined : e.target.value === "on",
672
+ })
673
+ }
674
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
675
+ >
676
+ <option value="">
677
+ Use shared default ({d?.autoreply_enabled ? "on" : "off"})
678
+ </option>
679
+ <option value="on">On</option>
680
+ <option value="off">Off</option>
681
+ </select>
682
+ </label>
683
+ <SmartTagLocalizedField
684
+ label="Subject"
685
+ value={priv.autoreplySubject}
686
+ fields={activeFields}
687
+ onChange={(v) => setPriv({ ...priv, autoreplySubject: v })}
688
+ />
689
+ {isInherited(priv.autoreplySubject) ? (
690
+ <Inherited from={d?.autoreply_subject?.sv ?? ""} />
691
+ ) : null}
692
+ <SmartTagLocalizedField
693
+ label="Body"
694
+ value={priv.autoreplyBody}
695
+ fields={activeFields}
696
+ multiline
697
+ onChange={(v) => setPriv({ ...priv, autoreplyBody: v })}
698
+ />
700
699
 
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"
700
+ {isInherited(priv.autoreplyBody) ? (
701
+ <Inherited from={(d?.autoreply_body?.sv ?? "").slice(0, 60)} />
702
+ ) : null}
703
+ <p className="text-xs text-ink/45">
704
+ Use {"{{"}field_key{"}}"} to insert an answer, for example {"{{"}name{"}}"} or{" "}
705
+ {"{{"}email{"}}"}. Available keys:{" "}
706
+ {fields
707
+ .filter((f) => f.enabled)
708
+ .map((f) => f.key)
709
+ .join(", ")}
710
+ .
711
+ </p>
712
+ </InspectorSection>
713
+ </div>
714
+
715
+ <div className="border border-ink/10 bg-white p-6 space-y-4">
716
+ <InspectorSection title="Google Sheets">
717
+ <TextField
718
+ label="Spreadsheet ID"
719
+ value={priv.sheetId ?? ""}
720
+ onChange={(v) => setPriv({ ...priv, sheetId: v })}
721
+ />
722
+ {isInherited(priv.sheetId) ? <Inherited from={d?.sheet_id ?? ""} /> : null}
723
+ <TextField
724
+ label="Tab name"
725
+ value={priv.sheetTab ?? ""}
726
+ onChange={(v) => setPriv({ ...priv, sheetTab: v })}
727
+ />
728
+ {isInherited(priv.sheetTab) ? <Inherited from={d?.sheet_tab ?? ""} /> : null}
729
+ <label className="block">
730
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
731
+ Append every new submission live
732
+ </span>
733
+ <select
734
+ value={
735
+ priv.sheetLiveAppend === undefined ? "" : priv.sheetLiveAppend ? "on" : "off"
736
+ }
737
+ onChange={(e) =>
738
+ setPriv({
739
+ ...priv,
740
+ sheetLiveAppend: e.target.value === "" ? undefined : e.target.value === "on",
741
+ })
742
+ }
743
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
744
+ >
745
+ <option value="">
746
+ Use shared default ({d?.sheet_live_append ? "on" : "off"})
747
+ </option>
748
+ <option value="on">On</option>
749
+ <option value="off">Off</option>
750
+ </select>
751
+ </label>
752
+ {(priv.sheetLiveAppend ?? d?.sheet_live_append) &&
753
+ !(priv.sheetId ?? d?.sheet_id ?? "").trim() ? (
754
+ <p className="text-xs text-red-600">
755
+ Live append does nothing until a spreadsheet ID is set here or in the shared
756
+ settings.
757
+ </p>
758
+ ) : null}
759
+ <button
760
+ onClick={async () => {
761
+ try {
762
+ const res = await syncSubmissions({ data: { formKey: form.key } });
763
+ toast.success(`Synced ${res.synced} submission(s)`);
764
+ await qc.invalidateQueries({ queryKey: ["form-submissions"] });
765
+ } catch (e) {
766
+ toast.error(e instanceof Error ? e.message : "Sync failed");
767
+ }
768
+ }}
769
+ className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea"
730
770
  >
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.
771
+ <RefreshCw className="size-4" />
772
+ Sync pending submissions now
773
+ </button>
774
+ <p className="text-xs text-ink/45">
775
+ Submissions are always stored first — a Sheets outage never loses data.
743
776
  </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>
777
+ </InspectorSection>
778
+ </div>
765
779
 
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"
780
+ <div className="border border-ink/10 bg-white p-6 space-y-4">
781
+ <InspectorSection title="Webhook">
782
+ <label className="flex items-center gap-3 text-sm text-ink/70">
783
+ <input
784
+ type="checkbox"
785
+ checked={priv.webhookEnabled ?? false}
786
+ onChange={(e) => setPriv({ ...priv, webhookEnabled: e.target.checked })}
787
+ className="size-4 accent-sea"
788
+ />
789
+ POST every new submission to an external endpoint
790
+ </label>
791
+ <TextField
792
+ label="Endpoint URL"
793
+ value={priv.webhookUrl ?? ""}
794
+ onChange={(v) => setPriv({ ...priv, webhookUrl: v })}
774
795
  />
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>
796
+ <TextField
797
+ label="Signing secret (optional)"
798
+ value={priv.webhookSecret ?? ""}
799
+ onChange={(v) => setPriv({ ...priv, webhookSecret: v })}
800
+ />
801
+ <button
802
+ onClick={async () => {
803
+ try {
804
+ const res = await testWebhook({
805
+ data: {
806
+ formKey: form.key,
807
+ url: priv.webhookUrl || undefined,
808
+ secret: priv.webhookSecret || undefined,
809
+ },
810
+ });
811
+ if (res.ok) toast.success(`Webhook responded ${res.status ?? 200}`);
812
+ else
813
+ toast.error(res.error ?? `Webhook failed (${res.status ?? "no response"})`);
814
+ await qc.invalidateQueries({ queryKey: ["form-deliveries"] });
815
+ } catch (e) {
816
+ toast.error(e instanceof Error ? e.message : "Webhook test failed");
817
+ }
818
+ }}
819
+ className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea"
820
+ >
821
+ <RefreshCw className="size-4" />
822
+ Send test payload
823
+ </button>
824
+ <p className="text-xs text-ink/45">
825
+ Requests are signed with HMAC-SHA256 in the <code>X-Signature</code> header. Failed
826
+ calls appear in the delivery monitor and can be retried there.
827
+ </p>
828
+ <Link
829
+ to={paths.deliveries}
830
+ className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-ink/50 hover:text-ink"
831
+ >
832
+ <ExternalLink className="size-4" />
833
+ Delivery monitor
834
+ </Link>
835
+ </InspectorSection>
836
+ </div>
822
837
  </div>
823
- </div>
824
- ) : null}
838
+ ) : null}
825
839
 
826
- {tab === "history" ? (
827
- <div className="mt-6">
828
- <VersionHistory formKey={form.key} defaults={d ?? null} />
829
- </div>
830
- ) : null}
840
+ {tab === "history" ? (
841
+ <div className="mt-6">
842
+ <VersionHistory
843
+ formKey={form.key}
844
+ defaults={d ?? null}
845
+ editSessionId={lock.sessionId}
846
+ />
847
+ </div>
848
+ ) : null}
831
849
 
832
- {tab === "activity" ? (
833
- <div className="mt-6">
834
- <ActivityLog formKey={form.key} />
835
- </div>
836
- ) : null}
850
+ {tab === "activity" ? (
851
+ <div className="mt-6">
852
+ <ActivityLog formKey={form.key} />
853
+ </div>
854
+ ) : null}
855
+ </EditLockFence>
837
856
  </div>
838
857
  );
839
858
  }