@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.
@@ -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 &amp; 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 &lt;head&gt;</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 &lt;body&gt; 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 &lt;/body&gt; 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
+ }