@bygghammar/cms-platform 0.62.1 → 0.64.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 +12 -0
- package/package.json +2 -2
- package/src/backoffice/DashboardView.tsx +2 -0
- package/src/backoffice/SiteSettingsView.tsx +114 -1
- package/src/backoffice/routes/host.ts +2 -0
- package/src/backoffice/routes/index.tsx +50 -6
- package/src/backoffice/routes/media-forms.tsx +33 -4
- package/src/backoffice/routes/pages.tsx +7 -1
- package/src/backoffice/shell/index.ts +8 -0
- package/src/backoffice/shell/use-resolved-config.tsx +84 -0
- package/src/config/site-config.ts +60 -0
- package/src/server/site-settings-core.ts +23 -0
- package/src/site/SiteHeader.tsx +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.63.0
|
|
4
|
+
|
|
5
|
+
- Site config gains `branding` (`logo`, `logoDark`, `logoAlt`,
|
|
6
|
+
`invertLogoOnLightSurface`) and it is an overridable setting, so the logo can be
|
|
7
|
+
uploaded from **Site settings → Branding** with the code value as the fallback.
|
|
8
|
+
- `SiteSettingsView` renders the branding section, using the host media field
|
|
9
|
+
(library picker + upload) when available and live previews on both surfaces.
|
|
10
|
+
- New `loadSiteBrandingCore(client, config)` for a public read of the resolved
|
|
11
|
+
logo, and `brandingLogoFor()` to pick artwork per surface.
|
|
12
|
+
- `SiteHeader` accepts `logoDarkSrc` and `logoAlt`; a dark artwork replaces the
|
|
13
|
+
CSS inversion on light surfaces.
|
|
14
|
+
|
|
3
15
|
## 0.61.1
|
|
4
16
|
|
|
5
17
|
- `site/host-indexing-response` takes the configured origin from the site instead
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.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": [
|
|
@@ -300,4 +300,4 @@
|
|
|
300
300
|
"cms-platform-migrations": "scripts/sync-migrations.mjs",
|
|
301
301
|
"create-cms-site": "./scripts/create-cms-site.mjs"
|
|
302
302
|
}
|
|
303
|
-
}
|
|
303
|
+
}
|
|
@@ -14,6 +14,8 @@ export type DashboardCard = {
|
|
|
14
14
|
title: string;
|
|
15
15
|
body: string;
|
|
16
16
|
icon: ComponentType<{ className?: string }>;
|
|
17
|
+
/** Module switch that has to be on for the card to be shown. */
|
|
18
|
+
feature?: string;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
/** Translation sweep modes offered on the dashboard. */
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
2
|
import { toast } from "sonner";
|
|
3
3
|
import { Save, RotateCcw } from "lucide-react";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_FEATURE_FLAGS,
|
|
7
|
+
EMPTY_BRANDING,
|
|
7
8
|
EMPTY_COMPANY,
|
|
9
|
+
type SiteBranding,
|
|
8
10
|
type SiteConfig,
|
|
9
11
|
type SiteFeatureFlags,
|
|
10
12
|
type SiteSettingOverrides,
|
|
@@ -37,6 +39,12 @@ export type SiteSettingsDeps = {
|
|
|
37
39
|
companyValues?: Record<string, string>;
|
|
38
40
|
/** Navigate to the contact details screen, where company fields are edited. */
|
|
39
41
|
onOpenContactDetails?: () => void;
|
|
42
|
+
/**
|
|
43
|
+
* Site media field (library picker + upload). When omitted the branding
|
|
44
|
+
* section falls back to plain URL inputs so the screen still works.
|
|
45
|
+
*/
|
|
46
|
+
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
47
|
+
MediaField?: (props: any) => ReactNode;
|
|
40
48
|
};
|
|
41
49
|
|
|
42
50
|
const FEATURE_LABELS: Record<keyof SiteFeatureFlags, string> = {
|
|
@@ -80,6 +88,10 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
|
80
88
|
const [company, setCompany] = useState({ ...EMPTY_COMPANY, ...config.company });
|
|
81
89
|
const [features, setFeatures] = useState<SiteFeatureFlags>({ ...config.features });
|
|
82
90
|
const [brand, setBrand] = useState<Record<string, string>>({ ...config.brand });
|
|
91
|
+
const [branding, setBranding] = useState<SiteBranding>({
|
|
92
|
+
...EMPTY_BRANDING,
|
|
93
|
+
...config.branding,
|
|
94
|
+
});
|
|
83
95
|
const [busy, setBusy] = useState<string | null>(null);
|
|
84
96
|
|
|
85
97
|
useEffect(() => {
|
|
@@ -87,6 +99,7 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
|
87
99
|
setCompany({ ...EMPTY_COMPANY, ...config.company });
|
|
88
100
|
setFeatures({ ...config.features });
|
|
89
101
|
setBrand({ ...config.brand });
|
|
102
|
+
setBranding({ ...EMPTY_BRANDING, ...config.branding });
|
|
90
103
|
}, [config]);
|
|
91
104
|
|
|
92
105
|
const save = async (key: string, patch: SiteSettingOverrides) => {
|
|
@@ -212,6 +225,60 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
|
212
225
|
) : null}
|
|
213
226
|
</section>
|
|
214
227
|
|
|
228
|
+
|
|
229
|
+
<section className={`mt-6 ${cardClass}`}>
|
|
230
|
+
<SectionHeader
|
|
231
|
+
title="Branding"
|
|
232
|
+
overridden={overridden("branding")}
|
|
233
|
+
onReset={() => reset("branding")}
|
|
234
|
+
busy={busy === "branding"}
|
|
235
|
+
/>
|
|
236
|
+
<p className="mt-2 max-w-2xl text-sm text-ink/60">
|
|
237
|
+
The logo shown in the site header. Upload the artwork you want here; leaving a field empty
|
|
238
|
+
falls back to the logo that ships with the site in code. Use a transparent PNG or SVG.
|
|
239
|
+
</p>
|
|
240
|
+
<div className="mt-5 grid gap-6 md:grid-cols-2">
|
|
241
|
+
<BrandingLogoField
|
|
242
|
+
label="Logo (light artwork, used on dark backgrounds)"
|
|
243
|
+
value={branding.logo}
|
|
244
|
+
onChange={(logo) => setBranding({ ...branding, logo })}
|
|
245
|
+
MediaField={deps.MediaField}
|
|
246
|
+
preview="dark"
|
|
247
|
+
/>
|
|
248
|
+
<BrandingLogoField
|
|
249
|
+
label="Dark logo (optional, used on light backgrounds)"
|
|
250
|
+
value={branding.logoDark}
|
|
251
|
+
onChange={(logoDark) => setBranding({ ...branding, logoDark })}
|
|
252
|
+
MediaField={deps.MediaField}
|
|
253
|
+
preview="light"
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="mt-5 grid gap-4 md:grid-cols-2 md:items-end">
|
|
257
|
+
<label className="block">
|
|
258
|
+
<span className={labelClass}>Logo alt text</span>
|
|
259
|
+
<input
|
|
260
|
+
className={inputClass}
|
|
261
|
+
value={branding.logoAlt}
|
|
262
|
+
placeholder={config.name}
|
|
263
|
+
onChange={(e) => setBranding({ ...branding, logoAlt: e.target.value })}
|
|
264
|
+
/>
|
|
265
|
+
</label>
|
|
266
|
+
<label className="flex items-center gap-3 text-sm text-ink">
|
|
267
|
+
<input
|
|
268
|
+
type="checkbox"
|
|
269
|
+
checked={branding.invertLogoOnLightSurface}
|
|
270
|
+
onChange={(e) =>
|
|
271
|
+
setBranding({ ...branding, invertLogoOnLightSurface: e.target.checked })
|
|
272
|
+
}
|
|
273
|
+
/>
|
|
274
|
+
Invert the light artwork on light backgrounds (when no dark logo is set)
|
|
275
|
+
</label>
|
|
276
|
+
</div>
|
|
277
|
+
<div className="mt-5">
|
|
278
|
+
<SaveButton busy={busy === "branding"} onClick={() => save("branding", { branding })} />
|
|
279
|
+
</div>
|
|
280
|
+
</section>
|
|
281
|
+
|
|
215
282
|
<section className={`mt-6 ${cardClass}`}>
|
|
216
283
|
<SectionHeader
|
|
217
284
|
title="Modules"
|
|
@@ -271,6 +338,52 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
|
271
338
|
);
|
|
272
339
|
}
|
|
273
340
|
|
|
341
|
+
/**
|
|
342
|
+
* One logo slot: the site's media field when the host provides one, otherwise a
|
|
343
|
+
* plain URL input. Both render the same live preview on the target surface.
|
|
344
|
+
*/
|
|
345
|
+
function BrandingLogoField({
|
|
346
|
+
label,
|
|
347
|
+
value,
|
|
348
|
+
onChange,
|
|
349
|
+
MediaField,
|
|
350
|
+
preview,
|
|
351
|
+
}: {
|
|
352
|
+
label: string;
|
|
353
|
+
value: string;
|
|
354
|
+
onChange: (next: string) => void;
|
|
355
|
+
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
356
|
+
MediaField?: (props: any) => ReactNode;
|
|
357
|
+
preview: "light" | "dark";
|
|
358
|
+
}) {
|
|
359
|
+
return (
|
|
360
|
+
<div>
|
|
361
|
+
<span className={labelClass}>{label}</span>
|
|
362
|
+
{MediaField ? (
|
|
363
|
+
<MediaField value={value} onChange={onChange} label="" />
|
|
364
|
+
) : (
|
|
365
|
+
<input
|
|
366
|
+
className={inputClass}
|
|
367
|
+
value={value}
|
|
368
|
+
placeholder="/logo.png"
|
|
369
|
+
onChange={(e) => onChange(e.target.value)}
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
372
|
+
<div
|
|
373
|
+
className={`mt-3 flex h-20 items-center justify-center border border-ink/10 px-4 ${
|
|
374
|
+
preview === "dark" ? "bg-ink" : "bg-white"
|
|
375
|
+
}`}
|
|
376
|
+
>
|
|
377
|
+
{value ? (
|
|
378
|
+
<img src={value} alt="" className="max-h-12 w-auto" />
|
|
379
|
+
) : (
|
|
380
|
+
<span className="text-xs uppercase tracking-wider text-ink/35">Code default</span>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
274
387
|
function SectionHeader({
|
|
275
388
|
title,
|
|
276
389
|
overridden,
|
|
@@ -232,6 +232,8 @@ export type BackofficeHost = {
|
|
|
232
232
|
title: string;
|
|
233
233
|
body: string;
|
|
234
234
|
icon: (props: { className?: string }) => ReactNode;
|
|
235
|
+
/** Module switch that has to be on for the card to be shown. */
|
|
236
|
+
feature?: string;
|
|
235
237
|
}[];
|
|
236
238
|
translateTarget?: string;
|
|
237
239
|
translate?: HostServerFn;
|
|
@@ -45,6 +45,7 @@ import { SiteSettingsView } from "../SiteSettingsView";
|
|
|
45
45
|
import { TrackingView } from "../TrackingView";
|
|
46
46
|
import { UsersView } from "../UsersView";
|
|
47
47
|
import { VersionView } from "../VersionView";
|
|
48
|
+
import { useFeatureGate } from "../shell/use-resolved-config";
|
|
48
49
|
import { backofficeHost, backofficePaths } from "./host";
|
|
49
50
|
|
|
50
51
|
export * from "./host";
|
|
@@ -162,6 +163,7 @@ export function SiteSettingsRoute() {
|
|
|
162
163
|
onOpenContactDetails: () => {
|
|
163
164
|
window.location.href = contactPath;
|
|
164
165
|
},
|
|
166
|
+
MediaField: host.components.MediaField,
|
|
165
167
|
}}
|
|
166
168
|
/>
|
|
167
169
|
);
|
|
@@ -188,7 +190,7 @@ export function RedirectsRoute() {
|
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
/** Code & tracking injection screen. */
|
|
191
|
-
|
|
193
|
+
function TrackingRouteInner() {
|
|
192
194
|
const { fns, locales } = backofficeHost();
|
|
193
195
|
const list = useServerFn(fns.listInjections);
|
|
194
196
|
const save = useServerFn(fns.saveInjection);
|
|
@@ -237,7 +239,7 @@ export function ContactDetailsRoute() {
|
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
/** Form defaults and retention screen. */
|
|
240
|
-
|
|
242
|
+
function FormSettingsRouteInner() {
|
|
241
243
|
const host = backofficeHost();
|
|
242
244
|
const { fns } = host;
|
|
243
245
|
const load = useServerFn(fns.getFormDefaults);
|
|
@@ -492,7 +494,7 @@ export function SitemapRoute() {
|
|
|
492
494
|
}
|
|
493
495
|
|
|
494
496
|
/** Email sending settings plus provider credentials. */
|
|
495
|
-
|
|
497
|
+
function EmailSettingsRouteInner() {
|
|
496
498
|
const host = backofficeHost();
|
|
497
499
|
const { fns } = host;
|
|
498
500
|
const fetchSettings = useServerFn(fns.getEmailSettings);
|
|
@@ -558,7 +560,7 @@ export function NavigationRoute() {
|
|
|
558
560
|
}
|
|
559
561
|
|
|
560
562
|
/** Per-locale domain mapping. */
|
|
561
|
-
|
|
563
|
+
function DomainsRouteInner() {
|
|
562
564
|
const host = backofficeHost();
|
|
563
565
|
const paths = backofficePaths(host);
|
|
564
566
|
const qc = useQueryClient();
|
|
@@ -584,7 +586,7 @@ export function DomainsRoute() {
|
|
|
584
586
|
}
|
|
585
587
|
|
|
586
588
|
/** Testimonials / reviews editor backed by the shared content section. */
|
|
587
|
-
|
|
589
|
+
function ReviewsRouteInner() {
|
|
588
590
|
const host = backofficeHost();
|
|
589
591
|
const paths = backofficePaths(host);
|
|
590
592
|
const qc = useQueryClient();
|
|
@@ -620,7 +622,7 @@ export function ReviewsRoute() {
|
|
|
620
622
|
}
|
|
621
623
|
|
|
622
624
|
/** Backup and restore, superadmin only. */
|
|
623
|
-
|
|
625
|
+
function BackupRouteInner() {
|
|
624
626
|
const host = backofficeHost();
|
|
625
627
|
const backup = host.backup;
|
|
626
628
|
if (!backup) {
|
|
@@ -648,3 +650,45 @@ export function BackupRoute() {
|
|
|
648
650
|
/>
|
|
649
651
|
);
|
|
650
652
|
}
|
|
653
|
+
|
|
654
|
+
/** ReviewsRoute guarded by the "reviews" module switch. */
|
|
655
|
+
export function ReviewsRoute() {
|
|
656
|
+
const gate = useFeatureGate("reviews", "Reviews");
|
|
657
|
+
if (gate) return <>{gate}</>;
|
|
658
|
+
return <ReviewsRouteInner />;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** DomainsRoute guarded by the "domains" module switch. */
|
|
662
|
+
export function DomainsRoute() {
|
|
663
|
+
const gate = useFeatureGate("domains", "Domains");
|
|
664
|
+
if (gate) return <>{gate}</>;
|
|
665
|
+
return <DomainsRouteInner />;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** TrackingRoute guarded by the "tracking" module switch. */
|
|
669
|
+
export function TrackingRoute() {
|
|
670
|
+
const gate = useFeatureGate("tracking", "Code & tracking");
|
|
671
|
+
if (gate) return <>{gate}</>;
|
|
672
|
+
return <TrackingRouteInner />;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** EmailSettingsRoute guarded by the "email" module switch. */
|
|
676
|
+
export function EmailSettingsRoute() {
|
|
677
|
+
const gate = useFeatureGate("email", "Email");
|
|
678
|
+
if (gate) return <>{gate}</>;
|
|
679
|
+
return <EmailSettingsRouteInner />;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** BackupRoute guarded by the "backup" module switch. */
|
|
683
|
+
export function BackupRoute() {
|
|
684
|
+
const gate = useFeatureGate("backup", "Backup");
|
|
685
|
+
if (gate) return <>{gate}</>;
|
|
686
|
+
return <BackupRouteInner />;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** FormSettingsRoute guarded by the "forms" module switch. */
|
|
690
|
+
export function FormSettingsRoute() {
|
|
691
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
692
|
+
if (gate) return <>{gate}</>;
|
|
693
|
+
return <FormSettingsRouteInner />;
|
|
694
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
10
10
|
|
|
11
|
+
import { useFeatureGate } from "../shell/use-resolved-config";
|
|
11
12
|
import { Link, useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
|
12
13
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
14
|
import { useServerFn } from "@tanstack/react-start";
|
|
@@ -113,7 +114,7 @@ export function MediaRoute() {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
/** Forms index: list, create, duplicate, delete and the trash bin. */
|
|
116
|
-
|
|
117
|
+
function FormsIndexRouteInner() {
|
|
117
118
|
const forms = requireHostGroup("forms");
|
|
118
119
|
const { fns } = backofficeHost();
|
|
119
120
|
const qc = useQueryClient();
|
|
@@ -158,7 +159,7 @@ export function FormsIndexRoute() {
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
/** Form editor for a single form key. */
|
|
161
|
-
|
|
162
|
+
function FormEditorRouteInner() {
|
|
162
163
|
const forms = requireHostGroup("forms");
|
|
163
164
|
const { fns } = backofficeHost();
|
|
164
165
|
const params = useParams({ strict: false }) as { key?: string };
|
|
@@ -231,7 +232,7 @@ export function validateSubmissionSearch(search: Record<string, unknown>): Submi
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
/** Submission inbox with filters, paging, exports and delivery retries. */
|
|
234
|
-
|
|
235
|
+
function FormSubmissionsRouteInner() {
|
|
235
236
|
const forms = requireHostGroup("forms");
|
|
236
237
|
const host = backofficeHost();
|
|
237
238
|
const paths = backofficePaths(host);
|
|
@@ -302,7 +303,7 @@ export function FormSubmissionsRoute() {
|
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
/** Delivery attempt dashboard (retry / resolve failed notifications). */
|
|
305
|
-
|
|
306
|
+
function FormDeliveriesRouteInner() {
|
|
306
307
|
const forms = requireHostGroup("forms");
|
|
307
308
|
const host = backofficeHost();
|
|
308
309
|
const paths = backofficePaths(host);
|
|
@@ -358,3 +359,31 @@ export function FormDeliveriesRoute() {
|
|
|
358
359
|
/>
|
|
359
360
|
);
|
|
360
361
|
}
|
|
362
|
+
|
|
363
|
+
/** FormsIndexRoute guarded by the "forms" module switch. */
|
|
364
|
+
export function FormsIndexRoute() {
|
|
365
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
366
|
+
if (gate) return <>{gate}</>;
|
|
367
|
+
return <FormsIndexRouteInner />;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** FormEditorRoute guarded by the "forms" module switch. */
|
|
371
|
+
export function FormEditorRoute() {
|
|
372
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
373
|
+
if (gate) return <>{gate}</>;
|
|
374
|
+
return <FormEditorRouteInner />;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** FormSubmissionsRoute guarded by the "forms" module switch. */
|
|
378
|
+
export function FormSubmissionsRoute() {
|
|
379
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
380
|
+
if (gate) return <>{gate}</>;
|
|
381
|
+
return <FormSubmissionsRouteInner />;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** FormDeliveriesRoute guarded by the "forms" module switch. */
|
|
385
|
+
export function FormDeliveriesRoute() {
|
|
386
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
387
|
+
if (gate) return <>{gate}</>;
|
|
388
|
+
return <FormDeliveriesRouteInner />;
|
|
389
|
+
}
|
|
@@ -17,6 +17,7 @@ import { toast } from "sonner";
|
|
|
17
17
|
import { PageBuilderView } from "../PageBuilderView";
|
|
18
18
|
import { SectionEditorView } from "../SectionEditorView";
|
|
19
19
|
import { DashboardView, type DashboardCard } from "../DashboardView";
|
|
20
|
+
import { useResolvedSiteConfig } from "../shell/use-resolved-config";
|
|
20
21
|
import { ResetPasswordView } from "../ResetPasswordView";
|
|
21
22
|
import { backofficeHost, backofficePaths, requireHostGroup } from "./host";
|
|
22
23
|
|
|
@@ -204,13 +205,18 @@ export function SectionEditorRoute() {
|
|
|
204
205
|
/** Backoffice dashboard for `/<backoffice>/`. */
|
|
205
206
|
export function DashboardRoute() {
|
|
206
207
|
const dashboard = requireHostGroup("dashboard");
|
|
208
|
+
const { config } = useResolvedSiteConfig();
|
|
207
209
|
const translateFn = dashboard.translate;
|
|
208
210
|
const runTranslate = useServerFn(translateFn ?? (async () => ({})));
|
|
209
211
|
|
|
210
212
|
return (
|
|
211
213
|
<DashboardView
|
|
212
214
|
deps={{
|
|
213
|
-
cards: dashboard.cards as unknown as readonly DashboardCard[]
|
|
215
|
+
cards: (dashboard.cards as unknown as readonly DashboardCard[]).filter(
|
|
216
|
+
(c) =>
|
|
217
|
+
!c.feature ||
|
|
218
|
+
(config.features as Record<string, boolean | undefined>)[c.feature] === true,
|
|
219
|
+
),
|
|
214
220
|
renderLink: ({ to, className, children }) => (
|
|
215
221
|
<Link to={to as any} className={className}>
|
|
216
222
|
{children}
|
|
@@ -7,3 +7,11 @@ export type {
|
|
|
7
7
|
export { backofficeNav } from "./nav-model";
|
|
8
8
|
export type { BackofficeNavItem, BackofficeNavKey, BackofficeNavOptions } from "./nav-model";
|
|
9
9
|
export { backofficeRedirectHost, isDevelopmentHost } from "./domain-guard";
|
|
10
|
+
export {
|
|
11
|
+
FeatureDisabledNotice,
|
|
12
|
+
SITE_SETTINGS_QUERY_KEY,
|
|
13
|
+
useBackofficeNav,
|
|
14
|
+
useFeatureEnabled,
|
|
15
|
+
useFeatureGate,
|
|
16
|
+
useResolvedSiteConfig,
|
|
17
|
+
} from "./use-resolved-config";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live backoffice site configuration.
|
|
3
|
+
*
|
|
4
|
+
* The sidebar, the dashboard cards and the module screens must all obey the
|
|
5
|
+
* module switches saved under Site settings, not just the values compiled into
|
|
6
|
+
* `site.config.ts`. These hooks resolve the code config layered with the stored
|
|
7
|
+
* overrides through the host's `getSiteSettings` function, sharing the
|
|
8
|
+
* `["admin","site-settings"]` query key with the settings screen so a save
|
|
9
|
+
* updates the menu immediately.
|
|
10
|
+
*/
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import { useQuery } from "@tanstack/react-query";
|
|
13
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
14
|
+
|
|
15
|
+
import type { SiteConfig, SiteFeatureFlags } from "../../config/site-config";
|
|
16
|
+
import { resolveSiteConfig } from "../../config/site-config";
|
|
17
|
+
import { backofficeHost } from "../routes/host";
|
|
18
|
+
import { backofficeNav, type BackofficeNavItem, type BackofficeNavOptions } from "./nav-model";
|
|
19
|
+
|
|
20
|
+
/** Query key shared with the site-settings screen. */
|
|
21
|
+
export const SITE_SETTINGS_QUERY_KEY = ["admin", "site-settings"] as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Code config merged with the stored overrides. While loading, the code config
|
|
25
|
+
* is returned so the shell renders without a flash of empty navigation.
|
|
26
|
+
*/
|
|
27
|
+
export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolean } {
|
|
28
|
+
const host = backofficeHost();
|
|
29
|
+
const load = useServerFn(host.fns.getSiteSettings);
|
|
30
|
+
const q = useQuery({
|
|
31
|
+
queryKey: SITE_SETTINGS_QUERY_KEY,
|
|
32
|
+
queryFn: () => load() as Promise<{ config?: SiteConfig; overrides?: unknown }>,
|
|
33
|
+
staleTime: 30_000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const resolved =
|
|
37
|
+
(q.data?.config as SiteConfig | undefined) ??
|
|
38
|
+
(q.data?.overrides
|
|
39
|
+
? resolveSiteConfig(host.siteConfig, q.data.overrides as never)
|
|
40
|
+
: host.siteConfig);
|
|
41
|
+
|
|
42
|
+
return { config: resolved, isLoading: q.isLoading };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Sidebar model built from the live (override-aware) configuration. */
|
|
46
|
+
export function useBackofficeNav(options: BackofficeNavOptions = {}): BackofficeNavItem[] {
|
|
47
|
+
const { config } = useResolvedSiteConfig();
|
|
48
|
+
return backofficeNav(config, options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a module is enabled right now. `undefined` while settings load, so a
|
|
53
|
+
* caller can avoid flashing a "turned off" notice before the answer is known.
|
|
54
|
+
*/
|
|
55
|
+
export function useFeatureEnabled(flag: keyof SiteFeatureFlags): boolean | undefined {
|
|
56
|
+
const { config, isLoading } = useResolvedSiteConfig();
|
|
57
|
+
if (isLoading) return undefined;
|
|
58
|
+
return config.features[flag] === true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Notice shown instead of a screen whose module is switched off. */
|
|
62
|
+
export function FeatureDisabledNotice({ label }: { label?: string }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="p-8 md:p-12">
|
|
65
|
+
<h1 className="font-serif text-2xl text-ink">
|
|
66
|
+
{label ? `${label} is turned off` : "This module is turned off"}
|
|
67
|
+
</h1>
|
|
68
|
+
<p className="mt-3 max-w-md text-sm text-ink/65">
|
|
69
|
+
Turn it back on under Site settings → Modules to use this screen.
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Guard for a module screen: returns a node to render instead of the screen
|
|
77
|
+
* when the module is disabled, and `null` when the screen may render.
|
|
78
|
+
*/
|
|
79
|
+
export function useFeatureGate(flag: keyof SiteFeatureFlags, label?: string): ReactNode | null {
|
|
80
|
+
const enabled = useFeatureEnabled(flag);
|
|
81
|
+
if (enabled === undefined) return <div className="p-8 text-sm text-ink/50">Loading…</div>;
|
|
82
|
+
if (enabled) return null;
|
|
83
|
+
return <FeatureDisabledNotice label={label} />;
|
|
84
|
+
}
|
|
@@ -64,6 +64,32 @@ export const EMPTY_COMPANY: SiteCompany = {
|
|
|
64
64
|
phone: "",
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Brand identity assets (logo artwork). Locale-neutral URLs; `alt` is plain
|
|
70
|
+
* text because a logo's accessible name is the brand name in every language.
|
|
71
|
+
*/
|
|
72
|
+
export type SiteBranding = {
|
|
73
|
+
/** Primary logo/wordmark URL. Empty string means "no logo configured". */
|
|
74
|
+
logo: string;
|
|
75
|
+
/**
|
|
76
|
+
* Optional dark-ink logo used on light surfaces. When empty, the primary
|
|
77
|
+
* artwork is CSS-inverted instead (see `invertLogoOnLightSurface`).
|
|
78
|
+
*/
|
|
79
|
+
logoDark: string;
|
|
80
|
+
/** Accessible name for the logo image. Falls back to the site name. */
|
|
81
|
+
logoAlt: string;
|
|
82
|
+
/** Invert the primary artwork on light surfaces when no dark variant exists. */
|
|
83
|
+
invertLogoOnLightSurface: boolean;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const EMPTY_BRANDING: SiteBranding = {
|
|
87
|
+
logo: "",
|
|
88
|
+
logoDark: "",
|
|
89
|
+
logoAlt: "",
|
|
90
|
+
invertLogoOnLightSurface: true,
|
|
91
|
+
};
|
|
92
|
+
|
|
67
93
|
/** Brand design tokens. Values are CSS colors written into the theme contract. */
|
|
68
94
|
export type SiteBrandTokens = Record<string, string>;
|
|
69
95
|
|
|
@@ -86,6 +112,8 @@ export type SiteConfigInput = {
|
|
|
86
112
|
/** UI language of the backoffice itself. */
|
|
87
113
|
backofficeLocale?: string;
|
|
88
114
|
company?: Partial<SiteCompany>;
|
|
115
|
+
/** Logo artwork defaults; the backoffice may override these. */
|
|
116
|
+
branding?: Partial<SiteBranding>;
|
|
89
117
|
brand?: SiteBrandTokens;
|
|
90
118
|
features?: Partial<SiteFeatureFlags>;
|
|
91
119
|
/** Optional free-form per-site extras shared code never reads. */
|
|
@@ -101,6 +129,7 @@ export type SiteConfig = {
|
|
|
101
129
|
backofficePath: string;
|
|
102
130
|
backofficeLocale: string;
|
|
103
131
|
company: SiteCompany;
|
|
132
|
+
branding: SiteBranding;
|
|
104
133
|
brand: SiteBrandTokens;
|
|
105
134
|
features: SiteFeatureFlags;
|
|
106
135
|
extra: SiteExtra;
|
|
@@ -134,6 +163,7 @@ export function defineSiteConfig(input: SiteConfigInput): SiteConfig {
|
|
|
134
163
|
backofficePath,
|
|
135
164
|
backofficeLocale: input.backofficeLocale ?? "en",
|
|
136
165
|
company: { ...EMPTY_COMPANY, ...(input.company ?? {}) },
|
|
166
|
+
branding: { ...EMPTY_BRANDING, ...(input.branding ?? {}) },
|
|
137
167
|
brand: { ...(input.brand ?? {}) },
|
|
138
168
|
features: { ...DEFAULT_FEATURE_FLAGS, ...(input.features ?? {}) },
|
|
139
169
|
extra: { ...(input.extra ?? {}) },
|
|
@@ -170,6 +200,7 @@ export function originIsFromEnvironment(fallback: string, rawEnvValue?: string |
|
|
|
170
200
|
export const OVERRIDABLE_SETTING_KEYS = [
|
|
171
201
|
"name",
|
|
172
202
|
"company",
|
|
203
|
+
"branding",
|
|
173
204
|
"brand",
|
|
174
205
|
"features",
|
|
175
206
|
] as const satisfies readonly (keyof SiteConfig)[];
|
|
@@ -180,6 +211,7 @@ export type OverridableSettingKey = (typeof OVERRIDABLE_SETTING_KEYS)[number];
|
|
|
180
211
|
export type SiteSettingOverrides = Partial<{
|
|
181
212
|
name: string;
|
|
182
213
|
company: Partial<SiteCompany>;
|
|
214
|
+
branding: Partial<SiteBranding>;
|
|
183
215
|
brand: SiteBrandTokens;
|
|
184
216
|
features: Partial<SiteFeatureFlags>;
|
|
185
217
|
}>;
|
|
@@ -201,6 +233,18 @@ export function parseSiteSettingOverrides(raw: unknown): SiteSettingOverrides {
|
|
|
201
233
|
}
|
|
202
234
|
out.company = company;
|
|
203
235
|
}
|
|
236
|
+
if (isRecord(raw["branding"])) {
|
|
237
|
+
const source = raw["branding"] as Record<string, unknown>;
|
|
238
|
+
const branding: Partial<SiteBranding> = {};
|
|
239
|
+
for (const key of ["logo", "logoDark", "logoAlt"] as const) {
|
|
240
|
+
const value = source[key];
|
|
241
|
+
if (typeof value === "string") branding[key] = value;
|
|
242
|
+
}
|
|
243
|
+
if (typeof source["invertLogoOnLightSurface"] === "boolean") {
|
|
244
|
+
branding.invertLogoOnLightSurface = source["invertLogoOnLightSurface"];
|
|
245
|
+
}
|
|
246
|
+
out.branding = branding;
|
|
247
|
+
}
|
|
204
248
|
if (isRecord(raw["brand"])) {
|
|
205
249
|
const brand: SiteBrandTokens = {};
|
|
206
250
|
for (const [key, value] of Object.entries(raw["brand"])) {
|
|
@@ -226,6 +270,7 @@ export function resolveSiteConfig(base: SiteConfig, overrides: unknown): SiteCon
|
|
|
226
270
|
...base,
|
|
227
271
|
name: parsed.name ?? base.name,
|
|
228
272
|
company: { ...base.company, ...(parsed.company ?? {}) },
|
|
273
|
+
branding: { ...base.branding, ...(parsed.branding ?? {}) },
|
|
229
274
|
brand: { ...base.brand, ...(parsed.brand ?? {}) },
|
|
230
275
|
features: { ...base.features, ...(parsed.features ?? {}) },
|
|
231
276
|
};
|
|
@@ -259,3 +304,18 @@ export function siteConfig(): SiteConfig {
|
|
|
259
304
|
}
|
|
260
305
|
return active;
|
|
261
306
|
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resolve the logo to render for a surface. A dark variant wins on light
|
|
310
|
+
* surfaces; otherwise the caller inverts the primary artwork when configured.
|
|
311
|
+
*/
|
|
312
|
+
export function brandingLogoFor(
|
|
313
|
+
branding: SiteBranding,
|
|
314
|
+
surface: "light" | "dark",
|
|
315
|
+
): { src: string; invert: boolean } {
|
|
316
|
+
if (surface === "light" && branding.logoDark) return { src: branding.logoDark, invert: false };
|
|
317
|
+
return {
|
|
318
|
+
src: branding.logo,
|
|
319
|
+
invert: surface === "light" && branding.invertLogoOnLightSurface,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -11,6 +11,9 @@ import { auditAs } from "./audit-core";
|
|
|
11
11
|
import {
|
|
12
12
|
OVERRIDABLE_SETTING_KEYS,
|
|
13
13
|
parseSiteSettingOverrides,
|
|
14
|
+
resolveSiteConfig,
|
|
15
|
+
type SiteBranding,
|
|
16
|
+
type SiteConfig,
|
|
14
17
|
type OverridableSettingKey,
|
|
15
18
|
type SiteSettingOverrides,
|
|
16
19
|
} from "../config/site-config";
|
|
@@ -87,3 +90,23 @@ export async function clearSiteSettingCore(
|
|
|
87
90
|
});
|
|
88
91
|
}
|
|
89
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Public read of the resolved branding (logo artwork) for the running site.
|
|
96
|
+
*
|
|
97
|
+
* The public header needs the stored logo before any staff session exists, so
|
|
98
|
+
* this takes whatever client the host passes — typically the publishable one —
|
|
99
|
+
* and always falls back to the code defaults when nothing is stored.
|
|
100
|
+
*/
|
|
101
|
+
export async function loadSiteBrandingCore(
|
|
102
|
+
client: Client,
|
|
103
|
+
base: SiteConfig,
|
|
104
|
+
): Promise<SiteBranding> {
|
|
105
|
+
try {
|
|
106
|
+
const overrides = await loadSiteSettingsCore(client);
|
|
107
|
+
return resolveSiteConfig(base, overrides).branding;
|
|
108
|
+
} catch {
|
|
109
|
+
// Branding must never break the header; code defaults are always valid.
|
|
110
|
+
return base.branding;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/site/SiteHeader.tsx
CHANGED
|
@@ -21,6 +21,10 @@ export type SiteHeaderProps = {
|
|
|
21
21
|
brandName: LocalizedString;
|
|
22
22
|
/** Logo/wordmark artwork. Light artwork is inverted on light surfaces. */
|
|
23
23
|
logoSrc: string;
|
|
24
|
+
/** Optional dark-ink artwork used on light surfaces instead of inverting. */
|
|
25
|
+
logoDarkSrc?: string;
|
|
26
|
+
/** Accessible name for the logo image. Defaults to the brand name. */
|
|
27
|
+
logoAlt?: string;
|
|
24
28
|
/** Set false when the logo artwork is already dark. */
|
|
25
29
|
invertLogoOnLightSurface?: boolean;
|
|
26
30
|
/** Primary call to action shown on desktop and at the end of the mobile panel. */
|
|
@@ -164,6 +168,8 @@ export function SiteHeader({
|
|
|
164
168
|
nav,
|
|
165
169
|
brandName,
|
|
166
170
|
logoSrc,
|
|
171
|
+
logoDarkSrc,
|
|
172
|
+
logoAlt,
|
|
167
173
|
invertLogoOnLightSurface = true,
|
|
168
174
|
primaryCta,
|
|
169
175
|
menuLabels,
|
|
@@ -201,6 +207,10 @@ export function SiteHeader({
|
|
|
201
207
|
|
|
202
208
|
const lightText = lightTextPaths.includes(pathname) && !scrolled;
|
|
203
209
|
const name = t(brandName);
|
|
210
|
+
// A dedicated dark artwork wins on light surfaces; otherwise invert the light one.
|
|
211
|
+
const onLightSurface = !lightText;
|
|
212
|
+
const logo = onLightSurface && logoDarkSrc ? logoDarkSrc : logoSrc;
|
|
213
|
+
const invertLogo = onLightSurface && !logoDarkSrc && invertLogoOnLightSurface;
|
|
204
214
|
const showBackoffice = Boolean(backoffice?.signedIn);
|
|
205
215
|
|
|
206
216
|
return (
|
|
@@ -213,11 +223,11 @@ export function SiteHeader({
|
|
|
213
223
|
<div className="container-editorial flex h-16 items-center justify-between gap-4 md:h-20">
|
|
214
224
|
<Link to="/" className="shrink-0 inline-flex items-center" aria-label={name}>
|
|
215
225
|
<img
|
|
216
|
-
src={
|
|
217
|
-
alt={name}
|
|
226
|
+
src={logo}
|
|
227
|
+
alt={logoAlt?.trim() || name}
|
|
218
228
|
className={[
|
|
219
229
|
"h-10 w-auto md:h-12 transition-[filter] duration-500",
|
|
220
|
-
|
|
230
|
+
invertLogo ? "brightness-0" : "",
|
|
221
231
|
].join(" ")}
|
|
222
232
|
/>
|
|
223
233
|
</Link>
|