@bygghammar/cms-platform 0.26.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.
- package/CHANGELOG.md +23 -0
- package/package.json +6 -3
- package/src/backoffice/FormDeliveriesView.tsx +344 -0
- package/src/backoffice/FormSettingsView.tsx +436 -0
- package/src/backoffice/FormSubmissionsView.tsx +646 -0
- package/src/backoffice/SiteSettingsView.tsx +28 -7
- package/src/config/site-config.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.28.0
|
|
4
|
+
|
|
5
|
+
- Added `backoffice/FormSubmissionsView` — the submissions inbox (filters, bulk
|
|
6
|
+
status changes, CSV export, resend/sheet retry and the drafts panel), with
|
|
7
|
+
host-supplied filter state, server functions and back link.
|
|
8
|
+
- Added `backoffice/FormDeliveriesView` — the delivery monitor for failed
|
|
9
|
+
notifications, autoreplies, Sheets appends and webhooks, with retry and
|
|
10
|
+
mark-handled.
|
|
11
|
+
- Added `backoffice/FormSettingsView` — shared form defaults: notifications,
|
|
12
|
+
spam protection, autoreply, retention/purge, GDPR consent, after-submit and
|
|
13
|
+
Google Sheets.
|
|
14
|
+
|
|
15
|
+
## 0.27.0
|
|
16
|
+
|
|
17
|
+
- `config/site-config`: added `resolveSiteOrigin()` and `originIsFromEnvironment()`
|
|
18
|
+
so the canonical public origin comes from the deployment environment
|
|
19
|
+
(`SITE_URL`) with the code value as fallback. The origin is never a database
|
|
20
|
+
override.
|
|
21
|
+
- `backoffice/SiteSettingsView`: site key, public origin, locale set and
|
|
22
|
+
backoffice path are now presented as read-only with their source, and the
|
|
23
|
+
intro copy no longer implies everything on the screen is a database override.
|
|
24
|
+
New optional `originSource` dep.
|
|
25
|
+
|
|
3
26
|
All notable changes to `@bygghammar/cms-platform`.
|
|
4
27
|
|
|
5
28
|
The package follows semver with the pre-1.0 convention that a **minor** bump may
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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,10 @@
|
|
|
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"
|
|
131
134
|
},
|
|
132
135
|
"dependencies": {
|
|
133
136
|
"fflate": "^0.8.3"
|
|
@@ -203,4 +206,4 @@
|
|
|
203
206
|
"bin": {
|
|
204
207
|
"cms-platform-migrations": "scripts/sync-migrations.mjs"
|
|
205
208
|
}
|
|
206
|
-
}
|
|
209
|
+
}
|
|
@@ -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
|
+
}
|