@bygghammar/cms-platform 0.24.0 → 0.26.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,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
+ }