@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.
|
|
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 & 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 & 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
|
+
}
|
|
@@ -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 & 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
|
+
}
|