@bygghammar/cms-platform 0.22.0 → 0.23.1
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 +11 -0
- package/package.json +5 -2
- package/src/backoffice/SiteSettingsView.tsx +258 -0
- package/src/config/site-config.ts +239 -0
- package/src/server/site-settings-core.ts +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,17 @@ 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.23.0
|
|
13
|
+
|
|
14
|
+
- Added `config/site-config`: the typed `SiteConfig` contract, `defineSiteConfig`
|
|
15
|
+
validation, feature flags, the overridable-key whitelist and
|
|
16
|
+
`resolveSiteConfig` (code defaults + database overrides).
|
|
17
|
+
- Added `server/site-settings-core`: load/save/clear for the `site_settings`
|
|
18
|
+
key/value store, running as the caller so RLS applies.
|
|
19
|
+
- Added `backoffice/SiteSettingsView`: shared site settings screen for name,
|
|
20
|
+
company details, module flags and brand tokens, with per-section reset back to
|
|
21
|
+
the code default.
|
|
22
|
+
|
|
12
23
|
## 0.22.0
|
|
13
24
|
|
|
14
25
|
- Added `docs/NEW-SITE.md`: the supported starter path for a new site (install,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
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,
|
|
@@ -112,7 +112,10 @@
|
|
|
112
112
|
"./server/page-seo-core": "./src/server/page-seo-core.ts",
|
|
113
113
|
"./server/media-cleanup-core": "./src/server/media-cleanup-core.ts",
|
|
114
114
|
"./server/media-usage-core": "./src/server/media-usage-core.ts",
|
|
115
|
-
"./server/forms-core": "./src/server/forms-core.ts"
|
|
115
|
+
"./server/forms-core": "./src/server/forms-core.ts",
|
|
116
|
+
"./config/site-config": "./src/config/site-config.ts",
|
|
117
|
+
"./server/site-settings-core": "./src/server/site-settings-core.ts",
|
|
118
|
+
"./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx"
|
|
116
119
|
},
|
|
117
120
|
"dependencies": {
|
|
118
121
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Save, RotateCcw } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_FEATURE_FLAGS,
|
|
7
|
+
EMPTY_COMPANY,
|
|
8
|
+
type SiteConfig,
|
|
9
|
+
type SiteFeatureFlags,
|
|
10
|
+
type SiteSettingOverrides,
|
|
11
|
+
} from "../config/site-config";
|
|
12
|
+
|
|
13
|
+
export type SiteSettingsDeps = {
|
|
14
|
+
/** Code config with any stored overrides already applied. */
|
|
15
|
+
config: SiteConfig;
|
|
16
|
+
/** Raw stored overrides, used to show what differs from code. */
|
|
17
|
+
overrides: SiteSettingOverrides;
|
|
18
|
+
saveSettings: (input: { data: SiteSettingOverrides }) => Promise<unknown>;
|
|
19
|
+
clearSetting: (input: { data: { key: string } }) => Promise<unknown>;
|
|
20
|
+
refresh: () => Promise<unknown> | void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const FEATURE_LABELS: Record<keyof SiteFeatureFlags, string> = {
|
|
24
|
+
trips: "Trips & departures",
|
|
25
|
+
courses: "Courses",
|
|
26
|
+
reviews: "Reviews",
|
|
27
|
+
email: "Transactional email",
|
|
28
|
+
tracking: "Code & tracking",
|
|
29
|
+
backup: "Backup & restore",
|
|
30
|
+
domains: "Custom domains",
|
|
31
|
+
forms: "Forms",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const COMPANY_LABELS: Record<keyof typeof EMPTY_COMPANY, string> = {
|
|
35
|
+
legalName: "Legal name",
|
|
36
|
+
locationName: "Location name",
|
|
37
|
+
street: "Street",
|
|
38
|
+
postCode: "Post code",
|
|
39
|
+
city: "City",
|
|
40
|
+
country: "Country",
|
|
41
|
+
orgNumber: "Org. number",
|
|
42
|
+
email: "Email",
|
|
43
|
+
phone: "Phone",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const inputClass =
|
|
47
|
+
"w-full border border-ink/15 bg-white px-3 py-2 text-sm text-ink focus:border-sea focus:outline-none";
|
|
48
|
+
const labelClass = "block text-xs uppercase tracking-wider text-ink/60 mb-1";
|
|
49
|
+
const cardClass = "border border-ink/10 bg-white p-6";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Host-agnostic site settings screen. Code config is always the baseline; this
|
|
53
|
+
* screen writes only the overridable subset into the database.
|
|
54
|
+
*/
|
|
55
|
+
export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
56
|
+
const { config, overrides, saveSettings, clearSetting, refresh } = deps;
|
|
57
|
+
const [name, setName] = useState(config.name);
|
|
58
|
+
const [company, setCompany] = useState({ ...EMPTY_COMPANY, ...config.company });
|
|
59
|
+
const [features, setFeatures] = useState<SiteFeatureFlags>({ ...config.features });
|
|
60
|
+
const [brand, setBrand] = useState<Record<string, string>>({ ...config.brand });
|
|
61
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setName(config.name);
|
|
65
|
+
setCompany({ ...EMPTY_COMPANY, ...config.company });
|
|
66
|
+
setFeatures({ ...config.features });
|
|
67
|
+
setBrand({ ...config.brand });
|
|
68
|
+
}, [config]);
|
|
69
|
+
|
|
70
|
+
const save = async (key: string, patch: SiteSettingOverrides) => {
|
|
71
|
+
setBusy(key);
|
|
72
|
+
try {
|
|
73
|
+
await saveSettings({ data: patch });
|
|
74
|
+
toast.success("Saved");
|
|
75
|
+
await refresh();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
toast.error(e instanceof Error ? e.message : "Failed to save");
|
|
78
|
+
} finally {
|
|
79
|
+
setBusy(null);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const reset = async (key: string) => {
|
|
84
|
+
setBusy(key);
|
|
85
|
+
try {
|
|
86
|
+
await clearSetting({ data: { key } });
|
|
87
|
+
toast.success("Reset to the value defined in code");
|
|
88
|
+
await refresh();
|
|
89
|
+
} catch (e) {
|
|
90
|
+
toast.error(e instanceof Error ? e.message : "Failed to reset");
|
|
91
|
+
} finally {
|
|
92
|
+
setBusy(null);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const overridden = (key: keyof SiteSettingOverrides) => overrides[key] !== undefined;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="p-8 md:p-12 w-full">
|
|
100
|
+
<h1 className="font-serif text-3xl md:text-4xl text-ink">Site settings</h1>
|
|
101
|
+
<p className="mt-3 max-w-2xl text-sm text-ink/60">
|
|
102
|
+
These values come from the site configuration in code. Anything you change here is stored as
|
|
103
|
+
an override in the database and can be reset back to the code default.
|
|
104
|
+
</p>
|
|
105
|
+
|
|
106
|
+
<dl className="mt-8 grid gap-4 md:grid-cols-4">
|
|
107
|
+
{[
|
|
108
|
+
["Site key", config.key],
|
|
109
|
+
["Public origin", config.origin],
|
|
110
|
+
["Locales", config.locales.join(", ")],
|
|
111
|
+
["Backoffice", `${config.backofficePath} (${config.backofficeLocale})`],
|
|
112
|
+
].map(([label, value]) => (
|
|
113
|
+
<div key={label} className="border border-ink/10 bg-white p-4">
|
|
114
|
+
<dt className="text-xs uppercase tracking-wider text-ink/50">{label}</dt>
|
|
115
|
+
<dd className="mt-1 text-sm text-ink">{value}</dd>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</dl>
|
|
119
|
+
|
|
120
|
+
<section className={`mt-8 ${cardClass}`}>
|
|
121
|
+
<SectionHeader
|
|
122
|
+
title="Site name"
|
|
123
|
+
overridden={overridden("name")}
|
|
124
|
+
onReset={() => reset("name")}
|
|
125
|
+
busy={busy === "name"}
|
|
126
|
+
/>
|
|
127
|
+
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
|
128
|
+
<label className="block">
|
|
129
|
+
<span className={labelClass}>Name</span>
|
|
130
|
+
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
|
131
|
+
</label>
|
|
132
|
+
<SaveButton busy={busy === "name"} onClick={() => save("name", { name })} />
|
|
133
|
+
</div>
|
|
134
|
+
</section>
|
|
135
|
+
|
|
136
|
+
<section className={`mt-6 ${cardClass}`}>
|
|
137
|
+
<SectionHeader
|
|
138
|
+
title="Company & contact details"
|
|
139
|
+
overridden={overridden("company")}
|
|
140
|
+
onReset={() => reset("company")}
|
|
141
|
+
busy={busy === "company"}
|
|
142
|
+
/>
|
|
143
|
+
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
|
144
|
+
{(Object.keys(COMPANY_LABELS) as (keyof typeof EMPTY_COMPANY)[]).map((key) => (
|
|
145
|
+
<label key={key} className="block">
|
|
146
|
+
<span className={labelClass}>{COMPANY_LABELS[key]}</span>
|
|
147
|
+
<input
|
|
148
|
+
className={inputClass}
|
|
149
|
+
value={company[key] ?? ""}
|
|
150
|
+
onChange={(e) => setCompany({ ...company, [key]: e.target.value })}
|
|
151
|
+
/>
|
|
152
|
+
</label>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
<div className="mt-4">
|
|
156
|
+
<SaveButton busy={busy === "company"} onClick={() => save("company", { company })} />
|
|
157
|
+
</div>
|
|
158
|
+
</section>
|
|
159
|
+
|
|
160
|
+
<section className={`mt-6 ${cardClass}`}>
|
|
161
|
+
<SectionHeader
|
|
162
|
+
title="Modules"
|
|
163
|
+
overridden={overridden("features")}
|
|
164
|
+
onReset={() => reset("features")}
|
|
165
|
+
busy={busy === "features"}
|
|
166
|
+
/>
|
|
167
|
+
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
168
|
+
{(Object.keys(DEFAULT_FEATURE_FLAGS) as (keyof SiteFeatureFlags)[]).map((flag) => (
|
|
169
|
+
<label key={flag} className="flex items-center gap-3 text-sm text-ink">
|
|
170
|
+
<input
|
|
171
|
+
type="checkbox"
|
|
172
|
+
checked={features[flag]}
|
|
173
|
+
onChange={(e) => setFeatures({ ...features, [flag]: e.target.checked })}
|
|
174
|
+
/>
|
|
175
|
+
{FEATURE_LABELS[flag]}
|
|
176
|
+
</label>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="mt-4">
|
|
180
|
+
<SaveButton busy={busy === "features"} onClick={() => save("features", { features })} />
|
|
181
|
+
</div>
|
|
182
|
+
</section>
|
|
183
|
+
|
|
184
|
+
{Object.keys(brand).length > 0 && (
|
|
185
|
+
<section className={`mt-6 ${cardClass}`}>
|
|
186
|
+
<SectionHeader
|
|
187
|
+
title="Brand tokens"
|
|
188
|
+
overridden={overridden("brand")}
|
|
189
|
+
onReset={() => reset("brand")}
|
|
190
|
+
busy={busy === "brand"}
|
|
191
|
+
/>
|
|
192
|
+
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
|
193
|
+
{Object.keys(brand).map((token) => (
|
|
194
|
+
<label key={token} className="block">
|
|
195
|
+
<span className={labelClass}>{token}</span>
|
|
196
|
+
<div className="flex items-center gap-2">
|
|
197
|
+
<span
|
|
198
|
+
className="size-8 shrink-0 border border-ink/15"
|
|
199
|
+
style={{ background: brand[token] }}
|
|
200
|
+
/>
|
|
201
|
+
<input
|
|
202
|
+
className={inputClass}
|
|
203
|
+
value={brand[token] ?? ""}
|
|
204
|
+
onChange={(e) => setBrand({ ...brand, [token]: e.target.value })}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</label>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
<div className="mt-4">
|
|
211
|
+
<SaveButton busy={busy === "brand"} onClick={() => save("brand", { brand })} />
|
|
212
|
+
</div>
|
|
213
|
+
</section>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function SectionHeader({
|
|
220
|
+
title,
|
|
221
|
+
overridden,
|
|
222
|
+
onReset,
|
|
223
|
+
busy,
|
|
224
|
+
}: {
|
|
225
|
+
title: string;
|
|
226
|
+
overridden: boolean;
|
|
227
|
+
onReset: () => void;
|
|
228
|
+
busy: boolean;
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<div className="flex items-center justify-between gap-4">
|
|
232
|
+
<h2 className="font-serif text-xl text-ink">{title}</h2>
|
|
233
|
+
{overridden && (
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={onReset}
|
|
237
|
+
disabled={busy}
|
|
238
|
+
className="inline-flex items-center gap-2 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-wider text-ink/70 hover:border-sea disabled:opacity-50"
|
|
239
|
+
>
|
|
240
|
+
<RotateCcw className="size-3.5" /> Reset to code default
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function SaveButton({ busy, onClick }: { busy: boolean; onClick: () => void }) {
|
|
248
|
+
return (
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={onClick}
|
|
252
|
+
disabled={busy}
|
|
253
|
+
className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-xs uppercase tracking-wider text-white hover:bg-sea disabled:opacity-50"
|
|
254
|
+
>
|
|
255
|
+
<Save className="size-4" /> {busy ? "Saving…" : "Save"}
|
|
256
|
+
</button>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site configuration contract (Phase 1 of the stand-alone CMS work).
|
|
3
|
+
*
|
|
4
|
+
* A site describes itself once, in code, with `defineSiteConfig`. A subset of
|
|
5
|
+
* those values may be overridden at runtime from the `site_settings` table so
|
|
6
|
+
* non-developers can change them in the backoffice. Shared platform code reads
|
|
7
|
+
* the resolved config instead of hardcoding a site name, locale set, backoffice
|
|
8
|
+
* path or feature set.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Feature modules a site can switch on. Unknown flags are ignored. */
|
|
12
|
+
export type SiteFeatureFlags = {
|
|
13
|
+
/** Trips / departures module (`/kontoret/trips`). */
|
|
14
|
+
trips: boolean;
|
|
15
|
+
/** Courses module (`/kontoret/kurser`). */
|
|
16
|
+
courses: boolean;
|
|
17
|
+
/** Reviews module (`/kontoret/recensioner`). */
|
|
18
|
+
reviews: boolean;
|
|
19
|
+
/** Transactional email + provider settings screens. */
|
|
20
|
+
email: boolean;
|
|
21
|
+
/** Head/body code injection and analytics pixels. */
|
|
22
|
+
tracking: boolean;
|
|
23
|
+
/** Backup & restore tooling (superadmin only). */
|
|
24
|
+
backup: boolean;
|
|
25
|
+
/** Custom domain management screen. */
|
|
26
|
+
domains: boolean;
|
|
27
|
+
/** Public forms module. */
|
|
28
|
+
forms: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_FEATURE_FLAGS: SiteFeatureFlags = {
|
|
32
|
+
trips: false,
|
|
33
|
+
courses: false,
|
|
34
|
+
reviews: false,
|
|
35
|
+
email: true,
|
|
36
|
+
tracking: true,
|
|
37
|
+
backup: true,
|
|
38
|
+
domains: false,
|
|
39
|
+
forms: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Official company / contact details. Locale-neutral by design. */
|
|
43
|
+
export type SiteCompany = {
|
|
44
|
+
legalName: string;
|
|
45
|
+
locationName: string;
|
|
46
|
+
street: string;
|
|
47
|
+
postCode: string;
|
|
48
|
+
city: string;
|
|
49
|
+
country: string;
|
|
50
|
+
orgNumber: string;
|
|
51
|
+
email: string;
|
|
52
|
+
phone: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const EMPTY_COMPANY: SiteCompany = {
|
|
56
|
+
legalName: "",
|
|
57
|
+
locationName: "",
|
|
58
|
+
street: "",
|
|
59
|
+
postCode: "",
|
|
60
|
+
city: "",
|
|
61
|
+
country: "",
|
|
62
|
+
orgNumber: "",
|
|
63
|
+
email: "",
|
|
64
|
+
phone: "",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Brand design tokens. Values are CSS colors written into the theme contract. */
|
|
68
|
+
export type SiteBrandTokens = Record<string, string>;
|
|
69
|
+
|
|
70
|
+
/** Serializable per-site extras. Kept primitive so the config can cross RPC. */
|
|
71
|
+
export type SiteExtra = Record<string, string | number | boolean | null>;
|
|
72
|
+
|
|
73
|
+
export type SiteConfigInput = {
|
|
74
|
+
/** Machine key, e.g. "voyara". Lowercase, no spaces. */
|
|
75
|
+
key: string;
|
|
76
|
+
/** Display name used in the backoffice and default SEO titles. */
|
|
77
|
+
name: string;
|
|
78
|
+
/** Canonical public origin, without a trailing slash. */
|
|
79
|
+
origin: string;
|
|
80
|
+
/** Locales the public site serves, most important first. */
|
|
81
|
+
locales: readonly string[];
|
|
82
|
+
/** Locale used when nothing else matches. Must be in `locales`. */
|
|
83
|
+
defaultLocale: string;
|
|
84
|
+
/** Path the backoffice is mounted at, e.g. "/kontoret". */
|
|
85
|
+
backofficePath?: string;
|
|
86
|
+
/** UI language of the backoffice itself. */
|
|
87
|
+
backofficeLocale?: string;
|
|
88
|
+
company?: Partial<SiteCompany>;
|
|
89
|
+
brand?: SiteBrandTokens;
|
|
90
|
+
features?: Partial<SiteFeatureFlags>;
|
|
91
|
+
/** Optional free-form per-site extras shared code never reads. */
|
|
92
|
+
extra?: SiteExtra;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type SiteConfig = {
|
|
96
|
+
key: string;
|
|
97
|
+
name: string;
|
|
98
|
+
origin: string;
|
|
99
|
+
locales: readonly string[];
|
|
100
|
+
defaultLocale: string;
|
|
101
|
+
backofficePath: string;
|
|
102
|
+
backofficeLocale: string;
|
|
103
|
+
company: SiteCompany;
|
|
104
|
+
brand: SiteBrandTokens;
|
|
105
|
+
features: SiteFeatureFlags;
|
|
106
|
+
extra: SiteExtra;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function fail(message: string): never {
|
|
110
|
+
throw new Error(`@bygghammar/cms-platform: invalid site config — ${message}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Validate and normalize a site config. Throws on anything unusable. */
|
|
114
|
+
export function defineSiteConfig(input: SiteConfigInput): SiteConfig {
|
|
115
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(input.key)) fail(`key "${input.key}" must be kebab-case`);
|
|
116
|
+
if (!input.name.trim()) fail("name is required");
|
|
117
|
+
if (!/^https?:\/\/[^/]+$/.test(input.origin)) {
|
|
118
|
+
fail(`origin "${input.origin}" must be an absolute URL without a path`);
|
|
119
|
+
}
|
|
120
|
+
if (!input.locales.length) fail("at least one locale is required");
|
|
121
|
+
if (!input.locales.includes(input.defaultLocale)) {
|
|
122
|
+
fail(`defaultLocale "${input.defaultLocale}" is not listed in locales`);
|
|
123
|
+
}
|
|
124
|
+
const backofficePath = input.backofficePath ?? "/kontoret";
|
|
125
|
+
if (!backofficePath.startsWith("/") || backofficePath.endsWith("/")) {
|
|
126
|
+
fail(`backofficePath "${backofficePath}" must start with "/" and not end with one`);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
key: input.key,
|
|
130
|
+
name: input.name.trim(),
|
|
131
|
+
origin: input.origin.replace(/\/$/, ""),
|
|
132
|
+
locales: [...input.locales],
|
|
133
|
+
defaultLocale: input.defaultLocale,
|
|
134
|
+
backofficePath,
|
|
135
|
+
backofficeLocale: input.backofficeLocale ?? "en",
|
|
136
|
+
company: { ...EMPTY_COMPANY, ...(input.company ?? {}) },
|
|
137
|
+
brand: { ...(input.brand ?? {}) },
|
|
138
|
+
features: { ...DEFAULT_FEATURE_FLAGS, ...(input.features ?? {}) },
|
|
139
|
+
extra: { ...(input.extra ?? {}) },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Keys the backoffice may override from the database. Everything else is
|
|
145
|
+
* code-only on purpose: routing, locale set and origin must be deployable and
|
|
146
|
+
* reviewable, not editable at runtime.
|
|
147
|
+
*/
|
|
148
|
+
export const OVERRIDABLE_SETTING_KEYS = [
|
|
149
|
+
"name",
|
|
150
|
+
"company",
|
|
151
|
+
"brand",
|
|
152
|
+
"features",
|
|
153
|
+
] as const satisfies readonly (keyof SiteConfig)[];
|
|
154
|
+
|
|
155
|
+
export type OverridableSettingKey = (typeof OVERRIDABLE_SETTING_KEYS)[number];
|
|
156
|
+
|
|
157
|
+
/** Stored override rows, keyed by `OverridableSettingKey`. */
|
|
158
|
+
export type SiteSettingOverrides = Partial<{
|
|
159
|
+
name: string;
|
|
160
|
+
company: Partial<SiteCompany>;
|
|
161
|
+
brand: SiteBrandTokens;
|
|
162
|
+
features: Partial<SiteFeatureFlags>;
|
|
163
|
+
}>;
|
|
164
|
+
|
|
165
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
166
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Narrow an untrusted settings payload to the overridable subset. */
|
|
170
|
+
export function parseSiteSettingOverrides(raw: unknown): SiteSettingOverrides {
|
|
171
|
+
if (!isRecord(raw)) return {};
|
|
172
|
+
const out: SiteSettingOverrides = {};
|
|
173
|
+
if (typeof raw["name"] === "string" && raw["name"].trim()) out.name = raw["name"].trim();
|
|
174
|
+
if (isRecord(raw["company"])) {
|
|
175
|
+
const company: Partial<SiteCompany> = {};
|
|
176
|
+
for (const key of Object.keys(EMPTY_COMPANY) as (keyof SiteCompany)[]) {
|
|
177
|
+
const value = (raw["company"] as Record<string, unknown>)[key];
|
|
178
|
+
if (typeof value === "string") company[key] = value;
|
|
179
|
+
}
|
|
180
|
+
out.company = company;
|
|
181
|
+
}
|
|
182
|
+
if (isRecord(raw["brand"])) {
|
|
183
|
+
const brand: SiteBrandTokens = {};
|
|
184
|
+
for (const [key, value] of Object.entries(raw["brand"])) {
|
|
185
|
+
if (typeof value === "string") brand[key] = value;
|
|
186
|
+
}
|
|
187
|
+
out.brand = brand;
|
|
188
|
+
}
|
|
189
|
+
if (isRecord(raw["features"])) {
|
|
190
|
+
const features: Partial<SiteFeatureFlags> = {};
|
|
191
|
+
for (const key of Object.keys(DEFAULT_FEATURE_FLAGS) as (keyof SiteFeatureFlags)[]) {
|
|
192
|
+
const value = (raw["features"] as Record<string, unknown>)[key];
|
|
193
|
+
if (typeof value === "boolean") features[key] = value;
|
|
194
|
+
}
|
|
195
|
+
out.features = features;
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Layer database overrides over the code config. Code stays the fallback. */
|
|
201
|
+
export function resolveSiteConfig(base: SiteConfig, overrides: unknown): SiteConfig {
|
|
202
|
+
const parsed = parseSiteSettingOverrides(overrides);
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
name: parsed.name ?? base.name,
|
|
206
|
+
company: { ...base.company, ...(parsed.company ?? {}) },
|
|
207
|
+
brand: { ...base.brand, ...(parsed.brand ?? {}) },
|
|
208
|
+
features: { ...base.features, ...(parsed.features ?? {}) },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** True when a feature module is enabled for the resolved config. */
|
|
213
|
+
export function isFeatureEnabled(config: SiteConfig, flag: keyof SiteFeatureFlags): boolean {
|
|
214
|
+
return config.features[flag] === true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Build a backoffice URL from the configured mount path. */
|
|
218
|
+
export function backofficeUrl(config: SiteConfig, subpath = ""): string {
|
|
219
|
+
if (!subpath) return config.backofficePath;
|
|
220
|
+
return `${config.backofficePath}/${subpath.replace(/^\//, "")}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let active: SiteConfig | null = null;
|
|
224
|
+
|
|
225
|
+
/** Register the resolved site config once at startup. */
|
|
226
|
+
export function registerSiteConfig(config: SiteConfig): SiteConfig {
|
|
227
|
+
active = config;
|
|
228
|
+
return config;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Read the registered site config. Throws when a site forgot to register. */
|
|
232
|
+
export function siteConfig(): SiteConfig {
|
|
233
|
+
if (!active) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
"@bygghammar/cms-platform: no site config registered. Call registerSiteConfig(defineSiteConfig({...})) during startup.",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return active;
|
|
239
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic core for the `site_settings` key/value store.
|
|
3
|
+
*
|
|
4
|
+
* Rows are `{ key, value jsonb }` where `key` is one of
|
|
5
|
+
* `OVERRIDABLE_SETTING_KEYS`. Reads use whatever client the host passes
|
|
6
|
+
* (publishable client for the public site, authenticated client in the
|
|
7
|
+
* backoffice); writes always run as the signed-in staff user so RLS applies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
OVERRIDABLE_SETTING_KEYS,
|
|
12
|
+
parseSiteSettingOverrides,
|
|
13
|
+
type OverridableSettingKey,
|
|
14
|
+
type SiteSettingOverrides,
|
|
15
|
+
} from "../config/site-config";
|
|
16
|
+
|
|
17
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
18
|
+
type Client = {
|
|
19
|
+
from: (table: string) => any;
|
|
20
|
+
};
|
|
21
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
22
|
+
|
|
23
|
+
export const SITE_SETTINGS_TABLE = "site_settings";
|
|
24
|
+
|
|
25
|
+
/** Load every stored override, narrowed to the overridable subset. */
|
|
26
|
+
export async function loadSiteSettingsCore(client: Client): Promise<SiteSettingOverrides> {
|
|
27
|
+
const { data, error } = await client.from(SITE_SETTINGS_TABLE).select("key, value");
|
|
28
|
+
if (error) throw new Error(error.message);
|
|
29
|
+
const raw: Record<string, unknown> = {};
|
|
30
|
+
for (const row of (data ?? []) as Array<{ key: string; value: unknown }>) {
|
|
31
|
+
if ((OVERRIDABLE_SETTING_KEYS as readonly string[]).includes(row.key)) raw[row.key] = row.value;
|
|
32
|
+
}
|
|
33
|
+
return parseSiteSettingOverrides(raw);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Upsert the provided override keys. Keys absent from `patch` are untouched, so
|
|
38
|
+
* a screen can save one section without clobbering another.
|
|
39
|
+
*/
|
|
40
|
+
export async function saveSiteSettingsCore(
|
|
41
|
+
client: Client,
|
|
42
|
+
patch: SiteSettingOverrides,
|
|
43
|
+
actorId?: string | null,
|
|
44
|
+
): Promise<SiteSettingOverrides> {
|
|
45
|
+
const clean = parseSiteSettingOverrides(patch);
|
|
46
|
+
const rows = (Object.keys(clean) as OverridableSettingKey[]).map((key) => ({
|
|
47
|
+
key,
|
|
48
|
+
value: clean[key] as unknown,
|
|
49
|
+
updated_by: actorId ?? null,
|
|
50
|
+
updated_at: new Date().toISOString(),
|
|
51
|
+
}));
|
|
52
|
+
if (rows.length) {
|
|
53
|
+
const { error } = await client.from(SITE_SETTINGS_TABLE).upsert(rows, { onConflict: "key" });
|
|
54
|
+
if (error) throw new Error(error.message);
|
|
55
|
+
}
|
|
56
|
+
return loadSiteSettingsCore(client);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Remove one override so the code default takes over again. */
|
|
60
|
+
export async function clearSiteSettingCore(
|
|
61
|
+
client: Client,
|
|
62
|
+
key: OverridableSettingKey,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const { error } = await client.from(SITE_SETTINGS_TABLE).delete().eq("key", key);
|
|
65
|
+
if (error) throw new Error(error.message);
|
|
66
|
+
}
|