@bygghammar/cms-platform 0.23.1 → 0.25.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 +17 -0
- package/package.json +13 -3
- package/src/backoffice/AuditView.tsx +171 -0
- package/src/backoffice/ContactDetailsView.tsx +108 -0
- package/src/backoffice/DomainsView.tsx +264 -0
- package/src/backoffice/EmailLogsView.tsx +203 -0
- package/src/backoffice/LogsView.tsx +43 -0
- package/src/backoffice/NavigationView.tsx +133 -0
- package/src/backoffice/RolesView.tsx +50 -0
- package/src/backoffice/TrackingView.tsx +405 -0
- package/src/backoffice/UsersView.tsx +348 -0
- package/src/backoffice/VersionView.tsx +109 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Plus, Save, Trash2, Copy } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
export type InjectionScope = "global" | "locale" | "domain";
|
|
6
|
+
|
|
7
|
+
/** One injection profile row, structurally matching the host's own type. */
|
|
8
|
+
export type InjectionProfileLike = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
scope_type: InjectionScope;
|
|
12
|
+
scope_value: string;
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
head_html: string;
|
|
15
|
+
body_start_html: string;
|
|
16
|
+
body_end_html: string;
|
|
17
|
+
gtm_id: string;
|
|
18
|
+
ga4_id: string;
|
|
19
|
+
meta_pixel_id: string;
|
|
20
|
+
tiktok_pixel_id: string;
|
|
21
|
+
extra_meta: { name: string; content: string }[];
|
|
22
|
+
sort_order: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Host-provided data and actions for the code & tracking screen. */
|
|
26
|
+
export type TrackingDeps = {
|
|
27
|
+
profiles: InjectionProfileLike[];
|
|
28
|
+
domains: { domain: string }[];
|
|
29
|
+
locales: readonly string[];
|
|
30
|
+
saveInjection: (input: { data: Omit<InjectionProfileLike, "id"> & { id?: string } }) => Promise<unknown>;
|
|
31
|
+
deleteInjection: (input: { data: { id: string } }) => Promise<unknown>;
|
|
32
|
+
refresh: () => Promise<unknown> | void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const blankProfile = (): InjectionProfileLike => ({
|
|
36
|
+
id: "",
|
|
37
|
+
name: "New profile",
|
|
38
|
+
scope_type: "global",
|
|
39
|
+
scope_value: "",
|
|
40
|
+
enabled: false,
|
|
41
|
+
head_html: "",
|
|
42
|
+
body_start_html: "",
|
|
43
|
+
body_end_html: "",
|
|
44
|
+
gtm_id: "",
|
|
45
|
+
ga4_id: "",
|
|
46
|
+
meta_pixel_id: "",
|
|
47
|
+
tiktok_pixel_id: "",
|
|
48
|
+
extra_meta: [],
|
|
49
|
+
sort_order: 100,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function Label({ children }: { children: React.ReactNode }) {
|
|
53
|
+
return (
|
|
54
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60 mb-1">
|
|
55
|
+
{children}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Input({
|
|
61
|
+
value,
|
|
62
|
+
onChange,
|
|
63
|
+
placeholder,
|
|
64
|
+
}: {
|
|
65
|
+
value: string;
|
|
66
|
+
onChange: (v: string) => void;
|
|
67
|
+
placeholder?: string;
|
|
68
|
+
}) {
|
|
69
|
+
return (
|
|
70
|
+
<input
|
|
71
|
+
value={value}
|
|
72
|
+
onChange={(e) => onChange(e.target.value)}
|
|
73
|
+
placeholder={placeholder}
|
|
74
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Code({
|
|
80
|
+
value,
|
|
81
|
+
onChange,
|
|
82
|
+
placeholder,
|
|
83
|
+
}: {
|
|
84
|
+
value: string;
|
|
85
|
+
onChange: (v: string) => void;
|
|
86
|
+
placeholder?: string;
|
|
87
|
+
}) {
|
|
88
|
+
return (
|
|
89
|
+
<textarea
|
|
90
|
+
value={value}
|
|
91
|
+
onChange={(e) => onChange(e.target.value)}
|
|
92
|
+
placeholder={placeholder}
|
|
93
|
+
spellCheck={false}
|
|
94
|
+
className="w-full min-h-[140px] border border-ink/20 bg-white p-3 font-mono text-xs text-ink focus:outline-none focus:border-sea resize-y"
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Host-agnostic "Code & tracking" screen: manage injection profiles scoped to
|
|
101
|
+
* all sites, one language or one domain.
|
|
102
|
+
*/
|
|
103
|
+
export function TrackingView({ deps }: { deps: TrackingDeps }) {
|
|
104
|
+
const { profiles, domains, locales, saveInjection: save, deleteInjection: remove, refresh } = deps;
|
|
105
|
+
|
|
106
|
+
const [drafts, setDrafts] = useState<InjectionProfileLike[]>([]);
|
|
107
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
setDrafts(profiles);
|
|
111
|
+
}, [profiles]);
|
|
112
|
+
|
|
113
|
+
const patch = (i: number, next: Partial<InjectionProfileLike>) =>
|
|
114
|
+
setDrafts((d) => d.map((p, idx) => (idx === i ? { ...p, ...next } : p)));
|
|
115
|
+
|
|
116
|
+
const onSave = async (p: InjectionProfileLike) => {
|
|
117
|
+
setBusy(p.id || p.name);
|
|
118
|
+
try {
|
|
119
|
+
await save({
|
|
120
|
+
data: {
|
|
121
|
+
id: p.id || undefined,
|
|
122
|
+
name: p.name,
|
|
123
|
+
scope_type: p.scope_type,
|
|
124
|
+
scope_value: p.scope_value,
|
|
125
|
+
enabled: p.enabled,
|
|
126
|
+
head_html: p.head_html,
|
|
127
|
+
body_start_html: p.body_start_html,
|
|
128
|
+
body_end_html: p.body_end_html,
|
|
129
|
+
gtm_id: p.gtm_id,
|
|
130
|
+
ga4_id: p.ga4_id,
|
|
131
|
+
meta_pixel_id: p.meta_pixel_id,
|
|
132
|
+
tiktok_pixel_id: p.tiktok_pixel_id,
|
|
133
|
+
extra_meta: p.extra_meta,
|
|
134
|
+
sort_order: p.sort_order,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
toast.success("Saved — live within 30 seconds");
|
|
138
|
+
void refresh();
|
|
139
|
+
} catch (e) {
|
|
140
|
+
toast.error(e instanceof Error ? e.message : "Could not save");
|
|
141
|
+
} finally {
|
|
142
|
+
setBusy(null);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const onDelete = async (p: InjectionProfileLike, i: number) => {
|
|
147
|
+
if (!p.id) {
|
|
148
|
+
setDrafts((d) => d.filter((_, idx) => idx !== i));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!confirm(`Delete "${p.name}"?`)) return;
|
|
152
|
+
try {
|
|
153
|
+
await remove({ data: { id: p.id } });
|
|
154
|
+
toast.success("Deleted");
|
|
155
|
+
void refresh();
|
|
156
|
+
} catch (e) {
|
|
157
|
+
toast.error(e instanceof Error ? e.message : "Could not delete");
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="p-8 md:p-12 w-full">
|
|
163
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Admin</div>
|
|
164
|
+
<h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">Code & tracking</h1>
|
|
165
|
+
<p className="mt-4 max-w-3xl text-sm text-ink/65">
|
|
166
|
+
Inject custom code into the public site and configure tracking pixels. Each profile can
|
|
167
|
+
target all sites, one language, or one domain — global first, then language, then domain.
|
|
168
|
+
Nothing is injected inside the backoffice.
|
|
169
|
+
</p>
|
|
170
|
+
|
|
171
|
+
<div className="mt-8 space-y-8">
|
|
172
|
+
{drafts.map((p, i) => (
|
|
173
|
+
<section key={p.id || `new-${i}`} className="border border-ink/10 bg-white">
|
|
174
|
+
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-ink/10 px-5 py-4">
|
|
175
|
+
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
|
176
|
+
<input
|
|
177
|
+
value={p.name}
|
|
178
|
+
onChange={(e) => patch(i, { name: e.target.value })}
|
|
179
|
+
className="border border-transparent px-1 py-1 font-serif text-lg text-ink hover:border-ink/15 focus:border-sea focus:outline-none"
|
|
180
|
+
/>
|
|
181
|
+
<label className="inline-flex items-center gap-2 text-sm text-ink">
|
|
182
|
+
<input
|
|
183
|
+
type="checkbox"
|
|
184
|
+
checked={p.enabled}
|
|
185
|
+
onChange={(e) => patch(i, { enabled: e.target.checked })}
|
|
186
|
+
/>
|
|
187
|
+
Enabled
|
|
188
|
+
</label>
|
|
189
|
+
</div>
|
|
190
|
+
<div className="flex items-center gap-2">
|
|
191
|
+
<button
|
|
192
|
+
onClick={() =>
|
|
193
|
+
setDrafts((d) => [
|
|
194
|
+
...d,
|
|
195
|
+
{ ...p, id: "", name: `${p.name} (copy)`, enabled: false },
|
|
196
|
+
])
|
|
197
|
+
}
|
|
198
|
+
className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
199
|
+
>
|
|
200
|
+
<Copy className="size-3.5" /> Duplicate
|
|
201
|
+
</button>
|
|
202
|
+
<button
|
|
203
|
+
onClick={() => onDelete(p, i)}
|
|
204
|
+
className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-red-600 hover:border-red-400"
|
|
205
|
+
>
|
|
206
|
+
<Trash2 className="size-3.5" /> Delete
|
|
207
|
+
</button>
|
|
208
|
+
<button
|
|
209
|
+
onClick={() => onSave(p)}
|
|
210
|
+
disabled={busy !== null}
|
|
211
|
+
className="inline-flex items-center gap-2 bg-ink px-4 py-1.5 text-sm text-white disabled:opacity-40"
|
|
212
|
+
>
|
|
213
|
+
<Save className="size-3.5" /> Save
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
</header>
|
|
217
|
+
|
|
218
|
+
<div className="grid gap-6 p-5 lg:grid-cols-2">
|
|
219
|
+
<div className="space-y-4">
|
|
220
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
221
|
+
<label className="block">
|
|
222
|
+
<Label>Applies to</Label>
|
|
223
|
+
<select
|
|
224
|
+
value={p.scope_type}
|
|
225
|
+
onChange={(e) =>
|
|
226
|
+
patch(i, {
|
|
227
|
+
scope_type: e.target.value as InjectionScope,
|
|
228
|
+
scope_value: "",
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
|
|
232
|
+
>
|
|
233
|
+
<option value="global">All sites</option>
|
|
234
|
+
<option value="locale">One language</option>
|
|
235
|
+
<option value="domain">One domain</option>
|
|
236
|
+
</select>
|
|
237
|
+
</label>
|
|
238
|
+
{p.scope_type === "locale" ? (
|
|
239
|
+
<label className="block">
|
|
240
|
+
<Label>Language</Label>
|
|
241
|
+
<select
|
|
242
|
+
value={p.scope_value}
|
|
243
|
+
onChange={(e) => patch(i, { scope_value: e.target.value })}
|
|
244
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
|
|
245
|
+
>
|
|
246
|
+
<option value="">Select…</option>
|
|
247
|
+
{locales.map((l) => (
|
|
248
|
+
<option key={l} value={l}>
|
|
249
|
+
{l.toUpperCase()}
|
|
250
|
+
</option>
|
|
251
|
+
))}
|
|
252
|
+
</select>
|
|
253
|
+
</label>
|
|
254
|
+
) : null}
|
|
255
|
+
{p.scope_type === "domain" ? (
|
|
256
|
+
<label className="block">
|
|
257
|
+
<Label>Domain</Label>
|
|
258
|
+
<input
|
|
259
|
+
value={p.scope_value}
|
|
260
|
+
onChange={(e) => patch(i, { scope_value: e.target.value })}
|
|
261
|
+
list={`domains-${i}`}
|
|
262
|
+
placeholder="dev.voyara.se"
|
|
263
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
|
|
264
|
+
/>
|
|
265
|
+
<datalist id={`domains-${i}`}>
|
|
266
|
+
{domains.map((d) => (
|
|
267
|
+
<option key={d.domain} value={d.domain} />
|
|
268
|
+
))}
|
|
269
|
+
</datalist>
|
|
270
|
+
</label>
|
|
271
|
+
) : null}
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
275
|
+
<label className="block">
|
|
276
|
+
<Label>Google Tag Manager ID</Label>
|
|
277
|
+
<Input
|
|
278
|
+
value={p.gtm_id}
|
|
279
|
+
onChange={(v) => patch(i, { gtm_id: v })}
|
|
280
|
+
placeholder="GTM-XXXXXXX"
|
|
281
|
+
/>
|
|
282
|
+
</label>
|
|
283
|
+
<label className="block">
|
|
284
|
+
<Label>Google Analytics 4 ID</Label>
|
|
285
|
+
<Input
|
|
286
|
+
value={p.ga4_id}
|
|
287
|
+
onChange={(v) => patch(i, { ga4_id: v })}
|
|
288
|
+
placeholder="G-XXXXXXXXXX"
|
|
289
|
+
/>
|
|
290
|
+
</label>
|
|
291
|
+
<label className="block">
|
|
292
|
+
<Label>Meta (Facebook / Instagram) Pixel</Label>
|
|
293
|
+
<Input
|
|
294
|
+
value={p.meta_pixel_id}
|
|
295
|
+
onChange={(v) => patch(i, { meta_pixel_id: v })}
|
|
296
|
+
placeholder="1234567890"
|
|
297
|
+
/>
|
|
298
|
+
</label>
|
|
299
|
+
<label className="block">
|
|
300
|
+
<Label>TikTok Pixel</Label>
|
|
301
|
+
<Input
|
|
302
|
+
value={p.tiktok_pixel_id}
|
|
303
|
+
onChange={(v) => patch(i, { tiktok_pixel_id: v })}
|
|
304
|
+
placeholder="CXXXXXXXXXXXXXXXX"
|
|
305
|
+
/>
|
|
306
|
+
</label>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div>
|
|
310
|
+
<Label>Extra meta tags (verification codes)</Label>
|
|
311
|
+
<div className="space-y-2">
|
|
312
|
+
{p.extra_meta.map((m, mi) => (
|
|
313
|
+
<div key={mi} className="flex gap-2">
|
|
314
|
+
<input
|
|
315
|
+
value={m.name}
|
|
316
|
+
onChange={(e) =>
|
|
317
|
+
patch(i, {
|
|
318
|
+
extra_meta: p.extra_meta.map((x, xi) =>
|
|
319
|
+
xi === mi ? { ...x, name: e.target.value } : x,
|
|
320
|
+
),
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
placeholder="google-site-verification"
|
|
324
|
+
className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
|
|
325
|
+
/>
|
|
326
|
+
<input
|
|
327
|
+
value={m.content}
|
|
328
|
+
onChange={(e) =>
|
|
329
|
+
patch(i, {
|
|
330
|
+
extra_meta: p.extra_meta.map((x, xi) =>
|
|
331
|
+
xi === mi ? { ...x, content: e.target.value } : x,
|
|
332
|
+
),
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
placeholder="value"
|
|
336
|
+
className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
|
|
337
|
+
/>
|
|
338
|
+
<button
|
|
339
|
+
onClick={() =>
|
|
340
|
+
patch(i, {
|
|
341
|
+
extra_meta: p.extra_meta.filter((_, xi) => xi !== mi),
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
className="p-2 text-red-500 hover:text-red-700"
|
|
345
|
+
>
|
|
346
|
+
<Trash2 className="size-3.5" />
|
|
347
|
+
</button>
|
|
348
|
+
</div>
|
|
349
|
+
))}
|
|
350
|
+
<button
|
|
351
|
+
onClick={() =>
|
|
352
|
+
patch(i, { extra_meta: [...p.extra_meta, { name: "", content: "" }] })
|
|
353
|
+
}
|
|
354
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
355
|
+
>
|
|
356
|
+
<Plus className="size-4" /> Add meta tag
|
|
357
|
+
</button>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<div className="space-y-4">
|
|
363
|
+
<label className="block">
|
|
364
|
+
<Label>Inside <head></Label>
|
|
365
|
+
<Code
|
|
366
|
+
value={p.head_html}
|
|
367
|
+
onChange={(v) => patch(i, { head_html: v })}
|
|
368
|
+
placeholder="<!-- scripts, meta tags, verification -->"
|
|
369
|
+
/>
|
|
370
|
+
</label>
|
|
371
|
+
<label className="block">
|
|
372
|
+
<Label>After <body> tag</Label>
|
|
373
|
+
<Code
|
|
374
|
+
value={p.body_start_html}
|
|
375
|
+
onChange={(v) => patch(i, { body_start_html: v })}
|
|
376
|
+
placeholder="<!-- noscript pixels, chat widgets -->"
|
|
377
|
+
/>
|
|
378
|
+
</label>
|
|
379
|
+
<label className="block">
|
|
380
|
+
<Label>Before </body> tag (footer)</Label>
|
|
381
|
+
<Code
|
|
382
|
+
value={p.body_end_html}
|
|
383
|
+
onChange={(v) => patch(i, { body_end_html: v })}
|
|
384
|
+
placeholder="<!-- deferred scripts -->"
|
|
385
|
+
/>
|
|
386
|
+
</label>
|
|
387
|
+
<p className="text-xs text-ink/50">
|
|
388
|
+
Tracking IDs above generate their official snippets automatically — you only need
|
|
389
|
+
raw code for anything not covered.
|
|
390
|
+
</p>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</section>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<button
|
|
398
|
+
onClick={() => setDrafts((d) => [...d, blankProfile()])}
|
|
399
|
+
className="mt-8 inline-flex items-center gap-2 border border-ink/20 px-5 py-2.5 text-sm text-ink hover:border-sea"
|
|
400
|
+
>
|
|
401
|
+
<Plus className="size-4" /> New profile
|
|
402
|
+
</button>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|