@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 +22 -0
- package/package.json +9 -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/GlobalSectionBuilderView.tsx +225 -0
- package/src/backoffice/GlobalSectionsListView.tsx +193 -0
- package/src/backoffice/ReviewsView.tsx +231 -0
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.
|
|
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
|
+
}
|