@bygghammar/cms-platform 0.3.1 → 0.5.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,590 @@
1
+ import type { LocalizedString } from "../i18n";
2
+ import { tripNumber } from "../trips";
3
+ import { getTripTerminology } from "../trips";
4
+
5
+ export type FormFieldType =
6
+ | "text"
7
+ | "email"
8
+ | "tel"
9
+ | "url"
10
+ | "textarea"
11
+ | "select"
12
+ | "trip"
13
+ /** @deprecated Stored forms may still contain this legacy type. */
14
+ | "course"
15
+ | "checkbox"
16
+ | "date"
17
+ | "time"
18
+ | "number";
19
+
20
+ /**
21
+ * Field types offered by the form builder. A function, not a constant, so the
22
+ * trip label reflects the terminology the host site registered at startup.
23
+ */
24
+ export function fieldTypes(): { value: FormFieldType; label: string }[] {
25
+ const trip = getTripTerminology();
26
+ return [
27
+ { value: "text", label: "Short text" },
28
+ { value: "textarea", label: "Long text" },
29
+ { value: "email", label: "Email" },
30
+ { value: "tel", label: "Phone" },
31
+ { value: "url", label: "URL" },
32
+ { value: "number", label: "Number" },
33
+ { value: "date", label: "Date" },
34
+ { value: "time", label: "Time" },
35
+ { value: "select", label: "Dropdown" },
36
+ {
37
+ value: "trip",
38
+ label: `${trip.singular} (from ${trip.plural})`,
39
+ },
40
+ { value: "checkbox", label: "Checkbox" },
41
+ ];
42
+ }
43
+
44
+ /** One trip row, displayed with site-specific terminology. */
45
+ export type TripSource = {
46
+ id?: string;
47
+ year?: string;
48
+ tripNo?: Partial<LocalizedString>;
49
+ /** @deprecated Compatibility with data saved before the trips module. */
50
+ courseNo?: Partial<LocalizedString>;
51
+ location?: Partial<LocalizedString>;
52
+ dates?: Partial<LocalizedString>;
53
+ };
54
+
55
+ const loc = (v: Partial<LocalizedString> | undefined, locale: string): string =>
56
+ (v?.[locale as keyof LocalizedString] as string | undefined) ?? v?.sv ?? v?.en ?? "";
57
+
58
+ /** Distinct years present in the Trips section, newest first. */
59
+ export function tripYears(items: TripSource[]): string[] {
60
+ const years = new Set<string>();
61
+ for (const c of items) if (c.year) years.add(String(c.year));
62
+ return [...years].sort().reverse();
63
+ }
64
+
65
+ /**
66
+ * Build dropdown choices from the Courses section (single source of truth).
67
+ * Value is the readable course name including the year
68
+ * ("Kurs 1, Stockholm–Tallinn 2027") so stored answers stay unambiguous even after
69
+ * the course list changes and course numbers repeat across years.
70
+ */
71
+ export function tripFieldOptions(items: TripSource[], year?: string): FormFieldOption[] {
72
+ const filtered = year ? items.filter((c) => String(c.year ?? "") === String(year)) : items;
73
+ return filtered.map((c, i) => {
74
+ const build = (locale: string) => {
75
+ const no = loc(tripNumber(c), locale);
76
+ const place = loc(c.location, locale);
77
+ const dates = loc(c.dates, locale);
78
+ // Prefer the explicit year; otherwise read a 4-digit year off the dates.
79
+ const y = String(c.year ?? "").trim() || (dates.match(/\b(20\d{2})\b/)?.[1] ?? "");
80
+ const base = [no, place].filter(Boolean).join(", ") || `#${i + 1}`;
81
+ const name = y && !base.includes(y) ? `${base} ${y}` : base;
82
+ return { name, label: dates ? `${name} — ${dates}` : name };
83
+ };
84
+ const sv = build("sv");
85
+ const en = build("en");
86
+ return { value: sv.name, label: { sv: sv.label, en: en.label } };
87
+ });
88
+ }
89
+
90
+ /** @deprecated Use TripSource. */
91
+ export type CourseSource = TripSource;
92
+ /** @deprecated Use tripYears. */
93
+ export const courseYears = tripYears;
94
+ /** @deprecated Use tripFieldOptions. */
95
+ export const courseFieldOptions = tripFieldOptions;
96
+
97
+ export type FormFieldOption = {
98
+ value: string;
99
+ label: Partial<LocalizedString>;
100
+ };
101
+
102
+ /** One comparison against another field's current answer. */
103
+ export type FieldCondition = {
104
+ /** Key of the field being compared. */
105
+ field: string;
106
+ op:
107
+ | "equals"
108
+ | "not_equals"
109
+ | "contains"
110
+ | "not_contains"
111
+ | "starts_with"
112
+ | "gt"
113
+ | "lt"
114
+ | "is_empty"
115
+ | "is_not_empty"
116
+ | "checked"
117
+ | "unchecked";
118
+ value?: string;
119
+ };
120
+
121
+ /** A set of conditions combined with all/any. Empty = always true. */
122
+ export type ConditionRule = {
123
+ mode: "all" | "any";
124
+ conditions: FieldCondition[];
125
+ };
126
+
127
+ export type FormFieldDef = {
128
+ /** Stable identifier — never renamed, submissions are keyed by it. */
129
+ key: string;
130
+ type: FormFieldType;
131
+ label: Partial<LocalizedString>;
132
+ placeholder?: Partial<LocalizedString>;
133
+ help?: Partial<LocalizedString>;
134
+ enabled: boolean;
135
+ required: boolean;
136
+ /** Full width in the two-column grid. */
137
+ wide?: boolean;
138
+ options?: FormFieldOption[];
139
+ /** True for editor-created fields that are not part of the canonical set. */
140
+ custom?: boolean;
141
+ /**
142
+ * Built-in field the editor removed. The key is kept as a tombstone so old
143
+ * submissions still resolve a label; the field never renders on the site.
144
+ */
145
+ removed?: boolean;
146
+ /** Show this field only when the rule matches. Empty = always shown. */
147
+ showWhen?: ConditionRule;
148
+ /** Make this field required only when the rule matches. */
149
+ requireWhen?: ConditionRule;
150
+ /** Validation. */
151
+ minLength?: number;
152
+ maxLength?: number;
153
+ /** Numeric bounds (type "number"). */
154
+ min?: number;
155
+ max?: number;
156
+ /** Date bounds as YYYY-MM-DD (type "date"). */
157
+ minDate?: string;
158
+ maxDate?: string;
159
+ /** Named format preset, see FIELD_FORMATS in ./conditions. */
160
+ format?: string;
161
+ pattern?: string;
162
+ /** Overrides every generated validation message for this field. */
163
+ errorMessage?: Partial<LocalizedString>;
164
+ /**
165
+ * Browser autofill token (HTML `autocomplete`). Undefined = automatic,
166
+ * resolved from the field key and type. "off" disables autofill.
167
+ */
168
+ autocomplete?: string;
169
+ /** Type "trip": only show trips of this year. Empty = all years. */
170
+ tripYear?: string;
171
+ /** @deprecated Compatibility with saved course fields. */
172
+ courseYear?: string;
173
+ };
174
+
175
+ /** Autofill tokens offered in the form editor. */
176
+ export const AUTOCOMPLETE_OPTIONS: { value: string; label: string }[] = [
177
+ { value: "off", label: "Off" },
178
+ { value: "name", label: "Full name" },
179
+ { value: "given-name", label: "Given name" },
180
+ { value: "family-name", label: "Family name" },
181
+ { value: "email", label: "Email" },
182
+ { value: "tel", label: "Phone" },
183
+ { value: "organization", label: "Organisation" },
184
+ { value: "organization-title", label: "Job title" },
185
+ { value: "street-address", label: "Street address" },
186
+ { value: "postal-code", label: "Postal code" },
187
+ { value: "address-level2", label: "City" },
188
+ { value: "country-name", label: "Country" },
189
+ { value: "bday", label: "Birthday" },
190
+ { value: "url", label: "Website" },
191
+ ];
192
+
193
+ /** Canonical field key -> autofill token. */
194
+ const DEFAULT_AUTOCOMPLETE: Record<string, string> = {
195
+ firstName: "given-name",
196
+ lastName: "family-name",
197
+ name: "name",
198
+ fullName: "name",
199
+ email: "email",
200
+ phone: "tel",
201
+ tel: "tel",
202
+ mobile: "tel",
203
+ company: "organization",
204
+ organization: "organization",
205
+ jobTitle: "organization-title",
206
+ address: "street-address",
207
+ street: "street-address",
208
+ postalCode: "postal-code",
209
+ zip: "postal-code",
210
+ city: "address-level2",
211
+ country: "country-name",
212
+ website: "url",
213
+ birthday: "bday",
214
+ };
215
+
216
+ const TYPE_AUTOCOMPLETE: Partial<Record<FormFieldType, string>> = {
217
+ email: "email",
218
+ tel: "tel",
219
+ url: "url",
220
+ };
221
+
222
+ /**
223
+ * Resolve the `autocomplete` attribute for a field:
224
+ * explicit setting -> key default -> type default -> "off".
225
+ */
226
+ export function resolveAutocomplete(f: FormFieldDef): string {
227
+ if (f.autocomplete) return f.autocomplete;
228
+ if (f.type === "checkbox" || f.type === "textarea") return "off";
229
+ return DEFAULT_AUTOCOMPLETE[f.key] ?? TYPE_AUTOCOMPLETE[f.type] ?? "off";
230
+ }
231
+
232
+ export type FormType = "contact" | "booking" | "interest" | "newsletter" | "callback";
233
+
234
+ export const FORM_TYPES: { value: FormType; label: string; description: string }[] = [
235
+ { value: "contact", label: "Contact", description: "Name, email, phone and a message." },
236
+ {
237
+ value: "booking",
238
+ label: "Booking request",
239
+ description: "Contact details plus course, date and participants.",
240
+ },
241
+ {
242
+ value: "interest",
243
+ label: "Interest / lead",
244
+ description: "Short lead capture with a free-text note.",
245
+ },
246
+ { value: "newsletter", label: "Newsletter", description: "Email only (plus optional name)." },
247
+ { value: "callback", label: "Call me back", description: "Name, phone and preferred time." },
248
+ ];
249
+
250
+ const f = (
251
+ key: string,
252
+ type: FormFieldType,
253
+ sv: string,
254
+ en: string,
255
+ extra: Partial<FormFieldDef> = {},
256
+ ): FormFieldDef => ({
257
+ key,
258
+ type,
259
+ label: { sv, en },
260
+ enabled: true,
261
+ required: false,
262
+ ...extra,
263
+ });
264
+
265
+ /** Fixed field sets per form type. Keys are stable and must never change. */
266
+ export const FIELD_SETS: Record<FormType, FormFieldDef[]> = {
267
+ contact: [
268
+ f("firstName", "text", "Förnamn", "First name", { required: true }),
269
+ f("lastName", "text", "Efternamn", "Last name", { required: true }),
270
+ f("email", "email", "E-post", "Email", { required: true, wide: true }),
271
+ f("phone", "tel", "Telefon", "Phone", { wide: true }),
272
+ f("message", "textarea", "Meddelande", "Message", { wide: true }),
273
+ ],
274
+ booking: [
275
+ f("firstName", "text", "Förnamn", "First name", { required: true }),
276
+ f("lastName", "text", "Efternamn", "Last name", { required: true }),
277
+ f("email", "email", "E-post", "Email", { required: true }),
278
+ f("phone", "tel", "Telefon", "Phone", { required: true }),
279
+ f("course", "trip", "Kryssning", "Cruise", { wide: true }),
280
+ f("startDate", "date", "Önskat startdatum", "Preferred start date"),
281
+ f("participants", "number", "Antal deltagare", "Number of participants"),
282
+ f("message", "textarea", "Övrigt", "Anything else", { wide: true }),
283
+ ],
284
+ interest: [
285
+ f("name", "text", "Namn", "Name", { required: true }),
286
+ f("email", "email", "E-post", "Email", { required: true }),
287
+ f("phone", "tel", "Telefon", "Phone"),
288
+ f("message", "textarea", "Berätta kort om dig", "Tell us briefly about yourself", {
289
+ wide: true,
290
+ }),
291
+ ],
292
+ newsletter: [
293
+ f("name", "text", "Namn", "Name"),
294
+ f("email", "email", "E-post", "Email", { required: true, wide: true }),
295
+ ],
296
+ callback: [
297
+ f("name", "text", "Namn", "Name", { required: true }),
298
+ f("phone", "tel", "Telefon", "Phone", { required: true }),
299
+ f("preferredTime", "text", "Önskad tid", "Preferred time", { wide: true }),
300
+ f("message", "textarea", "Meddelande", "Message", { wide: true }),
301
+ ],
302
+ };
303
+
304
+ export function defaultFieldsFor(type: string): FormFieldDef[] {
305
+ const set = FIELD_SETS[(type as FormType) in FIELD_SETS ? (type as FormType) : "contact"];
306
+ return set.map((x) => ({ ...x, label: { ...x.label } }));
307
+ }
308
+
309
+ const VALID_TYPES = new Set<string>([...fieldTypes().map((type) => type.value), "course"]);
310
+
311
+ const VALID_OPS = new Set([
312
+ "equals",
313
+ "not_equals",
314
+ "contains",
315
+ "not_contains",
316
+ "starts_with",
317
+ "gt",
318
+ "lt",
319
+ "is_empty",
320
+ "is_not_empty",
321
+ "checked",
322
+ "unchecked",
323
+ ]);
324
+
325
+ /** Drop malformed conditions so a bad save can never break a live form. */
326
+ function normalizeRule(r: unknown): ConditionRule | undefined {
327
+ if (!r || typeof r !== "object") return undefined;
328
+ const raw = r as Partial<ConditionRule>;
329
+ const conditions = (Array.isArray(raw.conditions) ? raw.conditions : [])
330
+ .filter(
331
+ (c): c is FieldCondition =>
332
+ !!c && typeof c.field === "string" && c.field.length > 0 && VALID_OPS.has(c.op),
333
+ )
334
+ .map((c) => ({ field: c.field, op: c.op, value: c.value ?? "" }));
335
+ if (conditions.length === 0) return undefined;
336
+ return { mode: raw.mode === "any" ? "any" : "all", conditions };
337
+ }
338
+
339
+ function normalizeStored(s: Partial<FormFieldDef>): Partial<FormFieldDef> {
340
+ const out: Partial<FormFieldDef> = { ...s };
341
+ if (out.type && !VALID_TYPES.has(out.type)) delete out.type;
342
+ if (Array.isArray(out.options)) {
343
+ out.options = out.options
344
+ .filter((o) => o && typeof o.value === "string")
345
+ .map((o) => ({ value: o.value, label: { ...(o.label ?? {}) } }));
346
+ }
347
+ if (typeof out.autocomplete === "string") {
348
+ const token = out.autocomplete.trim();
349
+ out.autocomplete =
350
+ token && AUTOCOMPLETE_OPTIONS.some((o) => o.value === token) ? token : undefined;
351
+ } else if (out.autocomplete !== undefined) {
352
+ delete out.autocomplete;
353
+ }
354
+ out.showWhen = normalizeRule(out.showWhen);
355
+ out.requireWhen = normalizeRule(out.requireWhen);
356
+ return out;
357
+ }
358
+
359
+ /**
360
+ * Merge stored field config over the canonical field set.
361
+ *
362
+ * - Canonical fields keep their key/type from code; the editor may override
363
+ * label, placeholder, help, enabled, required, width, options, conditional
364
+ * logic and validation rules.
365
+ * - Editor-created custom fields are appended (they only exist in storage).
366
+ * - The stored order decides the render order; unknown/new canonical fields
367
+ * fall back to their position in the canonical set.
368
+ *
369
+ * Keys are never rewritten, so historical submissions always stay readable.
370
+ */
371
+ export function mergeFields(type: string, stored: unknown): FormFieldDef[] {
372
+ const base = defaultFieldsFor(type);
373
+ if (!Array.isArray(stored)) return base;
374
+
375
+ const storedList = (stored as Partial<FormFieldDef>[])
376
+ .filter((s) => s && typeof s.key === "string" && s.key.length > 0)
377
+ .map(normalizeStored);
378
+
379
+ const byKey = new Map<string, Partial<FormFieldDef>>();
380
+ for (const s of storedList) byKey.set(s.key as string, s);
381
+
382
+ const merged: FormFieldDef[] = base.map((b) => {
383
+ const s = byKey.get(b.key);
384
+ if (!s) return b;
385
+ if (s.removed === true) return { ...b, removed: true, enabled: false };
386
+ return {
387
+ ...b,
388
+ enabled: typeof s.enabled === "boolean" ? s.enabled : b.enabled,
389
+ required: typeof s.required === "boolean" ? s.required : b.required,
390
+ wide: typeof s.wide === "boolean" ? s.wide : b.wide,
391
+ label: { ...b.label, ...(s.label ?? {}) },
392
+ placeholder: { ...(b.placeholder ?? {}), ...(s.placeholder ?? {}) },
393
+ help: { ...(b.help ?? {}), ...(s.help ?? {}) },
394
+ options: s.options?.length ? s.options : b.options,
395
+ showWhen: s.showWhen ?? b.showWhen,
396
+ requireWhen: s.requireWhen ?? b.requireWhen,
397
+ minLength: s.minLength ?? b.minLength,
398
+ maxLength: s.maxLength ?? b.maxLength,
399
+ min: s.min ?? b.min,
400
+ max: s.max ?? b.max,
401
+ minDate: s.minDate ?? b.minDate,
402
+ maxDate: s.maxDate ?? b.maxDate,
403
+ format: s.format ?? b.format,
404
+ pattern: s.pattern ?? b.pattern,
405
+ autocomplete: s.autocomplete ?? b.autocomplete,
406
+ courseYear: s.courseYear ?? b.courseYear,
407
+ errorMessage: { ...(b.errorMessage ?? {}), ...(s.errorMessage ?? {}) },
408
+ };
409
+ });
410
+
411
+ const baseKeys = new Set(base.map((b) => b.key));
412
+ for (const s of storedList) {
413
+ const key = s.key as string;
414
+ if (baseKeys.has(key)) continue;
415
+ if (s.removed === true) continue;
416
+ merged.push({
417
+ key,
418
+ type: (s.type ?? "text") as FormFieldType,
419
+ label: { ...(s.label ?? {}) },
420
+ placeholder: { ...(s.placeholder ?? {}) },
421
+ help: { ...(s.help ?? {}) },
422
+ enabled: s.enabled !== false,
423
+ required: s.required === true,
424
+ wide: s.wide === true,
425
+ options: s.options ?? [],
426
+ custom: true,
427
+ showWhen: s.showWhen,
428
+ requireWhen: s.requireWhen,
429
+ minLength: s.minLength,
430
+ maxLength: s.maxLength,
431
+ min: s.min,
432
+ max: s.max,
433
+ minDate: s.minDate,
434
+ maxDate: s.maxDate,
435
+ format: s.format,
436
+ pattern: s.pattern,
437
+ autocomplete: s.autocomplete,
438
+ courseYear: s.courseYear,
439
+ errorMessage: { ...(s.errorMessage ?? {}) },
440
+ });
441
+ }
442
+
443
+ const order = new Map<string, number>();
444
+ storedList.forEach((s, i) => order.set(s.key as string, i));
445
+ return merged
446
+ .map((fld, i) => ({ fld, i, o: order.get(fld.key) ?? 1000 + i }))
447
+ .sort((a, b) => a.o - b.o || a.i - b.i)
448
+ .map((x) => x.fld);
449
+ }
450
+
451
+ /** Suggest a stable, unique key for a new custom field. */
452
+ export function makeFieldKey(label: string, taken: Iterable<string>): string {
453
+ const used = new Set(taken);
454
+ const base =
455
+ label
456
+ .toLowerCase()
457
+ .normalize("NFD")
458
+ .replace(/[\u0300-\u036f]/g, "")
459
+ .replace(/[^a-z0-9]+/g, "_")
460
+ .replace(/^_+|_+$/g, "")
461
+ .slice(0, 40) || "field";
462
+ if (!used.has(base)) return base;
463
+ let n = 2;
464
+ while (used.has(`${base}_${n}`)) n += 1;
465
+ return `${base}_${n}`;
466
+ }
467
+
468
+ /** ---- Settings shapes (shared defaults + per-form overrides) ---- */
469
+
470
+ export type FormPublicSettings = {
471
+ title?: Partial<LocalizedString>;
472
+ intro?: Partial<LocalizedString>;
473
+ submitLabel?: Partial<LocalizedString>;
474
+ consentText?: Partial<LocalizedString>;
475
+ requireConsent?: boolean;
476
+ thankyouMode?: "inline" | "redirect";
477
+ thankyouMessage?: Partial<LocalizedString>;
478
+ thankyouRedirect?: string;
479
+ };
480
+
481
+ /**
482
+ * Extra notification recipients that only apply when the answers match.
483
+ * Example: route "Företag = ja" leads to the sales inbox.
484
+ */
485
+ export type NotificationRule = {
486
+ id: string;
487
+ label?: string;
488
+ to: string;
489
+ /** Undefined means the rule always applies. */
490
+ when?: ConditionRule;
491
+
492
+ /** Skip the normal recipients when this rule matches. */
493
+ exclusive?: boolean;
494
+ enabled?: boolean;
495
+ };
496
+
497
+ export type FormPrivateSettings = {
498
+ notifyTo?: string;
499
+ /** Per-form notification override. undefined = inherit shared defaults. */
500
+ notifyEnabled?: boolean;
501
+ notifySubject?: Partial<LocalizedString>;
502
+ notifyBody?: Partial<LocalizedString>;
503
+ notifyFromName?: string;
504
+ notifyFromEmail?: string;
505
+ notifyReplyTo?: string;
506
+ /** Conditional routing on top of the normal recipients. */
507
+ notificationRules?: NotificationRule[];
508
+ sheetId?: string;
509
+ sheetTab?: string;
510
+ sheetLiveAppend?: boolean;
511
+ /** Per-form autoreply override. undefined = inherit shared defaults. */
512
+ autoreplyEnabled?: boolean;
513
+ autoreplySubject?: Partial<LocalizedString>;
514
+ autoreplyBody?: Partial<LocalizedString>;
515
+ /** Per-form spam override. undefined = inherit shared defaults. */
516
+ spamMinSeconds?: number;
517
+ spamMaxPerHour?: number;
518
+ /**
519
+ * Keep a draft of what the visitor typed even if they never submit.
520
+ * undefined = inherit the shared default (which ships as on).
521
+ */
522
+ savePartials?: boolean;
523
+ /** Optional external endpoint that receives every submission as JSON. */
524
+ webhookUrl?: string;
525
+ /** Shared secret used to sign the webhook body (X-Signature, HMAC-SHA256). */
526
+ webhookSecret?: string;
527
+ webhookEnabled?: boolean;
528
+ };
529
+
530
+ /** Fields a form can inherit from the shared defaults. */
531
+ export function isInherited(v: unknown): boolean {
532
+ if (v === undefined || v === null) return true;
533
+ if (typeof v === "string") return v.trim() === "";
534
+ if (typeof v === "object") {
535
+ return Object.values(v as Record<string, unknown>).every(
536
+ (x) => typeof x !== "string" || x.trim() === "",
537
+ );
538
+ }
539
+ return false;
540
+ }
541
+
542
+ export type FormDefaults = {
543
+ notify_to: string;
544
+ notify_enabled: boolean;
545
+ notify_subject: Partial<LocalizedString>;
546
+ notify_body: Partial<LocalizedString>;
547
+ notify_from_name: string;
548
+ notify_from_email: string;
549
+ notify_reply_to: string;
550
+ autoreply_enabled: boolean;
551
+ autoreply_subject: Partial<LocalizedString>;
552
+ autoreply_body: Partial<LocalizedString>;
553
+ spam_honeypot: boolean;
554
+ spam_min_seconds: number;
555
+ spam_max_per_hour: number;
556
+ thankyou_mode: string;
557
+ thankyou_message: Partial<LocalizedString>;
558
+ thankyou_redirect: string;
559
+ consent_text: Partial<LocalizedString>;
560
+ /** Shared target for `[text](privacy)` links in consent wording. */
561
+ privacy_policy_url: string;
562
+ retention_days: number;
563
+ sheet_id: string;
564
+ sheet_tab: string;
565
+ sheet_live_append: boolean;
566
+ save_partials: boolean;
567
+ };
568
+
569
+ /** Public, render-time view of a managed form. */
570
+ export type PublicForm = {
571
+ id: string;
572
+ key: string;
573
+ name: string;
574
+ form_type: string;
575
+ fields: FormFieldDef[];
576
+ title?: Partial<LocalizedString>;
577
+ intro?: Partial<LocalizedString>;
578
+ submitLabel: Partial<LocalizedString>;
579
+ consentText: Partial<LocalizedString>;
580
+ /** Resolved URL used by `[text](privacy)` in the consent text. */
581
+ privacyPolicyUrl: string;
582
+ requireConsent: boolean;
583
+ thankyouMode: "inline" | "redirect";
584
+ thankyouMessage: Partial<LocalizedString>;
585
+ thankyouRedirect: string;
586
+ honeypot: boolean;
587
+ minSeconds: number;
588
+ /** Store part-filled answers as the visitor types. */
589
+ savePartials: boolean;
590
+ };