@bygghammar/cms-platform 0.24.0 → 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 CHANGED
@@ -9,6 +9,13 @@ always compatible. In addition, `PLATFORM_CONTRACT_VERSION` in
9
9
  or adapter capabilities an older site does not have. Sites assert that constant
10
10
  in a test and upgrade deliberately.
11
11
 
12
+ ## 0.25.0
13
+
14
+ - Added shared backoffice views: `TrackingView` (code & tracking profiles),
15
+ `DomainsView` (languages & domains), `NavigationView` (navigation & footer),
16
+ `ContactDetailsView` and `VersionView`. Each takes host-supplied data and
17
+ actions, so site routes become thin adapters.
18
+
12
19
  ## 0.24.0
13
20
 
14
21
  - Added `backoffice/UsersView`: shared account administration (create users,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.24.0",
3
+ "version": "0.25.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,
@@ -120,7 +120,12 @@
120
120
  "./backoffice/RolesView": "./src/backoffice/RolesView.tsx",
121
121
  "./backoffice/AuditView": "./src/backoffice/AuditView.tsx",
122
122
  "./backoffice/EmailLogsView": "./src/backoffice/EmailLogsView.tsx",
123
- "./backoffice/LogsView": "./src/backoffice/LogsView.tsx"
123
+ "./backoffice/LogsView": "./src/backoffice/LogsView.tsx",
124
+ "./backoffice/TrackingView": "./src/backoffice/TrackingView.tsx",
125
+ "./backoffice/DomainsView": "./src/backoffice/DomainsView.tsx",
126
+ "./backoffice/NavigationView": "./src/backoffice/NavigationView.tsx",
127
+ "./backoffice/ContactDetailsView": "./src/backoffice/ContactDetailsView.tsx",
128
+ "./backoffice/VersionView": "./src/backoffice/VersionView.tsx"
124
129
  },
125
130
  "dependencies": {
126
131
  "fflate": "^0.8.3"
@@ -196,4 +201,4 @@
196
201
  "bin": {
197
202
  "cms-platform-migrations": "scripts/sync-migrations.mjs"
198
203
  }
199
- }
204
+ }
@@ -0,0 +1,108 @@
1
+ import { useEffect, useState } from "react";
2
+ import { toast } from "sonner";
3
+
4
+ /** One editable company field. */
5
+ export type ContactField = { key: string; label: string; type?: string };
6
+
7
+ /** The canonical company detail fields every site shares. */
8
+ export const CONTACT_FIELDS: ContactField[] = [
9
+ { key: "locationName", label: "Name" },
10
+ { key: "street", label: "Address" },
11
+ { key: "postCode", label: "Post code" },
12
+ { key: "city", label: "City" },
13
+ { key: "country", label: "Country" },
14
+ { key: "legalName", label: "Company details (legal name)" },
15
+ { key: "orgNumber", label: "Org. number" },
16
+ { key: "email", label: "Email", type: "email" },
17
+ { key: "phone", label: "Phone", type: "tel" },
18
+ ];
19
+
20
+ /** Host-provided data and actions for the contact details screen. */
21
+ export type ContactDetailsDeps = {
22
+ /** Code defaults for the company block. */
23
+ defaults: Record<string, string>;
24
+ /** Saved override for the company block, if any. */
25
+ stored: Record<string, string> | undefined;
26
+ loading?: boolean;
27
+ /** Fields to render; defaults to `CONTACT_FIELDS`. */
28
+ fields?: ContactField[];
29
+ saveSection: (input: { data: { key: string; data: unknown } }) => Promise<unknown>;
30
+ refresh: () => Promise<unknown> | void;
31
+ };
32
+
33
+ const input =
34
+ "mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm focus:border-sea focus:outline-none";
35
+ const lbl = "text-[0.65rem] uppercase tracking-[0.22em] text-ink/55";
36
+
37
+ /**
38
+ * Host-agnostic contact details screen. These company details are the single
39
+ * source of truth for the contact section, the footer and structured data.
40
+ */
41
+ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
42
+ const { defaults, stored, loading, fields = CONTACT_FIELDS, saveSection, refresh } = deps;
43
+
44
+ const [form, setForm] = useState<Record<string, string>>(defaults);
45
+ const [saving, setSaving] = useState(false);
46
+
47
+ useEffect(() => {
48
+ if (stored && typeof stored === "object") setForm({ ...defaults, ...stored });
49
+ }, [stored, defaults]);
50
+
51
+ const set = (key: string, value: string) => setForm((f) => ({ ...f, [key]: value }));
52
+
53
+ async function onSave() {
54
+ setSaving(true);
55
+ try {
56
+ await saveSection({ data: { key: "company", data: form } });
57
+ await refresh();
58
+ toast.success("Contact details saved");
59
+ } catch (e) {
60
+ toast.error(e instanceof Error ? e.message : "Could not save");
61
+ } finally {
62
+ setSaving(false);
63
+ }
64
+ }
65
+
66
+ return (
67
+ <div className="p-8 md:p-12 w-full">
68
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Settings</div>
69
+ <h2 className="mt-2 font-serif text-3xl text-ink">Contact details</h2>
70
+ <p className="mt-3 text-sm text-ink/65">
71
+ These company details are the single source of truth. They are used in the website contact
72
+ section, the footer and the structured data for search engines.
73
+ </p>
74
+
75
+ <div className="mt-10 grid gap-5 sm:grid-cols-2">
76
+ {fields.map((f) => (
77
+ <label key={f.key} className="block">
78
+ <span className={lbl}>{f.label}</span>
79
+ <input
80
+ className={input}
81
+ type={f.type ?? "text"}
82
+ value={form[f.key] ?? ""}
83
+ onChange={(e) => set(f.key, e.target.value)}
84
+ />
85
+ </label>
86
+ ))}
87
+ </div>
88
+
89
+ <div className="mt-10 flex items-center gap-4">
90
+ <button
91
+ type="button"
92
+ onClick={onSave}
93
+ disabled={saving || !!loading}
94
+ className="bg-ink px-6 py-3 text-xs uppercase tracking-[0.22em] text-paper disabled:opacity-50"
95
+ >
96
+ {saving ? "Saving..." : "Save details"}
97
+ </button>
98
+ <button
99
+ type="button"
100
+ onClick={() => setForm(defaults)}
101
+ className="text-xs uppercase tracking-[0.22em] text-ink/55 hover:text-ink"
102
+ >
103
+ Reset to defaults
104
+ </button>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,264 @@
1
+ import { useState } from "react";
2
+ import { toast } from "sonner";
3
+ import { Trash2, Plus, Check, X } from "lucide-react";
4
+
5
+ /** One language-to-domain mapping. */
6
+ export type LocaleDomainRow = {
7
+ locale: string;
8
+ domain: string;
9
+ isDefault: boolean;
10
+ isAdminDomain: boolean;
11
+ };
12
+
13
+ /** Host-provided data, actions and navigation for the domains screen. */
14
+ export type DomainsDeps = {
15
+ domains: LocaleDomainRow[];
16
+ locales: readonly string[];
17
+ defaultLocale: string;
18
+ /** Path back to the backoffice home, e.g. `/kontoret`. */
19
+ backPath: string;
20
+ /** Example domain shown as the input placeholder. */
21
+ domainPlaceholder?: string;
22
+ saveDomain: (input: { data: LocaleDomainRow }) => Promise<unknown>;
23
+ deleteDomain: (input: { data: { locale: string } }) => Promise<unknown>;
24
+ refresh: () => Promise<unknown> | void;
25
+ };
26
+
27
+ /** Host-agnostic languages & domains screen. */
28
+ export function DomainsView({ deps }: { deps: DomainsDeps }) {
29
+ const {
30
+ domains,
31
+ locales,
32
+ defaultLocale: DEFAULT_LOCALE,
33
+ backPath,
34
+ domainPlaceholder = "www.example.com",
35
+ saveDomain: save,
36
+ deleteDomain: del,
37
+ refresh,
38
+ } = deps;
39
+ const [busy, setBusy] = useState(false);
40
+ const [editing, setEditing] = useState<LocaleDomainRow | null>(null);
41
+ const [draft, setDraft] = useState<Partial<LocaleDomainRow>>({
42
+ locale: DEFAULT_LOCALE,
43
+ domain: "",
44
+ isDefault: false,
45
+ isAdminDomain: false,
46
+ });
47
+
48
+ const reset = () => {
49
+ setEditing(null);
50
+ setDraft({
51
+ locale: DEFAULT_LOCALE,
52
+ domain: "",
53
+ isDefault: false,
54
+ isAdminDomain: false,
55
+ });
56
+ };
57
+
58
+ const submit = async (e: React.FormEvent) => {
59
+ e.preventDefault();
60
+ if (!draft.locale || !draft.domain) return;
61
+ setBusy(true);
62
+ try {
63
+ await save({
64
+ data: {
65
+ locale: draft.locale,
66
+ domain: draft.domain,
67
+ isDefault: !!draft.isDefault,
68
+ isAdminDomain: !!draft.isAdminDomain,
69
+ },
70
+ });
71
+ await refresh();
72
+ toast.success("Domain saved");
73
+ reset();
74
+ } catch (err) {
75
+ toast.error(err instanceof Error ? err.message : "Could not save");
76
+ } finally {
77
+ setBusy(false);
78
+ }
79
+ };
80
+
81
+ const remove = async (locale: string) => {
82
+ if (!confirm("Remove this domain mapping?")) return;
83
+ setBusy(true);
84
+ try {
85
+ await del({ data: { locale } });
86
+ await refresh();
87
+ toast.success("Domain removed");
88
+ } catch (err) {
89
+ toast.error(err instanceof Error ? err.message : "Could not remove");
90
+ } finally {
91
+ setBusy(false);
92
+ }
93
+ };
94
+
95
+ const startEdit = (d: LocaleDomainRow) => {
96
+ setEditing(d);
97
+ setDraft({ ...d });
98
+ };
99
+
100
+ return (
101
+ <main className="p-6 md:p-10 w-full">
102
+ <div className="flex items-center justify-between">
103
+ <div>
104
+ <div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50">Backoffice</div>
105
+ <h1 className="mt-1 font-serif text-3xl">Languages & domains</h1>
106
+ </div>
107
+ <a
108
+ href={backPath}
109
+ className="text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink"
110
+ >
111
+ ← Back
112
+ </a>
113
+ </div>
114
+
115
+ <p className="mt-4 max-w-2xl text-sm text-ink/70">
116
+ Map each language to its own domain. Mark one domain as the default (fallback) and one as
117
+ the admin domain where the backoffice is only available.
118
+ </p>
119
+
120
+ <section className="mt-10">
121
+ <h2 className="font-serif text-xl">Current domains</h2>
122
+ <div className="mt-4 overflow-x-auto border border-ink/10">
123
+ <table className="w-full text-sm">
124
+ <thead className="bg-ink text-paper text-left">
125
+ <tr>
126
+ <th className="px-4 py-3 font-medium">Language</th>
127
+ <th className="px-4 py-3 font-medium">Domain</th>
128
+ <th className="px-4 py-3 font-medium">Default</th>
129
+ <th className="px-4 py-3 font-medium">Admin</th>
130
+ <th className="px-4 py-3 font-medium"></th>
131
+ </tr>
132
+ </thead>
133
+ <tbody>
134
+ {domains.length === 0 && (
135
+ <tr>
136
+ <td colSpan={5} className="px-4 py-8 text-center text-ink/50">
137
+ No domains configured yet.
138
+ </td>
139
+ </tr>
140
+ )}
141
+ {domains.map((d) => (
142
+ <tr
143
+ key={d.locale}
144
+ className="border-b border-ink/5 last:border-b-0 hover:bg-ink/[0.02]"
145
+ >
146
+ <td className="px-4 py-3 font-medium uppercase">{d.locale}</td>
147
+ <td className="px-4 py-3 font-mono text-ink/80">{d.domain}</td>
148
+ <td className="px-4 py-3">
149
+ {d.isDefault ? (
150
+ <Check className="size-4 text-sea" />
151
+ ) : (
152
+ <X className="size-4 text-ink/30" />
153
+ )}
154
+ </td>
155
+ <td className="px-4 py-3">
156
+ {d.isAdminDomain ? (
157
+ <Check className="size-4 text-sea" />
158
+ ) : (
159
+ <X className="size-4 text-ink/30" />
160
+ )}
161
+ </td>
162
+ <td className="px-4 py-3">
163
+ <div className="flex items-center gap-2">
164
+ <button
165
+ onClick={() => startEdit(d)}
166
+ className="text-xs uppercase tracking-[0.15em] text-ink/70 hover:text-ink underline underline-offset-2"
167
+ >
168
+ Edit
169
+ </button>
170
+ <button
171
+ onClick={() => remove(d.locale)}
172
+ disabled={busy}
173
+ className="p-1.5 text-ink/50 hover:text-red-600 transition-colors"
174
+ aria-label="Remove"
175
+ >
176
+ <Trash2 className="size-4" />
177
+ </button>
178
+ </div>
179
+ </td>
180
+ </tr>
181
+ ))}
182
+ </tbody>
183
+ </table>
184
+ </div>
185
+ </section>
186
+
187
+ <section className="mt-12 max-w-xl">
188
+ <h2 className="font-serif text-xl">{editing ? "Edit domain" : "Add domain"}</h2>
189
+ <form onSubmit={submit} className="mt-4 space-y-4">
190
+ <label className="block">
191
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">Language</span>
192
+ <select
193
+ value={draft.locale}
194
+ onChange={(e) => setDraft((d) => ({ ...d, locale: e.target.value }))}
195
+ className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink focus:border-sea focus:outline-none"
196
+ >
197
+ {locales.map((l) => (
198
+ <option key={l} value={l}>
199
+ {l.toUpperCase()}
200
+ </option>
201
+ ))}
202
+ </select>
203
+ </label>
204
+
205
+ <label className="block">
206
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">Domain</span>
207
+ <input
208
+ type="text"
209
+ required
210
+ value={draft.domain}
211
+ onChange={(e) => setDraft((d) => ({ ...d, domain: e.target.value }))}
212
+ placeholder={domainPlaceholder}
213
+ className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink placeholder:text-ink/40 focus:border-sea focus:outline-none"
214
+ />
215
+ <span className="mt-1 block text-xs text-ink/50">
216
+ Enter the domain without https:// and without trailing slash.
217
+ </span>
218
+ </label>
219
+
220
+ <div className="flex flex-col gap-3 pt-2">
221
+ <label className="flex items-center gap-3 text-sm text-ink/80">
222
+ <input
223
+ type="checkbox"
224
+ checked={!!draft.isDefault}
225
+ onChange={(e) => setDraft((d) => ({ ...d, isDefault: e.target.checked }))}
226
+ className="size-4 border-ink/30 text-sea focus:ring-sea"
227
+ />
228
+ Default domain (fallback when nothing else matches)
229
+ </label>
230
+ <label className="flex items-center gap-3 text-sm text-ink/80">
231
+ <input
232
+ type="checkbox"
233
+ checked={!!draft.isAdminDomain}
234
+ onChange={(e) => setDraft((d) => ({ ...d, isAdminDomain: e.target.checked }))}
235
+ className="size-4 border-ink/30 text-sea focus:ring-sea"
236
+ />
237
+ Admin domain (/kontoret only accessible here)
238
+ </label>
239
+ </div>
240
+
241
+ <div className="flex items-center gap-3 pt-4">
242
+ <button
243
+ type="submit"
244
+ disabled={busy}
245
+ className="inline-flex items-center gap-2 border border-ink bg-ink px-6 py-3 text-xs uppercase tracking-[0.22em] text-paper hover:bg-paper hover:text-ink transition-colors disabled:opacity-60"
246
+ >
247
+ <Plus className="size-4" />
248
+ {editing ? "Save changes" : "Add domain"}
249
+ </button>
250
+ {editing && (
251
+ <button
252
+ type="button"
253
+ onClick={reset}
254
+ className="text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink underline underline-offset-4"
255
+ >
256
+ Cancel
257
+ </button>
258
+ )}
259
+ </div>
260
+ </form>
261
+ </section>
262
+ </main>
263
+ );
264
+ }
@@ -0,0 +1,133 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import type { ComponentType } from "react";
3
+ import { RotateCcw, Save } from "lucide-react";
4
+ import { toast } from "sonner";
5
+
6
+ /** The two site-wide content sections this screen edits. */
7
+ export type NavigationSectionKey = "nav" | "footer";
8
+
9
+ /** Host-provided data and actions for the navigation & footer screen. */
10
+ export type NavigationDeps = {
11
+ /** Saved overrides keyed by section, as stored in the CMS. */
12
+ overrides: Record<string, unknown> | undefined;
13
+ /** Resolved live site content (defaults merged with overrides). */
14
+ site: Record<string, unknown>;
15
+ /** Code defaults, used by "reset to default". */
16
+ defaults: Record<string, unknown>;
17
+ /** The host's section editor, already wired to its locales and media. */
18
+ SectionForm: ComponentType<{ value: unknown; onChange: (next: unknown) => void }>;
19
+ saveSection: (input: { data: { key: string; data: unknown } }) => Promise<unknown>;
20
+ refresh: () => Promise<unknown> | void;
21
+ };
22
+
23
+ function Editor({
24
+ deps,
25
+ sectionKey,
26
+ title,
27
+ description,
28
+ }: {
29
+ deps: NavigationDeps;
30
+ sectionKey: NavigationSectionKey;
31
+ title: string;
32
+ description: string;
33
+ }) {
34
+ const { overrides, site, defaults, SectionForm, saveSection, refresh } = deps;
35
+
36
+ const current = useMemo(() => {
37
+ const override = overrides?.[sectionKey];
38
+ return override ?? site[sectionKey];
39
+ }, [overrides, site, sectionKey]);
40
+
41
+ const [draft, setDraft] = useState<unknown>(current);
42
+ const [busy, setBusy] = useState(false);
43
+ const [dirty, setDirty] = useState(false);
44
+
45
+ useEffect(() => {
46
+ setDraft(current);
47
+ setDirty(false);
48
+ }, [current]);
49
+
50
+ const onSave = async () => {
51
+ setBusy(true);
52
+ try {
53
+ await saveSection({ data: { key: sectionKey, data: draft } });
54
+ await refresh();
55
+ toast.success(`${title} saved`);
56
+ setDirty(false);
57
+ } catch (err) {
58
+ toast.error(err instanceof Error ? err.message : "Could not save");
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ };
63
+
64
+ const onReset = () => {
65
+ setDraft(defaults[sectionKey]);
66
+ setDirty(true);
67
+ toast.info("Reset to default — remember to save");
68
+ };
69
+
70
+ return (
71
+ <section className="border border-ink/10 bg-white">
72
+ <header className="border-b border-ink/10 px-6 py-5">
73
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
74
+ {sectionKey}
75
+ {dirty && <span className="ml-3 text-sea">unsaved</span>}
76
+ </div>
77
+ <h2 className="mt-2 font-serif text-2xl text-ink">{title}</h2>
78
+ <p className="mt-2 text-sm text-ink/60">{description}</p>
79
+ </header>
80
+
81
+ <div className="p-6">
82
+ <SectionForm
83
+ value={draft}
84
+ onChange={(next) => {
85
+ setDraft(next);
86
+ setDirty(true);
87
+ }}
88
+ />
89
+
90
+ <div className="mt-6 flex flex-wrap items-center gap-3">
91
+ <button
92
+ onClick={onSave}
93
+ disabled={busy || !dirty}
94
+ className="inline-flex items-center gap-2 bg-ink text-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-ink/85 disabled:opacity-50"
95
+ >
96
+ <Save className="size-4" />
97
+ {busy ? "Saving…" : "Save"}
98
+ </button>
99
+ <button
100
+ onClick={onReset}
101
+ className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink"
102
+ >
103
+ <RotateCcw className="size-4" />
104
+ Reset to default
105
+ </button>
106
+ </div>
107
+ </div>
108
+ </section>
109
+ );
110
+ }
111
+
112
+ /** Host-agnostic navigation & footer editor. */
113
+ export function NavigationView({ deps }: { deps: NavigationDeps }) {
114
+ return (
115
+ <div className="p-8 md:p-12 w-full">
116
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Admin</div>
117
+ <h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">Navigation &amp; footer</h1>
118
+ <p className="mt-3 text-ink/65 max-w-2xl">
119
+ Site-wide top menu and footer. Changes apply to every page.
120
+ </p>
121
+
122
+ <div className="mt-8 grid gap-6 xl:grid-cols-2 items-start">
123
+ <Editor deps={deps} sectionKey="nav" title="Navigation" description="Top menu items." />
124
+ <Editor
125
+ deps={deps}
126
+ sectionKey="footer"
127
+ title="Footer"
128
+ description="Footer columns and legal."
129
+ />
130
+ </div>
131
+ </div>
132
+ );
133
+ }
@@ -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
+ }
@@ -0,0 +1,109 @@
1
+ import { Package } from "lucide-react";
2
+
3
+ /** Build metadata injected at compile time by the host. */
4
+ export type PlatformBuildInfo = {
5
+ appVersion?: string | null;
6
+ buildTime?: string | null;
7
+ platformVersion?: string | null;
8
+ platformRange?: string | null;
9
+ };
10
+
11
+ /** One published platform release. */
12
+ export type PlatformRelease = {
13
+ version: string;
14
+ publishedAt: string | null;
15
+ current?: boolean;
16
+ };
17
+
18
+ /** Host-provided data for the version screen. */
19
+ export type VersionDeps = {
20
+ build: PlatformBuildInfo | undefined;
21
+ releases: PlatformRelease[];
22
+ loading?: boolean;
23
+ /** Package name shown in the release history copy. */
24
+ packageName?: string;
25
+ };
26
+
27
+ function fmt(value: string | null | undefined) {
28
+ if (!value) return "—";
29
+ const d = new Date(value);
30
+ if (Number.isNaN(d.getTime())) return value;
31
+ return d.toLocaleString("sv-SE", { timeZone: "UTC", hour12: false }) + " UTC";
32
+ }
33
+
34
+ /** Host-agnostic CMS & platform version screen. */
35
+ export function VersionView({ deps }: { deps: VersionDeps }) {
36
+ const { build, releases, loading, packageName = "@bygghammar/cms-platform" } = deps;
37
+
38
+ return (
39
+ <div className="p-8 md:p-12 w-full max-w-4xl">
40
+ <div className="flex items-center gap-3">
41
+ <Package className="size-5 text-ink/70" />
42
+ <h2 className="font-serif text-xl text-ink">CMS &amp; platform version</h2>
43
+ </div>
44
+ <p className="mt-2 text-sm text-ink/60">
45
+ Which build of this site is live and which release of the shared CMS platform package it
46
+ runs on.
47
+ </p>
48
+
49
+ {loading ? (
50
+ <p className="mt-8 text-sm text-ink/50">Loading…</p>
51
+ ) : (
52
+ <>
53
+ <dl className="mt-8 grid gap-4 sm:grid-cols-2">
54
+ {[
55
+ { label: "CMS build", value: build?.appVersion ?? "—" },
56
+ { label: "Built at", value: fmt(build?.buildTime) },
57
+ { label: "Platform package", value: build?.platformVersion ?? "—" },
58
+ { label: "Declared range", value: build?.platformRange ?? "—" },
59
+ ].map((row) => (
60
+ <div key={row.label} className="border border-ink/10 bg-white p-5">
61
+ <dt className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/50">
62
+ {row.label}
63
+ </dt>
64
+ <dd className="mt-2 font-mono text-sm text-ink">{row.value}</dd>
65
+ </div>
66
+ ))}
67
+ </dl>
68
+
69
+ <h3 className="mt-12 font-serif text-lg text-ink">Latest platform releases</h3>
70
+ <p className="mt-1 text-sm text-ink/60">
71
+ The three most recent published versions of {packageName} with their publish dates.
72
+ </p>
73
+ <div className="mt-4 border border-ink/10 bg-white">
74
+ {releases.length === 0 ? (
75
+ <p className="p-5 text-sm text-ink/50">Release history unavailable right now.</p>
76
+ ) : (
77
+ <table className="w-full text-sm">
78
+ <thead>
79
+ <tr className="border-b border-ink/10 text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/50">
80
+ <th className="px-5 py-3">Version</th>
81
+ <th className="px-5 py-3">Published</th>
82
+ <th className="px-5 py-3">Status</th>
83
+ </tr>
84
+ </thead>
85
+ <tbody>
86
+ {releases.map((r) => (
87
+ <tr key={r.version} className="border-b border-ink/5 last:border-0">
88
+ <td className="px-5 py-3 font-mono text-ink">{r.version}</td>
89
+ <td className="px-5 py-3 text-ink/70">{fmt(r.publishedAt)}</td>
90
+ <td className="px-5 py-3">
91
+ {r.current ? (
92
+ <span className="inline-flex items-center rounded-full bg-sea/10 px-3 py-1 text-[0.7rem] uppercase tracking-[0.16em] text-sea">
93
+ In use
94
+ </span>
95
+ ) : (
96
+ <span className="text-ink/40">—</span>
97
+ )}
98
+ </td>
99
+ </tr>
100
+ ))}
101
+ </tbody>
102
+ </table>
103
+ )}
104
+ </div>
105
+ </>
106
+ )}
107
+ </div>
108
+ );
109
+ }