@tokenoftrust/storefront-runner 2.0.41 → 2.0.43
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/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
- package/apps/storefront/package.json +1 -0
- package/apps/storefront/platform-reconcile-tenants.json +3 -0
- package/apps/storefront/public/favicon.ico +0 -0
- package/apps/storefront/public/shared/commerce-chrome.css +147 -16
- package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
- package/apps/storefront/src/components/Img.astro +6 -5
- package/apps/storefront/src/components/ProductCard.astro +6 -4
- package/apps/storefront/src/components/Seo.astro +2 -1
- package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
- package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
- package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
- package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
- package/apps/storefront/src/components/home/Hero.astro +156 -4
- package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
- package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
- package/apps/storefront/src/components/marketing/Faq.astro +9 -2
- package/apps/storefront/src/config/devTenantSeed.ts +1 -1
- package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
- package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
- package/apps/storefront/src/config/tenants.ts +22 -0
- package/apps/storefront/src/layouts/Layout.astro +69 -44
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
- package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
- package/apps/storefront/src/lib/asyncCss.ts +23 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
- package/apps/storefront/src/lib/basePath.ts +70 -9
- package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
- package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
- package/apps/storefront/src/lib/diag/capture.ts +161 -0
- package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
- package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
- package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
- package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
- package/apps/storefront/src/lib/diag/reference.ts +148 -0
- package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
- package/apps/storefront/src/lib/diag/report.ts +109 -0
- package/apps/storefront/src/lib/diag/session.ts +93 -0
- package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
- package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
- package/apps/storefront/src/lib/imageAttrs.ts +9 -6
- package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
- package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
- package/apps/storefront/src/lib/jsonScript.ts +13 -0
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
- package/apps/storefront/src/lib/previewReload.ts +53 -9
- package/apps/storefront/src/lib/rawChrome.ts +148 -19
- package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
- package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
- package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
- package/apps/storefront/src/lib/tenantRouting.ts +15 -4
- package/apps/storefront/src/lib/tot/syncState.ts +21 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
- package/apps/storefront/src/middleware/controlPlane.ts +9 -5
- package/apps/storefront/src/middleware/index.ts +13 -1
- package/apps/storefront/src/middleware/renderChain.ts +54 -11
- package/apps/storefront/src/middleware/routeBranch.ts +58 -12
- package/apps/storefront/src/middleware/staticBundle.ts +37 -3
- package/apps/storefront/src/pages/[...slug].astro +6 -2
- package/apps/storefront/src/pages/api/request-access.ts +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +11 -1
- package/apps/storefront/src/pages/cart.ts +23 -0
- package/apps/storefront/src/pages/index.astro +64 -85
- package/apps/storefront/src/pages/products/[handle].astro +8 -6
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
- package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
- package/apps/storefront/src/styles/fonts.css +7 -0
- package/apps/storefront/src/styles/global.css +397 -3
- package/package.json +4 -1
- package/packages/cli/src/visual-parity.mjs +321 -0
- package/packages/public-runtime/package.json +7 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/chrome.ts +44 -7
- package/packages/public-runtime/src/csp.ts +12 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
- package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
- package/packages/public-runtime/src/extension-contract.ts +20 -6
- package/packages/public-runtime/src/image-dependencies.ts +1438 -0
- package/packages/public-runtime/src/index.ts +8 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
- package/packages/public-runtime/src/integration/consent-config.ts +9 -0
- package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
- package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
- package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
- package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
- package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
- package/packages/public-runtime/src/publication-redirects.mjs +379 -0
- package/packages/public-runtime/src/publication-space.ts +24 -3
- package/packages/public-runtime/src/static-bundle.ts +145 -26
- package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
- package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
- package/packages/public-runtime/src/tenant.ts +59 -1
- package/pnpm-lock.runner.yaml +19 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/dev/git-status.mjs +1 -1
- package/scripts/dev/publish.mjs +6 -0
- package/scripts/lib/tenant-asset-copy.mjs +4 -2
- package/scripts/tenant/materialize-tenant-content.mjs +1 -0
- package/scripts/tenant/validate.mjs +171 -8
- package/apps/storefront/src/lib/cfImage.ts +0 -55
- package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/js/storefront.js +0 -129
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +0 -235
- package/tenants/home/theme.json +0 -6
|
@@ -7,30 +7,60 @@
|
|
|
7
7
|
* reimplement verification here. Affirmation is remembered in localStorage.
|
|
8
8
|
*/
|
|
9
9
|
import { useEffect, useRef, useState } from "preact/hooks";
|
|
10
|
+
import type { ChromeAgeGate } from "@/lib/chrome/model";
|
|
11
|
+
import { deriveAgeGateStorageKey, resolveAgeGateCopy } from "@/lib/chrome/model";
|
|
10
12
|
|
|
11
|
-
interface Props {
|
|
13
|
+
export interface Props {
|
|
12
14
|
minAge: number;
|
|
13
15
|
brand: string;
|
|
16
|
+
tenantId: string;
|
|
17
|
+
presentation?: ChromeAgeGate;
|
|
18
|
+
/** Platform wrapper hook; tenants cannot supply or override this. */
|
|
19
|
+
onBlockingChange?: (blocking: boolean) => void;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
export function resolveAgeGateRuntime({
|
|
23
|
+
minAge,
|
|
24
|
+
brand,
|
|
25
|
+
tenantId,
|
|
26
|
+
presentation,
|
|
27
|
+
}: Props) {
|
|
28
|
+
return {
|
|
29
|
+
minAge,
|
|
30
|
+
storageKey: deriveAgeGateStorageKey(tenantId, minAge),
|
|
31
|
+
compact: presentation?.variant === "compact-modal",
|
|
32
|
+
title: resolveAgeGateCopy(presentation?.title, minAge) ?? `Are you ${minAge} or older?`,
|
|
33
|
+
lede:
|
|
34
|
+
resolveAgeGateCopy(presentation?.lede, minAge) ??
|
|
35
|
+
`${brand} sells age-restricted products. You must be ${minAge}+ to enter. By continuing you confirm your age — we verify it for real at checkout.`,
|
|
36
|
+
yesLabel:
|
|
37
|
+
resolveAgeGateCopy(presentation?.yesLabel, minAge) ?? `Yes, I'm ${minAge} or older`,
|
|
38
|
+
noLabel: resolveAgeGateCopy(presentation?.noLabel, minAge) ?? "No",
|
|
39
|
+
denyText:
|
|
40
|
+
resolveAgeGateCopy(presentation?.denyText, minAge) ??
|
|
41
|
+
`You must be ${minAge} or older to view this store. Please close this tab.`,
|
|
42
|
+
fineText: resolveAgeGateCopy(presentation?.fineText, minAge),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
17
45
|
|
|
18
|
-
export default function AgeGate({ minAge, brand }: Props) {
|
|
46
|
+
export default function AgeGate({ minAge, brand, tenantId, presentation, onBlockingChange }: Props) {
|
|
19
47
|
const [state, setState] = useState<"hidden" | "ask" | "denied">("hidden");
|
|
20
48
|
const yesRef = useRef<HTMLButtonElement>(null);
|
|
49
|
+
const retryRef = useRef<HTMLButtonElement>(null);
|
|
50
|
+
const { storageKey, compact, title, lede, yesLabel, noLabel, denyText, fineText } =
|
|
51
|
+
resolveAgeGateRuntime({ minAge, brand, tenantId, presentation });
|
|
21
52
|
|
|
22
53
|
useEffect(() => {
|
|
23
54
|
let affirmed = false;
|
|
24
55
|
try {
|
|
25
|
-
affirmed = localStorage.getItem(
|
|
56
|
+
affirmed = window.localStorage.getItem(storageKey) === "yes";
|
|
26
57
|
} catch {
|
|
27
58
|
/* storage disabled — show the gate */
|
|
28
59
|
}
|
|
29
60
|
if (!affirmed) {
|
|
30
61
|
setState("ask");
|
|
31
|
-
document.documentElement.style.overflow = "hidden";
|
|
32
62
|
}
|
|
33
|
-
}, []);
|
|
63
|
+
}, [storageKey]);
|
|
34
64
|
|
|
35
65
|
useEffect(() => {
|
|
36
66
|
// preventScroll: the gate is a fixed overlay, but its host <div> is appended
|
|
@@ -38,12 +68,15 @@ export default function AgeGate({ minAge, brand }: Props) {
|
|
|
38
68
|
// underlying page down to that host — leaving the page at the bottom once the
|
|
39
69
|
// gate is dismissed. preventScroll keeps the a11y focus without the jump.
|
|
40
70
|
if (state === "ask") yesRef.current?.focus({ preventScroll: true });
|
|
41
|
-
if (state === "
|
|
42
|
-
|
|
71
|
+
if (state === "denied") retryRef.current?.focus({ preventScroll: true });
|
|
72
|
+
onBlockingChange?.(state !== "hidden");
|
|
73
|
+
}, [state, onBlockingChange]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => () => onBlockingChange?.(false), [onBlockingChange]);
|
|
43
76
|
|
|
44
77
|
const affirm = () => {
|
|
45
78
|
try {
|
|
46
|
-
localStorage.setItem(
|
|
79
|
+
window.localStorage.setItem(storageKey, "yes");
|
|
47
80
|
} catch {
|
|
48
81
|
/* ignore */
|
|
49
82
|
}
|
|
@@ -52,52 +85,128 @@ export default function AgeGate({ minAge, brand }: Props) {
|
|
|
52
85
|
|
|
53
86
|
if (state === "hidden") return null;
|
|
54
87
|
|
|
88
|
+
// This component lives in a closed shadow root. Its presentation must not
|
|
89
|
+
// depend on a stylesheet crossing that boundary: a dynamically-created
|
|
90
|
+
// <style> would be refused by the page's strict style-src, while style
|
|
91
|
+
// attributes are intentionally admitted by style-src-attr. Keep the modal
|
|
92
|
+
// self-contained, token-driven, and responsive without opening either the
|
|
93
|
+
// shadow root or the CSP.
|
|
94
|
+
const overlayStyle = {
|
|
95
|
+
boxSizing: "border-box",
|
|
96
|
+
position: "fixed" as const,
|
|
97
|
+
inset: "0",
|
|
98
|
+
display: "flex",
|
|
99
|
+
alignItems: compact ? "flex-start" : "center",
|
|
100
|
+
justifyContent: "center",
|
|
101
|
+
width: "100%",
|
|
102
|
+
height: "100%",
|
|
103
|
+
padding: compact ? "min(24px, 4vw)" : "20px",
|
|
104
|
+
background: compact ? "rgba(0, 0, 0, 0.5)" : "color-mix(in srgb, var(--color-text, #111) 75%, transparent)",
|
|
105
|
+
backdropFilter: compact ? undefined : "blur(4px)",
|
|
106
|
+
color: "var(--color-text, #171717)",
|
|
107
|
+
fontFamily: "var(--font-body, system-ui, sans-serif)",
|
|
108
|
+
pointerEvents: "auto",
|
|
109
|
+
};
|
|
110
|
+
const panelStyle = {
|
|
111
|
+
boxSizing: "border-box",
|
|
112
|
+
width: compact ? "min(600px, calc(100vw - 32px))" : "min(480px, calc(100vw - 40px))",
|
|
113
|
+
minHeight: compact ? "min(382px, calc(100vh - 48px))" : undefined,
|
|
114
|
+
maxHeight: compact ? "calc(100vh - 48px)" : undefined,
|
|
115
|
+
overflowY: compact ? "auto" : undefined,
|
|
116
|
+
padding: compact ? "32px" : "28px",
|
|
117
|
+
borderRadius: compact ? "5px" : "var(--radius-lg, 12px)",
|
|
118
|
+
background: "var(--color-surface, #fff)",
|
|
119
|
+
boxShadow: compact ? "0 20px 60px rgba(0, 0, 0, 0.35)" : "var(--shadow-lg, 0 24px 60px -20px rgba(0, 0, 0, .22))",
|
|
120
|
+
textAlign: "center" as const,
|
|
121
|
+
};
|
|
122
|
+
const buttonBase = {
|
|
123
|
+
boxSizing: "border-box",
|
|
124
|
+
minHeight: compact ? "48px" : undefined,
|
|
125
|
+
padding: "12px 24px",
|
|
126
|
+
borderRadius: compact ? "3px" : "var(--radius-md, 6px)",
|
|
127
|
+
fontFamily: "var(--font-display, system-ui, sans-serif)",
|
|
128
|
+
fontSize: "14px",
|
|
129
|
+
fontWeight: 600,
|
|
130
|
+
lineHeight: 1.2,
|
|
131
|
+
cursor: "pointer",
|
|
132
|
+
};
|
|
133
|
+
|
|
55
134
|
return (
|
|
56
135
|
<div
|
|
57
|
-
|
|
136
|
+
style={overlayStyle}
|
|
58
137
|
role="dialog"
|
|
59
138
|
aria-modal="true"
|
|
60
139
|
aria-labelledby="agegate-title"
|
|
140
|
+
aria-describedby="agegate-description"
|
|
141
|
+
onKeyDown={(event) => {
|
|
142
|
+
if (event.key !== "Tab") return;
|
|
143
|
+
const controls = Array.from(
|
|
144
|
+
event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not([disabled])"),
|
|
145
|
+
);
|
|
146
|
+
if (controls.length === 0) return;
|
|
147
|
+
const first = controls[0]!;
|
|
148
|
+
const last = controls[controls.length - 1]!;
|
|
149
|
+
if (event.shiftKey && event.target === first) {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
last.focus();
|
|
152
|
+
} else if (!event.shiftKey && event.target === last) {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
first.focus();
|
|
155
|
+
}
|
|
156
|
+
}}
|
|
61
157
|
>
|
|
62
|
-
<div
|
|
158
|
+
<div style={panelStyle}>
|
|
63
159
|
{state === "ask" ? (
|
|
64
160
|
<>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
161
|
+
{presentation?.mark?.length ? (
|
|
162
|
+
<p style={{ display: "flex", justifyContent: "center", margin: "0 0 16px", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "18px", fontWeight: 700, letterSpacing: ".04em" }}>
|
|
163
|
+
{presentation.mark.map((part) => (
|
|
164
|
+
<span class={part.className}>{part.text}</span>
|
|
165
|
+
))}
|
|
166
|
+
</p>
|
|
167
|
+
) : (
|
|
168
|
+
<p style={{ margin: "0 0 16px", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "12px", fontWeight: 700, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--color-muted, #616161)" }}>Age verification</p>
|
|
169
|
+
)}
|
|
170
|
+
<h2 id="agegate-title" style={{ margin: 0, fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "clamp(24px, 4vw, 32px)", fontWeight: 600, lineHeight: 1.1, letterSpacing: "-.02em" }}>
|
|
171
|
+
{title}
|
|
68
172
|
</h2>
|
|
69
|
-
<p
|
|
70
|
-
{
|
|
71
|
-
By continuing you confirm your age — we verify it for real at checkout.
|
|
173
|
+
<p id="agegate-description" style={{ maxWidth: "32rem", margin: "12px auto 0", color: "var(--color-muted, #616161)", fontSize: "16px", lineHeight: 1.55 }}>
|
|
174
|
+
{lede}
|
|
72
175
|
</p>
|
|
73
|
-
<div
|
|
176
|
+
<div style={{ display: "flex", flexDirection: compact ? "column" : "row", flexWrap: "wrap", justifyContent: "center", gap: "12px", marginTop: "24px" }}>
|
|
74
177
|
<button
|
|
75
178
|
ref={yesRef}
|
|
76
179
|
type="button"
|
|
77
180
|
onClick={affirm}
|
|
78
|
-
|
|
181
|
+
style={{ ...buttonBase, width: compact ? "100%" : undefined, border: 0, background: "var(--color-primary, #111)", color: "var(--color-primary-contrast, #fff)" }}
|
|
79
182
|
>
|
|
80
|
-
|
|
183
|
+
{yesLabel}
|
|
81
184
|
</button>
|
|
82
185
|
<button
|
|
83
186
|
type="button"
|
|
84
187
|
onClick={() => setState("denied")}
|
|
85
|
-
|
|
188
|
+
style={{ ...buttonBase, width: compact ? "100%" : undefined, border: "1px solid var(--color-border, #d0d0d0)", background: "transparent", color: "var(--color-text, #171717)", fontWeight: 500 }}
|
|
86
189
|
>
|
|
87
|
-
|
|
190
|
+
{noLabel}
|
|
88
191
|
</button>
|
|
89
192
|
</div>
|
|
193
|
+
{fineText && (
|
|
194
|
+
<p style={{ maxWidth: "32rem", margin: "20px auto 0", color: "var(--color-muted, #616161)", fontSize: "12px", lineHeight: 1.45 }}>
|
|
195
|
+
{fineText}
|
|
196
|
+
</p>
|
|
197
|
+
)}
|
|
90
198
|
</>
|
|
91
199
|
) : (
|
|
92
200
|
<>
|
|
93
|
-
<h2 id="agegate-title"
|
|
94
|
-
<p
|
|
95
|
-
|
|
201
|
+
<h2 id="agegate-title" style={{ margin: 0, fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "clamp(24px, 4vw, 32px)", fontWeight: 600, lineHeight: 1.1 }}>Come back when you're {minAge}</h2>
|
|
202
|
+
<p id="agegate-description" style={{ maxWidth: "24rem", margin: "12px auto 0", color: "var(--color-muted, #616161)", fontSize: "16px", lineHeight: 1.55 }}>
|
|
203
|
+
{denyText}
|
|
96
204
|
</p>
|
|
97
205
|
<button
|
|
206
|
+
ref={retryRef}
|
|
98
207
|
type="button"
|
|
99
208
|
onClick={() => setState("ask")}
|
|
100
|
-
|
|
209
|
+
style={{ display: "inline-block", marginTop: "24px", border: 0, background: "transparent", color: "var(--color-accent-ink, var(--color-primary, #111))", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "14px", fontWeight: 500, textDecoration: "underline", cursor: "pointer" }}
|
|
101
210
|
>
|
|
102
211
|
I entered my age incorrectly
|
|
103
212
|
</button>
|
|
@@ -17,7 +17,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"
|
|
|
17
17
|
import type { CatalogImage } from "@tot/public-runtime";
|
|
18
18
|
import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
|
|
19
19
|
import { resolveImage } from "@/lib/imageAttrs";
|
|
20
|
-
import { cfImageUrl } from "@/lib/cfImage";
|
|
21
20
|
|
|
22
21
|
interface Props {
|
|
23
22
|
images: CatalogImage[];
|
|
@@ -34,9 +33,8 @@ const isShopifyCdnUrl = (url: string): boolean =>
|
|
|
34
33
|
|
|
35
34
|
/**
|
|
36
35
|
* Main-image src + responsive srcset. Delegates to the SHARED `resolveImage`
|
|
37
|
-
* decision (same as <Img> on the PLP) so rehosted
|
|
38
|
-
*
|
|
39
|
-
* per-island srcset silently served full-size bytes at every breakpoint.
|
|
36
|
+
* decision (same as <Img> on the PLP) so rehosted images use the same
|
|
37
|
+
* provider-neutral shared-CDN URL on Cloudflare and CloudFront.
|
|
40
38
|
*/
|
|
41
39
|
function mainImage(img: CatalogImage, title: string): { src: string; srcset?: string } {
|
|
42
40
|
return resolveImage({
|
|
@@ -50,10 +48,9 @@ function mainImage(img: CatalogImage, title: string): { src: string; srcset?: st
|
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
/**
|
|
53
|
-
* Thumbnail src
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* `?width=`; dead fixture URLs draw a deterministic placeholder.
|
|
51
|
+
* Thumbnail src. Referenced Shopify images use their native `?width=` contract;
|
|
52
|
+
* rehosted images use the provider-neutral shared-CDN original; dead fixture
|
|
53
|
+
* URLs draw a deterministic placeholder.
|
|
57
54
|
*/
|
|
58
55
|
const THUMB_WIDTH = 160;
|
|
59
56
|
function thumbSrc(img: CatalogImage, title: string): string {
|
|
@@ -65,7 +62,7 @@ function thumbSrc(img: CatalogImage, title: string): string {
|
|
|
65
62
|
label: title,
|
|
66
63
|
});
|
|
67
64
|
}
|
|
68
|
-
if (img.rehosted) return
|
|
65
|
+
if (img.rehosted) return img.url;
|
|
69
66
|
if (isShopifyCdnUrl(img.url)) {
|
|
70
67
|
return `${img.url}${img.url.includes("?") ? "&" : "?"}width=${THUMB_WIDTH}`;
|
|
71
68
|
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* root** so no tenant CSS or JavaScript can select, restyle, or hide it.
|
|
4
4
|
*
|
|
5
5
|
* Why a wrapper instead of rendering <AgeGate> directly: a closed shadow root
|
|
6
|
-
* also blocks the platform's own global stylesheet, so we clone the
|
|
7
|
-
* stylesheets into the shadow root (
|
|
6
|
+
* also blocks the platform's own global stylesheet, so we clone only the
|
|
7
|
+
* platform-generated/shared stylesheets into the shadow root (utilities + fonts).
|
|
8
|
+
* Tenant stylesheet links are excluded. Theme tokens
|
|
8
9
|
* are CSS custom properties on :root, which *do* inherit across the shadow
|
|
9
10
|
* boundary, so the gate keeps the tenant's colors.
|
|
10
11
|
*
|
|
@@ -16,58 +17,205 @@
|
|
|
16
17
|
*/
|
|
17
18
|
import { render } from "preact";
|
|
18
19
|
import { useEffect } from "preact/hooks";
|
|
20
|
+
import type { ChromeAgeGate } from "@/lib/chrome/model";
|
|
19
21
|
import AgeGate from "./AgeGate";
|
|
20
22
|
|
|
21
|
-
interface Props {
|
|
23
|
+
export interface Props {
|
|
22
24
|
minAge: number;
|
|
23
25
|
brand: string;
|
|
26
|
+
tenantId: string;
|
|
27
|
+
presentation?: ChromeAgeGate;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface BackgroundIsolation {
|
|
31
|
+
release(): void;
|
|
32
|
+
refresh(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Width the classic scrollbar occupies right now; 0 where the scrollbar is an overlay.
|
|
37
|
+
*
|
|
38
|
+
* Locking scroll with `overflow: hidden` REMOVES that scrollbar, which widens the viewport and
|
|
39
|
+
* reflows the whole document — every block re-measures, text re-wraps, and content jumps.
|
|
40
|
+
* Measured on a regulated tenant it cost CLS 0.82-0.90 on every page long enough to scroll,
|
|
41
|
+
* while pages that never scrolled were untouched — which is the tell. That dragged Lighthouse
|
|
42
|
+
* performance from ~99 to ~74 with every timing metric still green. Core Web Vitals are
|
|
43
|
+
* field-measured and feed ranking, so it is a real cost on a revenue-bearing surface, and the
|
|
44
|
+
* gate is mandatory for these tenants: they cannot opt out of paying it.
|
|
45
|
+
*/
|
|
46
|
+
function scrollbarGutterWidth(): number {
|
|
47
|
+
const width = window.innerWidth - document.documentElement.clientWidth;
|
|
48
|
+
return Number.isFinite(width) && width > 0 ? width : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Make the storefront behind the gate non-interactive without disturbing prior inert state. */
|
|
52
|
+
export function isolateAgeGateBackground(host: HTMLElement): BackgroundIsolation {
|
|
53
|
+
const isolated = new Set<HTMLElement>();
|
|
54
|
+
const previousOverflow = document.documentElement.style.overflow;
|
|
55
|
+
const previousPaddingRight = document.documentElement.style.paddingRight;
|
|
56
|
+
let active = true;
|
|
57
|
+
|
|
58
|
+
const refresh = () => {
|
|
59
|
+
if (!active) return;
|
|
60
|
+
// Reserve the scrollbar's width BEFORE hiding it, so the viewport keeps its dimensions and
|
|
61
|
+
// nothing behind the gate moves. Measure first: once overflow is hidden the gutter reads 0.
|
|
62
|
+
const gutter = scrollbarGutterWidth();
|
|
63
|
+
if (gutter > 0 && !document.documentElement.style.paddingRight) {
|
|
64
|
+
document.documentElement.style.paddingRight = `${gutter}px`;
|
|
65
|
+
}
|
|
66
|
+
document.documentElement.style.overflow = "hidden";
|
|
67
|
+
for (const sibling of Array.from(document.body.children)) {
|
|
68
|
+
if (!(sibling instanceof HTMLElement) || sibling === host || sibling.hasAttribute("inert")) continue;
|
|
69
|
+
sibling.setAttribute("inert", "");
|
|
70
|
+
isolated.add(sibling);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const release = () => {
|
|
74
|
+
if (!active) return;
|
|
75
|
+
active = false;
|
|
76
|
+
for (const sibling of isolated) sibling.removeAttribute("inert");
|
|
77
|
+
isolated.clear();
|
|
78
|
+
document.documentElement.style.overflow = previousOverflow;
|
|
79
|
+
document.documentElement.style.paddingRight = previousPaddingRight;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
refresh();
|
|
83
|
+
return { release, refresh };
|
|
24
84
|
}
|
|
25
85
|
|
|
26
86
|
const HOST_MARKER = "data-tot-compliance";
|
|
87
|
+
let mountedHost: HTMLElement | null = null;
|
|
88
|
+
const HOST_STYLE_FLOOR = {
|
|
89
|
+
all: "initial",
|
|
90
|
+
display: "block",
|
|
91
|
+
position: "fixed",
|
|
92
|
+
inset: "0",
|
|
93
|
+
"z-index": "2147483647",
|
|
94
|
+
visibility: "visible",
|
|
95
|
+
opacity: "1",
|
|
96
|
+
"pointer-events": "none",
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
/** Inline-important floor: tenant selectors cannot hide or cover the compliance host. */
|
|
100
|
+
export function hardenComplianceHost(host: HTMLElement): void {
|
|
101
|
+
for (const [property, value] of Object.entries(HOST_STYLE_FLOOR)) {
|
|
102
|
+
if (
|
|
103
|
+
host.style.getPropertyValue(property) !== value ||
|
|
104
|
+
host.style.getPropertyPriority(property) !== "important"
|
|
105
|
+
) {
|
|
106
|
+
host.style.setProperty(property, value, "important");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isPlatformStylesheetHref(
|
|
112
|
+
href: string,
|
|
113
|
+
pageOrigin = "https://storefront.invalid/",
|
|
114
|
+
resolutionBase = pageOrigin,
|
|
115
|
+
): boolean {
|
|
116
|
+
try {
|
|
117
|
+
const origin = new URL(pageOrigin).origin;
|
|
118
|
+
const url = new URL(href, resolutionBase);
|
|
119
|
+
return (
|
|
120
|
+
url.origin === origin &&
|
|
121
|
+
(url.pathname.startsWith("/_astro/") || url.pathname.startsWith("/shared/"))
|
|
122
|
+
);
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function mountIsolatedAgeGate({ minAge, brand, tenantId, presentation }: Props) {
|
|
129
|
+
if (mountedHost?.isConnected) return () => {};
|
|
130
|
+
|
|
131
|
+
const host = document.createElement("div");
|
|
132
|
+
host.setAttribute(HOST_MARKER, "");
|
|
133
|
+
hardenComplianceHost(host);
|
|
134
|
+
const shadow = host.attachShadow({ mode: "closed" });
|
|
135
|
+
mountedHost = host;
|
|
136
|
+
let backgroundIsolation: BackgroundIsolation | null = null;
|
|
137
|
+
|
|
138
|
+
// Bring platform styles into the shadow root via allowlisted external
|
|
139
|
+
// stylesheet paths only (Tailwind utilities; build externalizes all CSS).
|
|
140
|
+
// Island CSS is inserted in <head> after client:load begins, so observe that
|
|
141
|
+
// trusted source as well as copying the links already present. Tenant
|
|
142
|
+
// /tenants/** stylesheets never cross this boundary. We do NOT clone inline
|
|
143
|
+
// theme styles: their nonce is response-specific, while inherited custom
|
|
144
|
+
// properties already carry the theme tokens across the boundary.
|
|
145
|
+
const copiedStylesheets = new Set<string>();
|
|
146
|
+
const copyPlatformStylesheet = (link: HTMLLinkElement) => {
|
|
147
|
+
const href = link.getAttribute("href") ?? "";
|
|
148
|
+
if (!isPlatformStylesheetHref(href, location.origin, document.baseURI)) return;
|
|
149
|
+
const resolvedHref = new URL(href, document.baseURI).href;
|
|
150
|
+
if (copiedStylesheets.has(resolvedHref)) return;
|
|
151
|
+
copiedStylesheets.add(resolvedHref);
|
|
152
|
+
shadow.appendChild(link.cloneNode(true));
|
|
153
|
+
};
|
|
154
|
+
document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]').forEach(copyPlatformStylesheet);
|
|
155
|
+
const stylesheetObserver = new MutationObserver((records) => {
|
|
156
|
+
for (const record of records) {
|
|
157
|
+
record.addedNodes.forEach((node) => {
|
|
158
|
+
if (node instanceof HTMLLinkElement && node.rel === "stylesheet") {
|
|
159
|
+
copyPlatformStylesheet(node);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
stylesheetObserver.observe(document.head, { childList: true });
|
|
165
|
+
|
|
166
|
+
const mount = document.createElement("div");
|
|
167
|
+
shadow.appendChild(mount);
|
|
168
|
+
render(
|
|
169
|
+
<AgeGate
|
|
170
|
+
minAge={minAge}
|
|
171
|
+
brand={brand}
|
|
172
|
+
tenantId={tenantId}
|
|
173
|
+
presentation={presentation}
|
|
174
|
+
onBlockingChange={(blocking) => {
|
|
175
|
+
if (blocking) {
|
|
176
|
+
backgroundIsolation ??= isolateAgeGateBackground(host);
|
|
177
|
+
backgroundIsolation.refresh();
|
|
178
|
+
} else {
|
|
179
|
+
backgroundIsolation?.release();
|
|
180
|
+
backgroundIsolation = null;
|
|
181
|
+
}
|
|
182
|
+
}}
|
|
183
|
+
/>,
|
|
184
|
+
mount,
|
|
185
|
+
);
|
|
186
|
+
document.body.appendChild(host);
|
|
187
|
+
|
|
188
|
+
// Re-assert: restore the host if tenant code detaches or hides it.
|
|
189
|
+
const ensure = () => {
|
|
190
|
+
if (!host.isConnected || document.body.lastElementChild !== host) {
|
|
191
|
+
document.body.appendChild(host);
|
|
192
|
+
}
|
|
193
|
+
if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
|
|
194
|
+
hardenComplianceHost(host);
|
|
195
|
+
backgroundIsolation?.refresh();
|
|
196
|
+
};
|
|
197
|
+
const observer = new MutationObserver(ensure);
|
|
198
|
+
observer.observe(document.body, { childList: true });
|
|
199
|
+
observer.observe(host, {
|
|
200
|
+
attributes: true,
|
|
201
|
+
attributeFilter: ["style", "class", "hidden"],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return () => {
|
|
205
|
+
observer.disconnect();
|
|
206
|
+
stylesheetObserver.disconnect();
|
|
207
|
+
backgroundIsolation?.release();
|
|
208
|
+
render(null, mount);
|
|
209
|
+
host.remove();
|
|
210
|
+
if (mountedHost === host) mountedHost = null;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
27
213
|
|
|
28
|
-
export default function IsolatedAgeGate(
|
|
29
|
-
useEffect(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const host = document.createElement("div");
|
|
34
|
-
host.setAttribute(HOST_MARKER, "");
|
|
35
|
-
const shadow = host.attachShadow({ mode: "closed" });
|
|
36
|
-
|
|
37
|
-
// Bring platform styles into the shadow root via the external stylesheet
|
|
38
|
-
// <link>s only (Tailwind utilities; build externalizes all CSS). We do NOT
|
|
39
|
-
// clone the inline theme <style>: cloning strips its CSP nonce, and it's
|
|
40
|
-
// unneeded — CSS custom properties (the theme tokens on :root) inherit
|
|
41
|
-
// across the shadow boundary, so the gate keeps the tenant's colors anyway.
|
|
42
|
-
document
|
|
43
|
-
.querySelectorAll('link[rel="stylesheet"]')
|
|
44
|
-
.forEach((link) => shadow.appendChild(link.cloneNode(true)));
|
|
45
|
-
|
|
46
|
-
const mount = document.createElement("div");
|
|
47
|
-
shadow.appendChild(mount);
|
|
48
|
-
render(<AgeGate minAge={minAge} brand={brand} />, mount);
|
|
49
|
-
document.body.appendChild(host);
|
|
50
|
-
|
|
51
|
-
// Re-assert: restore the host if tenant code detaches or hides it.
|
|
52
|
-
const ensure = () => {
|
|
53
|
-
if (!host.isConnected) document.body.appendChild(host);
|
|
54
|
-
if (host.style.display === "none") host.style.display = "";
|
|
55
|
-
if (host.style.visibility === "hidden") host.style.visibility = "";
|
|
56
|
-
if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
|
|
57
|
-
};
|
|
58
|
-
const observer = new MutationObserver(ensure);
|
|
59
|
-
observer.observe(document.body, { childList: true });
|
|
60
|
-
observer.observe(host, {
|
|
61
|
-
attributes: true,
|
|
62
|
-
attributeFilter: ["style", "class", "hidden"],
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
return () => {
|
|
66
|
-
observer.disconnect();
|
|
67
|
-
render(null, mount);
|
|
68
|
-
host.remove();
|
|
69
|
-
};
|
|
70
|
-
}, [minAge, brand]);
|
|
214
|
+
export default function IsolatedAgeGate(props: Props) {
|
|
215
|
+
useEffect(
|
|
216
|
+
() => mountIsolatedAgeGate(props),
|
|
217
|
+
[props.minAge, props.brand, props.tenantId, props.presentation],
|
|
218
|
+
);
|
|
71
219
|
|
|
72
220
|
return null;
|
|
73
221
|
}
|
|
@@ -18,6 +18,8 @@ import { withClientBase } from "@/lib/basePath";
|
|
|
18
18
|
|
|
19
19
|
interface Props {
|
|
20
20
|
action?: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
label?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
const MAX_RESULTS = 6;
|
|
@@ -35,7 +37,11 @@ function thumbFor(doc: TypeaheadDoc): string {
|
|
|
35
37
|
});
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export default function SearchTypeahead({
|
|
40
|
+
export default function SearchTypeahead({
|
|
41
|
+
action = "/search",
|
|
42
|
+
placeholder = "Search products",
|
|
43
|
+
label,
|
|
44
|
+
}: Props) {
|
|
39
45
|
const [query, setQuery] = useState("");
|
|
40
46
|
const [results, setResults] = useState<TypeaheadDoc[]>([]);
|
|
41
47
|
const [open, setOpen] = useState(false);
|
|
@@ -145,7 +151,7 @@ export default function SearchTypeahead({ action = "/search" }: Props) {
|
|
|
145
151
|
<form
|
|
146
152
|
ref={rootRef}
|
|
147
153
|
role="search"
|
|
148
|
-
aria-label=
|
|
154
|
+
aria-label={label ?? "Search"}
|
|
149
155
|
action={action}
|
|
150
156
|
method="get"
|
|
151
157
|
onSubmit={(e) => {
|
|
@@ -176,14 +182,14 @@ export default function SearchTypeahead({ action = "/search" }: Props) {
|
|
|
176
182
|
value={query}
|
|
177
183
|
autocomplete="off"
|
|
178
184
|
spellcheck={false}
|
|
179
|
-
aria-label="Search
|
|
185
|
+
aria-label={label ?? "Search"}
|
|
180
186
|
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
|
|
181
187
|
role="combobox"
|
|
182
188
|
aria-expanded={open}
|
|
183
189
|
aria-controls={LISTBOX_ID}
|
|
184
190
|
aria-autocomplete="list"
|
|
185
191
|
aria-activedescendant={activeId}
|
|
186
|
-
placeholder=
|
|
192
|
+
placeholder={placeholder}
|
|
187
193
|
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
|
|
188
194
|
onFocus={() => {
|
|
189
195
|
if (results.length > 0) setOpen(true);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import Section from "@/components/Section.astro";
|
|
8
8
|
import type { FaqBlock } from "@/lib/storyblok/content-model";
|
|
9
|
+
import { safeJsonScriptContent } from "@/lib/jsonScript";
|
|
9
10
|
interface Props {
|
|
10
11
|
block: FaqBlock;
|
|
11
12
|
}
|
|
@@ -20,12 +21,18 @@ const faqLd = {
|
|
|
20
21
|
acceptedAnswer: { "@type": "Answer", text: it.a },
|
|
21
22
|
})),
|
|
22
23
|
};
|
|
24
|
+
const faqJsonLd = safeJsonScriptContent(faqLd);
|
|
23
25
|
---
|
|
24
26
|
<Section eyebrow={block.eyebrow} title={block.title} tone="surface">
|
|
27
|
+
{block.lede && (
|
|
28
|
+
<p class="mb-8 max-w-[42rem] text-[var(--color-muted)]" data-reveal>{block.lede}</p>
|
|
29
|
+
)}
|
|
25
30
|
<div class="mx-auto max-w-[46rem] divide-y divide-[var(--color-border)]" data-reveal>
|
|
26
31
|
{block.items.map((it) => (
|
|
27
32
|
<details class="group py-4">
|
|
28
|
-
|
|
33
|
+
{/* `py-2.5 -my-2.5` takes the disclosure row to the 44px tap minimum
|
|
34
|
+
and gives the space straight back, so the rows keep their rhythm. */}
|
|
35
|
+
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 py-2.5 -my-2.5 font-display text-base font-semibold text-[var(--color-text)]">
|
|
29
36
|
{it.q}
|
|
30
37
|
<span aria-hidden="true" class="shrink-0 text-[var(--color-accent)] transition-transform group-open:rotate-45">+</span>
|
|
31
38
|
</summary>
|
|
@@ -34,4 +41,4 @@ const faqLd = {
|
|
|
34
41
|
))}
|
|
35
42
|
</div>
|
|
36
43
|
</Section>
|
|
37
|
-
<script type="application/ld+json" set:html={
|
|
44
|
+
<script type="application/ld+json" set:html={faqJsonLd} is:inline />
|
|
@@ -70,7 +70,7 @@ export function synthesizeDevTenant(
|
|
|
70
70
|
const appDomain = hints.appDomain ?? id;
|
|
71
71
|
// The tenant's OWN tree analytics.json → the render plane's declaration seam,
|
|
72
72
|
// fail-closed (a missing/invalid file yields no declarations, never a throw).
|
|
73
|
-
const integrations = loadTenantAnalyticsDeclarations(hints.analyticsRaw, id);
|
|
73
|
+
const integrations = loadTenantAnalyticsDeclarations(hints.analyticsRaw, id, appDomain);
|
|
74
74
|
const base: TenantConfig = {
|
|
75
75
|
tenant_id: id,
|
|
76
76
|
appDomain,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* keeps working before KV is seeded and during a gradual cutover from static.
|
|
17
17
|
*/
|
|
18
18
|
import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
|
|
19
|
+
import { withPlatformPolicy } from "@/config/tenants";
|
|
19
20
|
|
|
20
21
|
const DOMAIN_PREFIX = "domain:";
|
|
21
22
|
const TENANT_PREFIX = "tenant:";
|
|
@@ -35,11 +36,16 @@ export class KvTenantResolver implements TenantResolver {
|
|
|
35
36
|
private readonly fallback?: TenantResolver,
|
|
36
37
|
) {}
|
|
37
38
|
|
|
39
|
+
// Platform policy is composed over every KV-sourced record (ADR 0035). A record
|
|
40
|
+
// written before a platform field existed does not carry it, so without this the
|
|
41
|
+
// declaration is inert for exactly the established tenants — and a record could
|
|
42
|
+
// otherwise set policy that is not a tenant's to set. The static fallback needs
|
|
43
|
+
// no composition: it IS the declaration.
|
|
38
44
|
private async readTenant(id: string): Promise<TenantConfig | null> {
|
|
39
45
|
const raw = await this.kv.get(TENANT_PREFIX + id, { cacheTtl: CACHE_TTL });
|
|
40
46
|
if (!raw) return null;
|
|
41
47
|
try {
|
|
42
|
-
return JSON.parse(raw) as TenantConfig;
|
|
48
|
+
return withPlatformPolicy(JSON.parse(raw) as TenantConfig);
|
|
43
49
|
} catch {
|
|
44
50
|
return null;
|
|
45
51
|
}
|