@bygghammar/cms-platform 0.27.0 → 0.28.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,436 @@
1
+ import type React from "react";
2
+ import { useEffect, useState } from "react";
3
+ import { toast } from "sonner";
4
+ import { ArrowLeft, Save } from "lucide-react";
5
+ import type { FormDefaults } from "../forms/field-sets";
6
+ import { InspectorSection, LocalizedField, TextField } from "./inspectors/fields";
7
+ import { SmartTagTextField, SmartTagLocalizedField } from "./forms/SmartTags";
8
+ import { POLICY_WORDS, normalizePolicyUrl, renderLinkedText } from "../forms/rich-text";
9
+
10
+ /** Host-provided data access for the shared form settings screen. */
11
+ export type FormSettingsDeps = {
12
+ defaults: FormDefaults | undefined;
13
+ isLoading: boolean;
14
+ saveDefaults: (input: { data: Record<string, unknown> }) => Promise<unknown>;
15
+ purgeOldSubmissions: (input: {
16
+ data: { dryRun: boolean };
17
+ }) => Promise<{ matched: number; deleted: number; cutoff: string }>;
18
+ /** Locale previewed under the consent text; defaults to the first key present. */
19
+ previewLocale?: string;
20
+ /** Host router link back to the forms index. */
21
+ BackLink: (props: { className?: string; children: React.ReactNode }) => React.ReactElement;
22
+ };
23
+
24
+ /** Settings every form inherits; a form only overrides what it needs. */
25
+ export function FormSettingsView({ deps }: { deps: FormSettingsDeps }) {
26
+ const [s, setS] = useState<FormDefaults | null>(null);
27
+ const [busy, setBusy] = useState(false);
28
+ const [purgeInfo, setPurgeInfo] = useState<{
29
+ matched: number;
30
+ deleted: number;
31
+ cutoff: string;
32
+ } | null>(null);
33
+
34
+ useEffect(() => {
35
+ if (deps.defaults) setS(deps.defaults);
36
+ }, [deps.defaults]);
37
+
38
+ if (deps.isLoading || !s) return <div className="p-8 text-sm text-ink/50">Loading…</div>;
39
+
40
+ const patch = (p: Partial<FormDefaults>) => setS({ ...s, ...p });
41
+
42
+ const onSave = async () => {
43
+ if (s.thankyou_mode === "redirect" && !s.thankyou_redirect.trim()) {
44
+ toast.error("Add a redirect URL, or switch back to the thank-you message.");
45
+ return;
46
+ }
47
+ setBusy(true);
48
+ try {
49
+ await deps.saveDefaults({
50
+ data: {
51
+ notify_to: s.notify_to,
52
+ notify_enabled: s.notify_enabled,
53
+ notify_subject: s.notify_subject,
54
+ notify_body: s.notify_body,
55
+ notify_from_name: s.notify_from_name,
56
+ notify_from_email: s.notify_from_email,
57
+ notify_reply_to: s.notify_reply_to,
58
+
59
+ autoreply_enabled: s.autoreply_enabled,
60
+ autoreply_subject: s.autoreply_subject,
61
+ autoreply_body: s.autoreply_body,
62
+ spam_honeypot: s.spam_honeypot,
63
+ spam_min_seconds: s.spam_min_seconds,
64
+ spam_max_per_hour: s.spam_max_per_hour,
65
+ thankyou_mode: s.thankyou_mode,
66
+ thankyou_message: s.thankyou_message,
67
+ thankyou_redirect: s.thankyou_redirect,
68
+ consent_text: s.consent_text,
69
+ privacy_policy_url: s.privacy_policy_url ?? "",
70
+ retention_days: s.retention_days,
71
+ sheet_id: s.sheet_id,
72
+ sheet_tab: s.sheet_tab,
73
+ sheet_live_append: s.sheet_live_append,
74
+ save_partials: s.save_partials,
75
+ },
76
+ });
77
+ toast.success("Shared settings saved");
78
+ } catch (e) {
79
+ toast.error(e instanceof Error ? e.message : "Could not save");
80
+ } finally {
81
+ setBusy(false);
82
+ }
83
+ };
84
+
85
+ const consent = (s.consent_text ?? {}) as Record<string, string>;
86
+ const previewLocale = deps.previewLocale ?? Object.keys(consent)[0] ?? "sv";
87
+
88
+ return (
89
+ <div className="p-8 md:p-12 w-full">
90
+ <div className="flex flex-wrap items-center justify-between gap-4">
91
+ <div>
92
+ <deps.BackLink className="inline-flex items-center gap-2 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 hover:text-sea">
93
+ <ArrowLeft className="size-3.5" />
94
+ Back to forms
95
+ </deps.BackLink>
96
+ <h1 className="mt-3 font-serif text-2xl text-ink">Shared form settings</h1>
97
+ <p className="mt-2 text-sm text-ink/60">
98
+ Every form inherits these values. A form only overrides what it needs.
99
+ </p>
100
+ </div>
101
+ <button
102
+ onClick={onSave}
103
+ disabled={busy}
104
+ 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"
105
+ >
106
+ <Save className="size-4" />
107
+ Save
108
+ </button>
109
+ </div>
110
+
111
+ <div className="mt-8 grid gap-6 lg:grid-cols-2">
112
+ <div className="border border-ink/10 bg-white p-6 space-y-4">
113
+ <InspectorSection title="Incomplete submissions">
114
+ <label className="flex items-center gap-3 text-sm text-ink/70">
115
+ <input
116
+ type="checkbox"
117
+ checked={s.save_partials !== false}
118
+ onChange={(e) => patch({ save_partials: e.target.checked })}
119
+ className="size-4 accent-sea"
120
+ />
121
+ Save part-filled forms (drafts) by default on every form
122
+ </label>
123
+ <p className="text-xs text-ink/45">
124
+ Drafts are stored apart from real submissions and never overwrite them. A form can
125
+ override this in its own settings.
126
+ </p>
127
+ </InspectorSection>
128
+
129
+ <InspectorSection title="Notifications">
130
+ <label className="flex items-center gap-3 text-sm text-ink/70">
131
+ <input
132
+ type="checkbox"
133
+ checked={s.notify_enabled !== false}
134
+ onChange={(e) => patch({ notify_enabled: e.target.checked })}
135
+ className="size-4 accent-sea"
136
+ />
137
+ Email us when a form is submitted
138
+ </label>
139
+ <TextField
140
+ label="Default recipients (comma separated)"
141
+ value={s.notify_to}
142
+ onChange={(v) => patch({ notify_to: v })}
143
+ />
144
+ <div className="grid gap-3 sm:grid-cols-2">
145
+ <SmartTagTextField
146
+ label="Sender name (optional)"
147
+ value={s.notify_from_name ?? ""}
148
+ fields={[]}
149
+ onChange={(v) => patch({ notify_from_name: v })}
150
+ />
151
+ <TextField
152
+ label="Sender address (optional)"
153
+ value={s.notify_from_email ?? ""}
154
+ onChange={(v) => patch({ notify_from_email: v })}
155
+ />
156
+ </div>
157
+ <SmartTagTextField
158
+ label="Reply-to (optional, defaults to the sender's address)"
159
+ value={s.notify_reply_to ?? ""}
160
+ fields={[]}
161
+ placeholder="{{email}}"
162
+ onChange={(v) => patch({ notify_reply_to: v })}
163
+ />
164
+ <SmartTagLocalizedField
165
+ label="Subject (optional)"
166
+ value={s.notify_subject}
167
+ fields={[]}
168
+ onChange={(v) => patch({ notify_subject: v })}
169
+ />
170
+ <SmartTagLocalizedField
171
+ label="Body (optional — leave empty for the standard answer table)"
172
+ value={s.notify_body}
173
+ fields={[]}
174
+ multiline
175
+ onChange={(v) => patch({ notify_body: v })}
176
+ />
177
+ <p className="text-xs text-ink/50">
178
+ Use {"{{"}field_key{"}}"} for an answer, {"{{"}all_fields{"}}"} for the full table,
179
+ plus {"{{"}form{"}}"}, {"{{"}locale{"}}"}, {"{{"}page_url{"}}"}, {"{{"}ip{"}}"},{" "}
180
+ {"{{"}user_agent{"}}"} and {"{{"}admin_url{"}}"}. Sender name and reply-to accept the
181
+ same tags — {"{{"}email{"}}"} replies straight to the person who submitted. Sent
182
+ through the default provider configured in Admin → Email settings.
183
+ </p>
184
+ </InspectorSection>
185
+
186
+ <InspectorSection title="Spam protection">
187
+ <label className="flex items-center gap-3 text-sm text-ink/70">
188
+ <input
189
+ type="checkbox"
190
+ checked={s.spam_honeypot}
191
+ onChange={(e) => patch({ spam_honeypot: e.target.checked })}
192
+ className="size-4 accent-sea"
193
+ />
194
+ Honeypot field
195
+ </label>
196
+ <p className="text-xs text-ink/45">
197
+ Applies to every form — there is no per-form override.
198
+ </p>
199
+ <TextField
200
+ label="Minimum seconds before submit"
201
+ value={String(s.spam_min_seconds)}
202
+ onChange={(v) => patch({ spam_min_seconds: Number(v) || 0 })}
203
+ />
204
+ <TextField
205
+ label="Max submissions per hour (per form)"
206
+ value={String(s.spam_max_per_hour)}
207
+ onChange={(v) => patch({ spam_max_per_hour: Number(v) || 0 })}
208
+ />
209
+ <p className="text-xs text-ink/45">
210
+ Counted per visitor IP. When the IP cannot be determined the limit is skipped.
211
+ </p>
212
+ </InspectorSection>
213
+
214
+ <InspectorSection title="Autoreply to the sender">
215
+ <label className="flex items-center gap-3 text-sm text-ink/70">
216
+ <input
217
+ type="checkbox"
218
+ checked={s.autoreply_enabled}
219
+ onChange={(e) => patch({ autoreply_enabled: e.target.checked })}
220
+ className="size-4 accent-sea"
221
+ />
222
+ Send an automatic reply when an email address was submitted
223
+ </label>
224
+ <SmartTagLocalizedField
225
+ label="Subject"
226
+ value={s.autoreply_subject}
227
+ fields={[]}
228
+ onChange={(v) => patch({ autoreply_subject: v })}
229
+ />
230
+ <SmartTagLocalizedField
231
+ label="Body"
232
+ value={s.autoreply_body}
233
+ fields={[]}
234
+ multiline
235
+ onChange={(v) => patch({ autoreply_body: v })}
236
+ />
237
+ <p className="text-xs text-ink/50">
238
+ Use {"{{"}field_key{"}}"} to insert an answer, for example {"{{"}name{"}}"}, or {"{{"}
239
+ all_fields{"}}"} for the full answer table.
240
+ </p>
241
+ </InspectorSection>
242
+
243
+ <InspectorSection title="Data retention">
244
+ <TextField
245
+ label="Keep submissions for (days, 0 = forever)"
246
+ value={String(s.retention_days)}
247
+ onChange={(v) => patch({ retention_days: Number(v) || 0 })}
248
+ />
249
+ <p className="text-xs text-ink/50">
250
+ Submissions are append-only. Nothing is ever removed automatically by site updates —
251
+ purging is always a manual, confirmed action.
252
+ </p>
253
+ <div className="flex flex-wrap items-center gap-2">
254
+ <button
255
+ onClick={async () => {
256
+ try {
257
+ const res = await deps.purgeOldSubmissions({ data: { dryRun: true } });
258
+ setPurgeInfo(res);
259
+ toast.info(
260
+ `${res.matched} submission(s) are older than ${s.retention_days} days.`,
261
+ );
262
+ } catch (e) {
263
+ toast.error(e instanceof Error ? e.message : "Could not check");
264
+ }
265
+ }}
266
+ className="border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea"
267
+ >
268
+ Check what would be purged
269
+ </button>
270
+ <button
271
+ disabled={!purgeInfo || purgeInfo.matched === 0}
272
+ onClick={async () => {
273
+ if (
274
+ !window.confirm(
275
+ `Permanently delete ${purgeInfo?.matched} submission(s) older than ${s.retention_days} days? This cannot be undone.`,
276
+ )
277
+ )
278
+ return;
279
+ try {
280
+ const res = await deps.purgeOldSubmissions({ data: { dryRun: false } });
281
+ setPurgeInfo(null);
282
+ toast.success(`Deleted ${res.deleted} submission(s)`);
283
+ } catch (e) {
284
+ toast.error(e instanceof Error ? e.message : "Purge failed");
285
+ }
286
+ }}
287
+ className="border border-red-300 px-4 py-2 text-xs uppercase tracking-[0.2em] text-red-600 hover:bg-red-50 disabled:opacity-40"
288
+ >
289
+ Purge now
290
+ </button>
291
+ </div>
292
+ {purgeInfo ? (
293
+ <p className="text-xs text-ink/50">
294
+ {purgeInfo.matched} match(es) before{" "}
295
+ {new Date(purgeInfo.cutoff).toLocaleDateString("sv-SE")}.
296
+ </p>
297
+ ) : null}
298
+ </InspectorSection>
299
+ </div>
300
+
301
+ <div className="border border-ink/10 bg-white p-6 space-y-4">
302
+ <InspectorSection title="GDPR consent">
303
+ <LocalizedField
304
+ label="Consent text"
305
+ value={s.consent_text}
306
+ multiline
307
+ onChange={(v) => patch({ consent_text: v })}
308
+ />
309
+ <div className="flex flex-wrap items-center gap-2">
310
+ <button
311
+ type="button"
312
+ onClick={() => {
313
+ const next = { ...consent };
314
+ let changed = false;
315
+ for (const loc of Object.keys(next)) {
316
+ const text = next[loc] ?? "";
317
+ if (/\[[^\]\n]+\]\([^)\s]+\)/.test(text)) continue;
318
+ const m = POLICY_WORDS.exec(text);
319
+ if (!m) continue;
320
+ next[loc] =
321
+ text.slice(0, m.index) +
322
+ `[${m[0]}](privacy)` +
323
+ text.slice(m.index + m[0].length);
324
+ changed = true;
325
+ }
326
+ if (!changed) {
327
+ toast.info('No policy wording found to link — add e.g. "integritetspolicyn".');
328
+ return;
329
+ }
330
+ patch({ consent_text: next });
331
+ toast.success("Privacy policy link inserted");
332
+ }}
333
+ className="border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea"
334
+ >
335
+ Insert privacy policy link
336
+ </button>
337
+ </div>
338
+ <p className="text-xs text-ink/50">
339
+ Add a link with <code>[integritetspolicyn](privacy)</code> — it points at the privacy
340
+ policy URL below. You can also use a full path or address, e.g.{" "}
341
+ <code>[privacy policy](/integritetspolicy)</code>. If there is no link at all, the
342
+ words &ldquo;integritetspolicyn&rdquo; / &ldquo;privacy policy&rdquo; are linked
343
+ automatically.
344
+ </p>
345
+ <TextField
346
+ label="Privacy policy URL"
347
+ value={s.privacy_policy_url ?? ""}
348
+ onChange={(v) => patch({ privacy_policy_url: v })}
349
+ placeholder="/integritetspolicy"
350
+ />
351
+ <p className="text-xs text-ink/50">
352
+ Link target:{" "}
353
+ {normalizePolicyUrl(s.privacy_policy_url) ? (
354
+ <code>{normalizePolicyUrl(s.privacy_policy_url)}</code>
355
+ ) : (
356
+ <span className="text-red-600">
357
+ not set — consent links will render as plain text
358
+ </span>
359
+ )}
360
+ </p>
361
+ <div className="border border-ink/10 bg-paper/60 p-3">
362
+ <p className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/45">Preview</p>
363
+ <p className="mt-2 text-sm text-ink/75">
364
+ {renderLinkedText(consent[previewLocale] ?? "", {
365
+ privacyUrl: s.privacy_policy_url ?? "",
366
+ })}
367
+ </p>
368
+ </div>
369
+ </InspectorSection>
370
+
371
+ <InspectorSection title="After submit">
372
+ <label className="block">
373
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Mode</span>
374
+ <select
375
+ value={s.thankyou_mode}
376
+ onChange={(e) => patch({ thankyou_mode: e.target.value })}
377
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
378
+ >
379
+ <option value="inline">Show thank-you message</option>
380
+ <option value="redirect">Redirect to a page</option>
381
+ </select>
382
+ </label>
383
+ <LocalizedField
384
+ label="Thank-you message"
385
+ value={s.thankyou_message}
386
+ multiline
387
+ onChange={(v) => patch({ thankyou_message: v })}
388
+ />
389
+ <TextField
390
+ label="Redirect URL"
391
+ value={s.thankyou_redirect}
392
+ onChange={(v) => patch({ thankyou_redirect: v })}
393
+ />
394
+ {s.thankyou_mode === "redirect" && !s.thankyou_redirect.trim() ? (
395
+ <p className="text-xs text-red-600">
396
+ Redirect mode needs a URL — without one the thank-you message is shown instead.
397
+ </p>
398
+ ) : null}
399
+ </InspectorSection>
400
+
401
+ <InspectorSection title="Google Sheets">
402
+ <TextField
403
+ label="Default spreadsheet ID"
404
+ value={s.sheet_id}
405
+ onChange={(v) => patch({ sheet_id: v })}
406
+ />
407
+ <TextField
408
+ label="Default tab name"
409
+ value={s.sheet_tab}
410
+ onChange={(v) => patch({ sheet_tab: v })}
411
+ />
412
+ <label className="flex items-center gap-3 text-sm text-ink/70">
413
+ <input
414
+ type="checkbox"
415
+ checked={s.sheet_live_append}
416
+ onChange={(e) => patch({ sheet_live_append: e.target.checked })}
417
+ className="size-4 accent-sea"
418
+ />
419
+ Append every new submission live
420
+ </label>
421
+ {s.sheet_live_append && !s.sheet_id.trim() ? (
422
+ <p className="text-xs text-red-600">
423
+ Live append does nothing until a default spreadsheet ID is set (or one is set per
424
+ form).
425
+ </p>
426
+ ) : null}
427
+ <p className="text-xs text-ink/50">
428
+ Live sync requires the Google Sheets connection. CSV export always works from the
429
+ submissions inbox.
430
+ </p>
431
+ </InspectorSection>
432
+ </div>
433
+ </div>
434
+ </div>
435
+ );
436
+ }