@bygghammar/cms-platform 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,435 @@
1
+ import type React from "react";
2
+ import { useState } from "react";
3
+ import { toast } from "sonner";
4
+ import {
5
+ Activity,
6
+ Plus,
7
+ Settings2,
8
+ Inbox,
9
+ Trash2,
10
+ Copy,
11
+ RotateCcw,
12
+ ShieldAlert,
13
+ } from "lucide-react";
14
+ import { FORM_TYPES, type FormFieldDef } from "../forms/field-sets";
15
+
16
+ /** One row in the forms list. */
17
+ export type FormsListRow = {
18
+ id: string;
19
+ key: string;
20
+ name: string;
21
+ form_type: string;
22
+ is_active: boolean;
23
+ fields: FormFieldDef[];
24
+ submissions: number;
25
+ newSubmissions: number;
26
+ lastSubmissionAt?: string | null;
27
+ };
28
+
29
+ /** One soft-deleted form row. */
30
+ export type DeletedFormRow = {
31
+ id: string;
32
+ key: string;
33
+ name: string;
34
+ form_type: string;
35
+ deleted_at: string;
36
+ deleted_by_email?: string | null;
37
+ };
38
+
39
+ /** Host-provided data access and links for the forms index. */
40
+ export type FormsIndexDeps = {
41
+ forms: FormsListRow[];
42
+ isLoading: boolean;
43
+ bin: { rows: DeletedFormRow[]; canPurge: boolean } | undefined;
44
+ createForm: (input: {
45
+ data: { key: string; name: string; form_type: string };
46
+ }) => Promise<unknown>;
47
+ duplicateForm: (input: { data: { id: string; key: string; name: string } }) => Promise<unknown>;
48
+ deleteForm: (input: { data: { id: string } }) => Promise<unknown>;
49
+ restoreForm: (input: { data: { id: string } }) => Promise<unknown>;
50
+ purgeForm: (input: { data: { id: string } }) => Promise<unknown>;
51
+ getFormUsage: (input: {
52
+ data: { key: string };
53
+ }) => Promise<{ pages: string[]; globalSections: string[] }>;
54
+ refresh: () => Promise<unknown> | void;
55
+ /** Host router link; `params` carries the form key for the editor route. */
56
+ LinkComponent: (props: {
57
+ to: string;
58
+ params?: Record<string, string>;
59
+ search?: Record<string, unknown>;
60
+ className?: string;
61
+ children: React.ReactNode;
62
+ }) => React.ReactElement;
63
+ paths?: {
64
+ editor?: string;
65
+ submissions?: string;
66
+ deliveries?: string;
67
+ settings?: string;
68
+ };
69
+ };
70
+
71
+ function slugify(v: string) {
72
+ return v
73
+ .toLowerCase()
74
+ .replace(/[^a-z0-9]+/g, "-")
75
+ .replace(/^-+|-+$/g, "")
76
+ .slice(0, 64);
77
+ }
78
+
79
+ /** Host-agnostic forms index: create, list, duplicate, bin and restore forms. */
80
+ export function FormsIndexView({ deps }: { deps: FormsIndexDeps }) {
81
+ const {
82
+ forms,
83
+ isLoading,
84
+ bin,
85
+ createForm,
86
+ duplicateForm,
87
+ deleteForm,
88
+ restoreForm,
89
+ purgeForm,
90
+ getFormUsage,
91
+ refresh,
92
+ LinkComponent: Link,
93
+ } = deps;
94
+ const paths = {
95
+ editor: deps.paths?.editor ?? "/kontoret/forms/$key",
96
+ submissions: deps.paths?.submissions ?? "/kontoret/forms/submissions",
97
+ deliveries: deps.paths?.deliveries ?? "/kontoret/forms/deliveries",
98
+ settings: deps.paths?.settings ?? "/kontoret/forms/settings",
99
+ };
100
+
101
+ const [view, setView] = useState<"list" | "bin">("list");
102
+ const [name, setName] = useState("");
103
+ const [type, setType] = useState("contact");
104
+ const [busy, setBusy] = useState(false);
105
+
106
+ const onCreate = async () => {
107
+ if (!name.trim()) return;
108
+ setBusy(true);
109
+ try {
110
+ await createForm({ data: { key: slugify(name), name: name.trim(), form_type: type } });
111
+ await refresh();
112
+ setName("");
113
+ toast.success("Form created");
114
+ } catch (e) {
115
+ toast.error(e instanceof Error ? e.message : "Could not create the form");
116
+ } finally {
117
+ setBusy(false);
118
+ }
119
+ };
120
+
121
+ const onDuplicate = async (f: { id: string; name: string; key: string }) => {
122
+ const newName = window.prompt("Name for the copy", `${f.name} (copy)`);
123
+ if (!newName?.trim()) return;
124
+ let key = slugify(newName);
125
+ const taken = new Set(forms.map((x) => x.key));
126
+ if (!key || taken.has(key)) {
127
+ let i = 2;
128
+ const base = key || `${f.key}-copy`;
129
+ key = base;
130
+ while (taken.has(key)) key = `${base}-${i++}`;
131
+ }
132
+ try {
133
+ await duplicateForm({ data: { id: f.id, key, name: newName.trim() } });
134
+ await refresh();
135
+ toast.success(`Copied as "${newName.trim()}" (inactive)`);
136
+ } catch (e) {
137
+ toast.error(e instanceof Error ? e.message : "Could not copy the form");
138
+ }
139
+ };
140
+
141
+ const onDelete = async (f: { id: string; name: string; key: string }) => {
142
+ let warning = "";
143
+ try {
144
+ const u = await getFormUsage({ data: { key: f.key } });
145
+ const places = [...u.pages, ...u.globalSections];
146
+ if (places.length) {
147
+ warning = `\n\nWARNING: this form is still used on: ${places.join(", ")}. Those places will show nothing until you pick another form.`;
148
+ }
149
+ } catch {
150
+ /* usage check is advisory only */
151
+ }
152
+ if (
153
+ !window.confirm(
154
+ `Move the form "${f.name}" to the bin? Submissions are kept and stay visible in the inbox.${warning}`,
155
+ )
156
+ )
157
+ return;
158
+ try {
159
+ await deleteForm({ data: { id: f.id } });
160
+ await refresh();
161
+ toast.success("Form moved to the bin");
162
+ } catch (e) {
163
+ toast.error(e instanceof Error ? e.message : "Could not delete");
164
+ }
165
+ };
166
+
167
+ return (
168
+ <div className="p-8 md:p-12 w-full">
169
+ <div className="flex flex-wrap items-end justify-between gap-4">
170
+ <div>
171
+ <h1 className="font-serif text-2xl text-ink">Forms</h1>
172
+ <p className="mt-2 text-sm text-ink/60">
173
+ Every form on the site in one place — fields, notifications, submissions and Google
174
+ Sheets sync. All forms inherit the shared defaults.
175
+ </p>
176
+ </div>
177
+ <div className="flex gap-2">
178
+ <Link
179
+ to={paths.submissions}
180
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
181
+ >
182
+ <Inbox className="size-4" />
183
+ Submissions
184
+ </Link>
185
+ <Link
186
+ to={paths.deliveries}
187
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
188
+ >
189
+ <Activity className="size-4" />
190
+ Delivery monitor
191
+ </Link>
192
+ <Link
193
+ to={paths.settings}
194
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
195
+ >
196
+ <Settings2 className="size-4" />
197
+ Shared settings
198
+ </Link>
199
+ </div>
200
+ </div>
201
+
202
+ <div className="mt-8 border border-ink/10 bg-white p-6">
203
+ <div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">New form</div>
204
+ <div className="mt-4 flex flex-wrap items-end gap-3">
205
+ <label className="flex-1 min-w-[220px]">
206
+ <span className="text-xs text-ink/55">Name</span>
207
+ <input
208
+ value={name}
209
+ onChange={(e) => setName(e.target.value)}
210
+ placeholder="Contact form"
211
+ className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
212
+ />
213
+ </label>
214
+ <label className="min-w-[220px]">
215
+ <span className="text-xs text-ink/55">Type</span>
216
+ <select
217
+ value={type}
218
+ onChange={(e) => setType(e.target.value)}
219
+ className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
220
+ >
221
+ {FORM_TYPES.map((t) => (
222
+ <option key={t.value} value={t.value}>
223
+ {t.label} — {t.description}
224
+ </option>
225
+ ))}
226
+ </select>
227
+ </label>
228
+ <button
229
+ onClick={onCreate}
230
+ disabled={busy || !name.trim()}
231
+ 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"
232
+ >
233
+ <Plus className="size-4" />
234
+ Create
235
+ </button>
236
+ </div>
237
+ {name.trim() ? (
238
+ <p className="mt-3 text-xs text-ink/50">Key: {slugify(name) || "—"}</p>
239
+ ) : null}
240
+ </div>
241
+
242
+ <div className="mt-8 flex gap-2">
243
+ {(["list", "bin"] as const).map((v) => (
244
+ <button
245
+ key={v}
246
+ onClick={() => setView(v)}
247
+ className={
248
+ view === v
249
+ ? "border border-ink bg-ink px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em] text-paper"
250
+ : "border border-ink/15 bg-white px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em] text-ink/60 hover:border-sea"
251
+ }
252
+ >
253
+ {v === "list" ? "Forms" : `Bin${bin?.rows.length ? ` (${bin.rows.length})` : ""}`}
254
+ </button>
255
+ ))}
256
+ </div>
257
+
258
+ {view === "list" ? (
259
+ <div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
260
+ <table className="w-full text-sm">
261
+ <thead>
262
+ <tr className="border-b border-ink/10 text-left text-[0.65rem] uppercase tracking-[0.2em] text-ink/50">
263
+ <th className="px-6 py-3">Form</th>
264
+ <th className="px-6 py-3">Key</th>
265
+ <th className="px-6 py-3">Type</th>
266
+ <th className="px-6 py-3">Fields</th>
267
+ <th className="px-6 py-3">Status</th>
268
+ <th className="px-6 py-3">Submissions</th>
269
+ <th className="px-6 py-3">Last</th>
270
+ <th className="px-6 py-3"></th>
271
+ </tr>
272
+ </thead>
273
+ <tbody>
274
+ {isLoading ? (
275
+ <tr>
276
+ <td className="px-6 py-6 text-ink/50" colSpan={8}>
277
+ Loading…
278
+ </td>
279
+ </tr>
280
+ ) : forms.length === 0 ? (
281
+ <tr>
282
+ <td className="px-6 py-6 text-ink/50" colSpan={8}>
283
+ No forms yet.
284
+ </td>
285
+ </tr>
286
+ ) : (
287
+ forms.map((f) => (
288
+ <tr key={f.id} className="border-b border-ink/5 last:border-0">
289
+ <td className="px-6 py-3">
290
+ <Link
291
+ to={paths.editor}
292
+ params={{ key: f.key }}
293
+ className="text-ink hover:text-sea"
294
+ >
295
+ {f.name}
296
+ </Link>
297
+ </td>
298
+ <td className="px-6 py-3 text-ink/60">{f.key}</td>
299
+ <td className="px-6 py-3 text-ink/60">{f.form_type}</td>
300
+ <td className="px-6 py-3 text-ink/60">
301
+ {f.fields.filter((x) => x.enabled).length}/
302
+ {f.fields.filter((x) => !x.removed).length}
303
+ </td>
304
+ <td className="px-6 py-3">
305
+ <span
306
+ className={
307
+ f.is_active
308
+ ? "text-[0.65rem] uppercase tracking-[0.2em] text-sea"
309
+ : "text-[0.65rem] uppercase tracking-[0.2em] text-ink/40"
310
+ }
311
+ >
312
+ {f.is_active ? "Active" : "Inactive"}
313
+ </span>
314
+ </td>
315
+ <td className="px-6 py-3 text-ink/60">
316
+ <Link to={paths.submissions} search={{ form: f.key }} className="hover:text-sea">
317
+ {f.submissions}
318
+ {f.newSubmissions > 0 ? (
319
+ <span className="ml-2 bg-sea px-1.5 py-0.5 text-[0.6rem] uppercase tracking-[0.18em] text-ink">
320
+ {f.newSubmissions} new
321
+ </span>
322
+ ) : null}
323
+ </Link>
324
+ </td>
325
+ <td className="px-6 py-3 text-ink/50 whitespace-nowrap">
326
+ {f.lastSubmissionAt
327
+ ? new Date(f.lastSubmissionAt).toLocaleDateString("sv-SE")
328
+ : "—"}
329
+ </td>
330
+ <td className="px-6 py-3 text-right whitespace-nowrap">
331
+ <button
332
+ onClick={() => onDuplicate(f)}
333
+ className="mr-3 text-ink/40 hover:text-sea"
334
+ title="Duplicate form"
335
+ >
336
+ <Copy className="size-4" />
337
+ </button>
338
+ <button
339
+ onClick={() => onDelete(f)}
340
+ className="text-ink/40 hover:text-red-500"
341
+ title="Move form to bin"
342
+ >
343
+ <Trash2 className="size-4" />
344
+ </button>
345
+ </td>
346
+ </tr>
347
+ ))
348
+ )}
349
+ </tbody>
350
+ </table>
351
+ </div>
352
+ ) : (
353
+ <div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
354
+ <table className="w-full text-sm">
355
+ <thead>
356
+ <tr className="border-b border-ink/10 text-left text-[0.65rem] uppercase tracking-[0.2em] text-ink/50">
357
+ <th className="px-6 py-3">Form</th>
358
+ <th className="px-6 py-3">Key</th>
359
+ <th className="px-6 py-3">Type</th>
360
+ <th className="px-6 py-3">Deleted</th>
361
+ <th className="px-6 py-3">By</th>
362
+ <th className="px-6 py-3"></th>
363
+ </tr>
364
+ </thead>
365
+ <tbody>
366
+ {(bin?.rows ?? []).length === 0 ? (
367
+ <tr>
368
+ <td className="px-6 py-6 text-ink/50" colSpan={6}>
369
+ The bin is empty.
370
+ </td>
371
+ </tr>
372
+ ) : (
373
+ (bin?.rows ?? []).map((f) => (
374
+ <tr key={f.id} className="border-b border-ink/5 last:border-0">
375
+ <td className="px-6 py-3 text-ink">{f.name}</td>
376
+ <td className="px-6 py-3 text-ink/60">{f.key}</td>
377
+ <td className="px-6 py-3 text-ink/60">{f.form_type}</td>
378
+ <td className="px-6 py-3 text-ink/50 whitespace-nowrap">
379
+ {new Date(f.deleted_at).toLocaleString("sv-SE")}
380
+ </td>
381
+ <td className="px-6 py-3 text-ink/60">{f.deleted_by_email ?? "—"}</td>
382
+ <td className="px-6 py-3 text-right whitespace-nowrap">
383
+ <button
384
+ onClick={async () => {
385
+ try {
386
+ await restoreForm({ data: { id: f.id } });
387
+ await refresh();
388
+ toast.success("Form restored (inactive)");
389
+ } catch (e) {
390
+ toast.error(e instanceof Error ? e.message : "Could not restore");
391
+ }
392
+ }}
393
+ className="mr-3 inline-flex items-center gap-1 text-xs uppercase tracking-[0.18em] text-ink/60 hover:text-sea"
394
+ >
395
+ <RotateCcw className="size-4" />
396
+ Restore
397
+ </button>
398
+ {bin?.canPurge ? (
399
+ <button
400
+ onClick={async () => {
401
+ if (
402
+ !window.confirm(
403
+ `Permanently delete "${f.name}"? This cannot be undone. Submissions are kept in the inbox.`,
404
+ )
405
+ )
406
+ return;
407
+ try {
408
+ await purgeForm({ data: { id: f.id } });
409
+ await refresh();
410
+ toast.success("Form permanently deleted");
411
+ } catch (e) {
412
+ toast.error(e instanceof Error ? e.message : "Could not delete");
413
+ }
414
+ }}
415
+ className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.18em] text-ink/50 hover:text-red-500"
416
+ >
417
+ <ShieldAlert className="size-4" />
418
+ Delete permanently
419
+ </button>
420
+ ) : (
421
+ <span className="text-[0.65rem] uppercase tracking-[0.18em] text-ink/35">
422
+ Superadmin only
423
+ </span>
424
+ )}
425
+ </td>
426
+ </tr>
427
+ ))
428
+ )}
429
+ </tbody>
430
+ </table>
431
+ </div>
432
+ )}
433
+ </div>
434
+ );
435
+ }