@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.
- package/CHANGELOG.md +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- 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
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
347
|
+
<TestDeliveryPanel formKey={form.key} fields={activeFields} priv={priv} />
|
|
314
348
|
</div>
|
|
315
349
|
</div>
|
|
316
|
-
|
|
317
|
-
) : null}
|
|
350
|
+
) : null}
|
|
318
351
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
>
|
|
402
|
-
|
|
403
|
-
<
|
|
404
|
-
|
|
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
|
-
|
|
430
|
-
|
|
395
|
+
{isInherited(pub.consentText) ? <Inherited from={d?.consent_text?.sv ?? ""} /> : null}
|
|
396
|
+
</InspectorSection>
|
|
397
|
+
</div>
|
|
431
398
|
|
|
432
|
-
<
|
|
433
|
-
<
|
|
434
|
-
label
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
<
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
) : null}
|
|
498
|
+
) : null}
|
|
485
499
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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="
|
|
520
|
-
value={priv.
|
|
545
|
+
label="Reply-to"
|
|
546
|
+
value={priv.notifyReplyTo ?? ""}
|
|
521
547
|
fields={activeFields}
|
|
522
|
-
|
|
548
|
+
placeholder="{{email}}"
|
|
549
|
+
onChange={(v) => setPriv({ ...priv, notifyReplyTo: v })}
|
|
523
550
|
/>
|
|
524
|
-
<
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
onChange={(
|
|
592
|
-
className="
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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="
|
|
653
|
+
className="border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
|
|
661
654
|
>
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
687
|
-
<
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
value={
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
<
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
745
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
<
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
824
|
-
) : null}
|
|
838
|
+
) : null}
|
|
825
839
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
}
|