@bygghammar/cms-platform 0.27.0 → 0.29.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.29.0
4
+
5
+ - Added `backoffice/GlobalSectionsListView` — the global-sections library with
6
+ previews, block chips, create and delete.
7
+ - Added `backoffice/GlobalSectionBuilderView` — editor for one global section or
8
+ global block: rename, convert kind, usage note, save; the block editor and
9
+ data access are host-injected.
10
+ - Added `backoffice/ReviewsView` — the reviews/testimonials slider editor with
11
+ host-supplied media picker and uploader.
12
+
13
+ ## 0.28.0
14
+
15
+ - Added `backoffice/FormSubmissionsView` — the submissions inbox (filters, bulk
16
+ status changes, CSV export, resend/sheet retry and the drafts panel), with
17
+ host-supplied filter state, server functions and back link.
18
+ - Added `backoffice/FormDeliveriesView` — the delivery monitor for failed
19
+ notifications, autoreplies, Sheets appends and webhooks, with retry and
20
+ mark-handled.
21
+ - Added `backoffice/FormSettingsView` — shared form defaults: notifications,
22
+ spam protection, autoreply, retention/purge, GDPR consent, after-submit and
23
+ Google Sheets.
24
+
3
25
  ## 0.27.0
4
26
 
5
27
  - `config/site-config`: added `resolveSiteOrigin()` and `originIsFromEnvironment()`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -127,7 +127,13 @@
127
127
  "./backoffice/ContactDetailsView": "./src/backoffice/ContactDetailsView.tsx",
128
128
  "./backoffice/VersionView": "./src/backoffice/VersionView.tsx",
129
129
  "./backoffice/EmailSettingsView": "./src/backoffice/EmailSettingsView.tsx",
130
- "./backoffice/BackupView": "./src/backoffice/BackupView.tsx"
130
+ "./backoffice/BackupView": "./src/backoffice/BackupView.tsx",
131
+ "./backoffice/FormSubmissionsView": "./src/backoffice/FormSubmissionsView.tsx",
132
+ "./backoffice/FormDeliveriesView": "./src/backoffice/FormDeliveriesView.tsx",
133
+ "./backoffice/FormSettingsView": "./src/backoffice/FormSettingsView.tsx",
134
+ "./backoffice/GlobalSectionsListView": "./src/backoffice/GlobalSectionsListView.tsx",
135
+ "./backoffice/GlobalSectionBuilderView": "./src/backoffice/GlobalSectionBuilderView.tsx",
136
+ "./backoffice/ReviewsView": "./src/backoffice/ReviewsView.tsx"
131
137
  },
132
138
  "dependencies": {
133
139
  "fflate": "^0.8.3"
@@ -203,4 +209,4 @@
203
209
  "bin": {
204
210
  "cms-platform-migrations": "scripts/sync-migrations.mjs"
205
211
  }
206
- }
212
+ }
@@ -0,0 +1,344 @@
1
+ import type React from "react";
2
+ import { useMemo, useState } from "react";
3
+ import { toast } from "sonner";
4
+ import { AlertTriangle, ArrowLeft, CheckCircle2, RefreshCw, RotateCcw } from "lucide-react";
5
+
6
+ /** Outbound channels a submission can be delivered through. */
7
+ export const DELIVERY_CHANNELS = ["notification", "autoreply", "sheet", "webhook"] as const;
8
+ export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number];
9
+
10
+ /** One logged delivery attempt. */
11
+ export type DeliveryAttemptRow = {
12
+ id: string;
13
+ form_key: string;
14
+ channel: string;
15
+ status: string;
16
+ attempt: number;
17
+ created_at: string;
18
+ resolved_at?: string | null;
19
+ duration_ms?: number | null;
20
+ target?: string | null;
21
+ error?: string | null;
22
+ submission_id?: string | null;
23
+ submission?: { payload?: Record<string, unknown> | null } | null;
24
+ };
25
+
26
+ /** Aggregated delivery data as returned by the host. */
27
+ export type DeliveryAttemptsResult = {
28
+ rows: DeliveryAttemptRow[];
29
+ totals: { open: number; last24h: number; resolved: number };
30
+ perForm: {
31
+ form_key: string;
32
+ name: string;
33
+ open: number;
34
+ byChannel: Record<string, number>;
35
+ }[];
36
+ };
37
+
38
+ /** Host-provided data access for the delivery monitor. */
39
+ export type FormDeliveriesDeps = {
40
+ data: DeliveryAttemptsResult | undefined;
41
+ isLoading: boolean;
42
+ forms: { key: string; name: string }[];
43
+ filters: {
44
+ formKey: string;
45
+ channel: "" | DeliveryChannel;
46
+ state: "open" | "resolved" | "all";
47
+ };
48
+ setFilters: (patch: Partial<FormDeliveriesDeps["filters"]>) => void;
49
+ refetch: () => Promise<unknown> | void;
50
+ retryAttempts: (input: {
51
+ data: { ids: string[] };
52
+ }) => Promise<{ retried: number; succeeded: number; failures: string[] }>;
53
+ resolveAttempts: (input: { data: { ids: string[] } }) => Promise<unknown>;
54
+ /** Host router link back to the forms index. */
55
+ BackLink: (props: { className?: string; children: React.ReactNode }) => React.ReactElement;
56
+ };
57
+
58
+ /**
59
+ * Failed outbound deliveries per form, with diagnostics and retry.
60
+ *
61
+ * Submissions are always stored before delivery is attempted, so a failure
62
+ * listed here never means lost data.
63
+ */
64
+ export function FormDeliveriesView({ deps }: { deps: FormDeliveriesDeps }) {
65
+ const { data, isLoading, forms, filters, setFilters, refetch } = deps;
66
+ const [selected, setSelected] = useState<string[]>([]);
67
+ const [busy, setBusy] = useState(false);
68
+
69
+ const rows = data?.rows ?? [];
70
+ const allSelected = useMemo(
71
+ () => rows.length > 0 && selected.length === rows.length,
72
+ [rows, selected],
73
+ );
74
+
75
+ const run = async (fn: () => Promise<void>) => {
76
+ setBusy(true);
77
+ try {
78
+ await fn();
79
+ setSelected([]);
80
+ await refetch();
81
+ } catch (e) {
82
+ toast.error(e instanceof Error ? e.message : "Action failed");
83
+ } finally {
84
+ setBusy(false);
85
+ }
86
+ };
87
+
88
+ const onRetry = (ids: string[]) =>
89
+ run(async () => {
90
+ const res = await deps.retryAttempts({ data: { ids } });
91
+ if (res.failures.length)
92
+ toast.warning(`${res.succeeded}/${res.retried} delivered`, {
93
+ description: res.failures.slice(0, 3).join(" · "),
94
+ });
95
+ else toast.success(`${res.succeeded} delivery/deliveries succeeded`);
96
+ });
97
+
98
+ return (
99
+ <div className="p-8 md:p-12 w-full">
100
+ <div className="flex flex-wrap items-center justify-between gap-4">
101
+ <div>
102
+ <deps.BackLink className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-ink/45 hover:text-ink">
103
+ <ArrowLeft className="size-4" />
104
+ Forms
105
+ </deps.BackLink>
106
+ <h1 className="mt-2 text-3xl font-semibold tracking-tight">Delivery monitor</h1>
107
+ <p className="mt-1 max-w-2xl text-sm text-ink/55">
108
+ Every notification, autoreply, Sheets append and webhook is logged. Submissions are
109
+ always stored first, so a failure here never means lost data.
110
+ </p>
111
+ </div>
112
+ <button
113
+ onClick={() => refetch()}
114
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea"
115
+ >
116
+ <RefreshCw className="size-4" />
117
+ Refresh
118
+ </button>
119
+ </div>
120
+
121
+ <div className="mt-8 grid gap-4 sm:grid-cols-3">
122
+ <Stat label="Open failures" value={data?.totals.open ?? 0} tone="bad" />
123
+ <Stat label="Failures last 24 h" value={data?.totals.last24h ?? 0} />
124
+ <Stat label="Resolved" value={data?.totals.resolved ?? 0} tone="good" />
125
+ </div>
126
+
127
+ {data?.perForm.length ? (
128
+ <div className="mt-6 border border-ink/10 bg-white">
129
+ <h2 className="border-b border-ink/10 px-6 py-3 text-xs uppercase tracking-[0.22em] text-ink/50">
130
+ Open failures per form
131
+ </h2>
132
+ <ul className="divide-y divide-ink/10">
133
+ {data.perForm.map((f) => (
134
+ <li key={f.form_key} className="flex flex-wrap items-center gap-4 px-6 py-3">
135
+ <button
136
+ onClick={() => setFilters({ formKey: f.form_key })}
137
+ className="text-sm font-medium hover:text-sea"
138
+ >
139
+ {f.name}
140
+ </button>
141
+ <span className="text-xs text-ink/45">{f.form_key}</span>
142
+ <span className="text-xs text-ink/45">
143
+ {Object.entries(f.byChannel)
144
+ .map(([c, n]) => `${c}: ${n}`)
145
+ .join(" · ")}
146
+ </span>
147
+ <span className="ml-auto text-xs uppercase tracking-[0.18em] text-red-600">
148
+ {f.open} open
149
+ </span>
150
+ </li>
151
+ ))}
152
+ </ul>
153
+ </div>
154
+ ) : null}
155
+
156
+ <div className="mt-6 flex flex-wrap items-center gap-3">
157
+ <select
158
+ value={filters.formKey}
159
+ onChange={(e) => setFilters({ formKey: e.target.value })}
160
+ className="border border-ink/15 bg-white px-3 py-2 text-sm"
161
+ >
162
+ <option value="">All forms</option>
163
+ {forms.map((f) => (
164
+ <option key={f.key} value={f.key}>
165
+ {f.name}
166
+ </option>
167
+ ))}
168
+ </select>
169
+ <select
170
+ value={filters.channel}
171
+ onChange={(e) => setFilters({ channel: e.target.value as "" | DeliveryChannel })}
172
+ className="border border-ink/15 bg-white px-3 py-2 text-sm"
173
+ >
174
+ <option value="">All channels</option>
175
+ {DELIVERY_CHANNELS.map((c) => (
176
+ <option key={c} value={c}>
177
+ {c}
178
+ </option>
179
+ ))}
180
+ </select>
181
+ <select
182
+ value={filters.state}
183
+ onChange={(e) =>
184
+ setFilters({ state: e.target.value as FormDeliveriesDeps["filters"]["state"] })
185
+ }
186
+ className="border border-ink/15 bg-white px-3 py-2 text-sm"
187
+ >
188
+ <option value="open">Open failures</option>
189
+ <option value="resolved">Resolved</option>
190
+ <option value="all">Everything</option>
191
+ </select>
192
+
193
+ {selected.length ? (
194
+ <div className="ml-auto flex items-center gap-2">
195
+ <button
196
+ onClick={() => onRetry(selected)}
197
+ disabled={busy}
198
+ className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-xs uppercase tracking-[0.2em] text-paper hover:bg-sea hover:text-ink disabled:opacity-50"
199
+ >
200
+ <RotateCcw className="size-4" />
201
+ Retry {selected.length}
202
+ </button>
203
+ <button
204
+ onClick={() =>
205
+ run(async () => {
206
+ await deps.resolveAttempts({ data: { ids: selected } });
207
+ toast.success("Marked as handled");
208
+ })
209
+ }
210
+ disabled={busy}
211
+ className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
212
+ >
213
+ <CheckCircle2 className="size-4" />
214
+ Mark handled
215
+ </button>
216
+ </div>
217
+ ) : null}
218
+ </div>
219
+
220
+ <div className="mt-4 border border-ink/10 bg-white">
221
+ {isLoading ? (
222
+ <p className="px-6 py-8 text-sm text-ink/45">Loading…</p>
223
+ ) : !rows.length ? (
224
+ <p className="px-6 py-8 text-sm text-ink/45">
225
+ Nothing to show — every delivery went through.
226
+ </p>
227
+ ) : (
228
+ <>
229
+ <label className="flex items-center gap-3 border-b border-ink/10 px-6 py-3 text-xs uppercase tracking-[0.18em] text-ink/45">
230
+ <input
231
+ type="checkbox"
232
+ checked={allSelected}
233
+ onChange={(e) => setSelected(e.target.checked ? rows.map((r) => r.id) : [])}
234
+ className="size-4 accent-sea"
235
+ />
236
+ Select all ({rows.length})
237
+ </label>
238
+ <ul className="divide-y divide-ink/10">
239
+ {rows.map((r) => (
240
+ <li key={r.id} className="px-6 py-4">
241
+ <div className="flex flex-wrap items-start gap-3">
242
+ <input
243
+ type="checkbox"
244
+ checked={selected.includes(r.id)}
245
+ onChange={(e) =>
246
+ setSelected((prev) =>
247
+ e.target.checked ? [...prev, r.id] : prev.filter((x) => x !== r.id),
248
+ )
249
+ }
250
+ className="mt-1 size-4 accent-sea"
251
+ />
252
+ <div className="min-w-0 flex-1">
253
+ <div className="flex flex-wrap items-center gap-2">
254
+ <span
255
+ className={`inline-flex items-center gap-1 border px-2 py-0.5 text-[0.6rem] uppercase tracking-[0.18em] ${
256
+ r.resolved_at
257
+ ? "border-emerald-600/30 text-emerald-700"
258
+ : r.status === "failed"
259
+ ? "border-red-600/30 text-red-600"
260
+ : "border-ink/15 text-ink/50"
261
+ }`}
262
+ >
263
+ {r.resolved_at ? (
264
+ <CheckCircle2 className="size-3" />
265
+ ) : (
266
+ <AlertTriangle className="size-3" />
267
+ )}
268
+ {r.resolved_at ? "resolved" : r.status}
269
+ </span>
270
+ <span className="text-sm font-medium">{r.channel}</span>
271
+ <span className="text-xs text-ink/45">{r.form_key}</span>
272
+ <span className="text-xs text-ink/45">
273
+ {new Date(r.created_at).toLocaleString("sv-SE")}
274
+ </span>
275
+ <span className="text-xs text-ink/45">attempt {r.attempt}</span>
276
+ {r.duration_ms != null ? (
277
+ <span className="text-xs text-ink/45">{r.duration_ms} ms</span>
278
+ ) : null}
279
+ </div>
280
+ {r.target ? (
281
+ <p className="mt-1 truncate text-xs text-ink/55">→ {r.target}</p>
282
+ ) : null}
283
+ {r.error ? (
284
+ <p className="mt-1 break-words text-xs text-red-600">{r.error}</p>
285
+ ) : null}
286
+ {r.submission ? (
287
+ <details className="mt-2">
288
+ <summary className="cursor-pointer text-[0.65rem] uppercase tracking-[0.18em] text-ink/40">
289
+ Submission details
290
+ </summary>
291
+ <dl className="mt-2 grid gap-1 text-xs text-ink/60 sm:grid-cols-2">
292
+ {Object.entries(r.submission.payload ?? {}).map(([k, v]) => (
293
+ <div key={k} className="flex gap-2">
294
+ <dt className="text-ink/40">{k}</dt>
295
+ <dd className="truncate">{String(v)}</dd>
296
+ </div>
297
+ ))}
298
+ </dl>
299
+ </details>
300
+ ) : (
301
+ <p className="mt-1 text-xs text-ink/40">
302
+ No stored submission (test send).
303
+ </p>
304
+ )}
305
+ </div>
306
+ {r.submission_id && !r.resolved_at ? (
307
+ <button
308
+ onClick={() => onRetry([r.id])}
309
+ disabled={busy}
310
+ className="inline-flex items-center gap-2 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.18em] hover:border-sea disabled:opacity-50"
311
+ >
312
+ <RotateCcw className="size-3.5" />
313
+ Retry
314
+ </button>
315
+ ) : null}
316
+ </div>
317
+ </li>
318
+ ))}
319
+ </ul>
320
+ </>
321
+ )}
322
+ </div>
323
+ </div>
324
+ );
325
+ }
326
+
327
+ function Stat({ label, value, tone }: { label: string; value: number; tone?: "good" | "bad" }) {
328
+ return (
329
+ <div className="border border-ink/10 bg-white p-6">
330
+ <p className="text-xs uppercase tracking-[0.22em] text-ink/45">{label}</p>
331
+ <p
332
+ className={`mt-2 text-3xl font-semibold ${
333
+ tone === "bad" && value > 0
334
+ ? "text-red-600"
335
+ : tone === "good"
336
+ ? "text-emerald-700"
337
+ : "text-ink"
338
+ }`}
339
+ >
340
+ {value}
341
+ </p>
342
+ </div>
343
+ );
344
+ }