@tokenoftrust/storefront-runner 2.4.35 → 2.4.36
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/public/shared/commerce-chrome.css +5 -1
- package/apps/storefront/src/components/islands/AgeGate.tsx +54 -19
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +26 -11
- package/apps/storefront/src/lib/chrome/model.ts +1 -0
- package/docs/widget-library-guide.md +2 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/chrome.ts +29 -0
|
@@ -321,7 +321,11 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
321
321
|
|
|
322
322
|
/* --- Age gate (21+ interstitial; remembered per device) ------------------- */
|
|
323
323
|
/* Overlay + on-dark surfaces derive from --color-text (deep ink) so no brand hex. */
|
|
324
|
-
|
|
324
|
+
/* Sized by viewport units, not `inset: 0`: on a phone the fixed containing block is the
|
|
325
|
+
LAYOUT viewport, which widens with content wider than the screen and shrinks as it
|
|
326
|
+
settles, so a panel centered in it moves with the page and can sit off-screen. The
|
|
327
|
+
shadow spread carries the backdrop over any layout viewport wider than the screen. */
|
|
328
|
+
.age-gate { box-sizing: border-box; position: fixed; top: 0; left: 0; width: 100vw; height: 100svh; overflow-y: auto; z-index: 1000; display: none; align-items: center; justify-content: center; padding: 24px; background: color-mix(in srgb, var(--color-text) 78%, transparent); box-shadow: 0 0 0 200vmax color-mix(in srgb, var(--color-text) 78%, transparent); }
|
|
325
329
|
.age-gate.is-open { display: flex; }
|
|
326
330
|
.age-gate__panel { width: min(30rem, 100%); background: var(--color-surface); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: clamp(24px, 4vw, 40px); text-align: center; }
|
|
327
331
|
.age-gate__mark { font-family: var(--font-display); font-weight: 800; letter-spacing: .04em; font-size: 1.05rem; color: var(--color-text); }
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { useEffect, useRef, useState } from "preact/hooks";
|
|
10
10
|
import type { ChromeAgeGate } from "@/lib/chrome/model";
|
|
11
|
-
import { deriveAgeGateStorageKey, resolveAgeGateCopy } from "@/lib/chrome/model";
|
|
11
|
+
import { deriveAgeGateStorageKey, isSafeAgeGateHref, resolveAgeGateCopy } from "@/lib/chrome/model";
|
|
12
12
|
|
|
13
13
|
export interface Props {
|
|
14
14
|
minAge: number;
|
|
@@ -40,6 +40,18 @@ export function resolveAgeGateRuntime({
|
|
|
40
40
|
resolveAgeGateCopy(presentation?.denyText, minAge) ??
|
|
41
41
|
`You must be ${minAge} or older to view this store. Please close this tab.`,
|
|
42
42
|
fineText: resolveAgeGateCopy(presentation?.fineText, minAge),
|
|
43
|
+
showNo: presentation?.showNo !== false,
|
|
44
|
+
eyebrow:
|
|
45
|
+
presentation?.eyebrow === false
|
|
46
|
+
? null
|
|
47
|
+
: (resolveAgeGateCopy(presentation?.eyebrow, minAge) ?? "Age verification"),
|
|
48
|
+
helpLink:
|
|
49
|
+
presentation?.helpLink && isSafeAgeGateHref(presentation.helpLink.href)
|
|
50
|
+
? {
|
|
51
|
+
label: resolveAgeGateCopy(presentation.helpLink.label, minAge)!,
|
|
52
|
+
href: presentation.helpLink.href,
|
|
53
|
+
}
|
|
54
|
+
: null,
|
|
43
55
|
};
|
|
44
56
|
}
|
|
45
57
|
|
|
@@ -47,7 +59,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
47
59
|
const [state, setState] = useState<"hidden" | "ask" | "denied">("hidden");
|
|
48
60
|
const yesRef = useRef<HTMLButtonElement>(null);
|
|
49
61
|
const retryRef = useRef<HTMLButtonElement>(null);
|
|
50
|
-
const { storageKey, compact, title, lede, yesLabel, noLabel, denyText, fineText } =
|
|
62
|
+
const { storageKey, compact, title, lede, yesLabel, noLabel, denyText, fineText, showNo, eyebrow, helpLink } =
|
|
51
63
|
resolveAgeGateRuntime({ minAge, brand, tenantId, presentation });
|
|
52
64
|
|
|
53
65
|
useEffect(() => {
|
|
@@ -91,24 +103,36 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
91
103
|
// attributes are intentionally admitted by style-src-attr. Keep the modal
|
|
92
104
|
// self-contained, token-driven, and responsive without opening either the
|
|
93
105
|
// shadow root or the CSP.
|
|
106
|
+
// The backdrop covers the fixed containing block, which on mobile is the LAYOUT
|
|
107
|
+
// viewport: it grows with any content wider than the screen and shrinks as that
|
|
108
|
+
// content settles. Centering the panel in it let the page under the gate move the
|
|
109
|
+
// panel (CLS 0.30 on product pages). The panel is placed in a frame sized only
|
|
110
|
+
// by viewport units, which no document content can change.
|
|
94
111
|
const overlayStyle = {
|
|
95
112
|
boxSizing: "border-box",
|
|
96
113
|
position: "fixed" as const,
|
|
97
114
|
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
115
|
background: compact ? "rgba(0, 0, 0, 0.5)" : "color-mix(in srgb, var(--color-text, #111) 75%, transparent)",
|
|
105
116
|
backdropFilter: compact ? undefined : "blur(4px)",
|
|
106
117
|
color: "var(--color-text, #171717)",
|
|
107
118
|
fontFamily: "var(--font-body, system-ui, sans-serif)",
|
|
108
119
|
pointerEvents: "auto",
|
|
109
120
|
};
|
|
121
|
+
const frameStyle = {
|
|
122
|
+
boxSizing: "border-box",
|
|
123
|
+
position: "absolute" as const,
|
|
124
|
+
top: "0",
|
|
125
|
+
left: "0",
|
|
126
|
+
width: "100vw",
|
|
127
|
+
height: "100svh",
|
|
128
|
+
display: "flex",
|
|
129
|
+
overflowY: "auto" as const,
|
|
130
|
+
padding: compact ? "min(24px, 4vw)" : "20px",
|
|
131
|
+
};
|
|
110
132
|
const panelStyle = {
|
|
111
133
|
boxSizing: "border-box",
|
|
134
|
+
// Auto margins center without clipping a panel taller than the viewport.
|
|
135
|
+
margin: compact ? "0 auto auto" : "auto",
|
|
112
136
|
width: compact ? "min(600px, calc(100vw - 32px))" : "min(480px, calc(100vw - 40px))",
|
|
113
137
|
minHeight: compact ? "min(382px, calc(100vh - 48px))" : undefined,
|
|
114
138
|
maxHeight: compact ? "calc(100vh - 48px)" : undefined,
|
|
@@ -141,7 +165,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
141
165
|
onKeyDown={(event) => {
|
|
142
166
|
if (event.key !== "Tab") return;
|
|
143
167
|
const controls = Array.from(
|
|
144
|
-
event.currentTarget.querySelectorAll<
|
|
168
|
+
event.currentTarget.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]"),
|
|
145
169
|
);
|
|
146
170
|
if (controls.length === 0) return;
|
|
147
171
|
const first = controls[0]!;
|
|
@@ -155,6 +179,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
155
179
|
}
|
|
156
180
|
}}
|
|
157
181
|
>
|
|
182
|
+
<div style={frameStyle}>
|
|
158
183
|
<div style={panelStyle}>
|
|
159
184
|
{state === "ask" ? (
|
|
160
185
|
<>
|
|
@@ -164,9 +189,9 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
164
189
|
<span class={part.className}>{part.text}</span>
|
|
165
190
|
))}
|
|
166
191
|
</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)" }}>
|
|
169
|
-
)}
|
|
192
|
+
) : eyebrow ? (
|
|
193
|
+
<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)" }}>{eyebrow}</p>
|
|
194
|
+
) : null}
|
|
170
195
|
<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
196
|
{title}
|
|
172
197
|
</h2>
|
|
@@ -182,14 +207,23 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
182
207
|
>
|
|
183
208
|
{yesLabel}
|
|
184
209
|
</button>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
210
|
+
{showNo && (
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
onClick={() => setState("denied")}
|
|
214
|
+
style={{ ...buttonBase, width: compact ? "100%" : undefined, border: "1px solid var(--color-border, #d0d0d0)", background: "transparent", color: "var(--color-text, #171717)", fontWeight: 500 }}
|
|
215
|
+
>
|
|
216
|
+
{noLabel}
|
|
217
|
+
</button>
|
|
218
|
+
)}
|
|
192
219
|
</div>
|
|
220
|
+
{helpLink && (
|
|
221
|
+
<p style={{ margin: "16px 0 0", fontSize: "14px", lineHeight: 1.45 }}>
|
|
222
|
+
<a href={helpLink.href} style={{ color: "var(--color-accent-ink, var(--color-primary, #111))", textDecoration: "underline" }}>
|
|
223
|
+
{helpLink.label}
|
|
224
|
+
</a>
|
|
225
|
+
</p>
|
|
226
|
+
)}
|
|
193
227
|
{fineText && (
|
|
194
228
|
<p style={{ maxWidth: "32rem", margin: "20px auto 0", color: "var(--color-muted, #616161)", fontSize: "12px", lineHeight: 1.45 }}>
|
|
195
229
|
{fineText}
|
|
@@ -213,6 +247,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
213
247
|
</>
|
|
214
248
|
)}
|
|
215
249
|
</div>
|
|
250
|
+
</div>
|
|
216
251
|
</div>
|
|
217
252
|
);
|
|
218
253
|
}
|
|
@@ -217,21 +217,36 @@ export function mountIsolatedAgeGate({ minAge, brand, tenantId, presentation }:
|
|
|
217
217
|
mount,
|
|
218
218
|
);
|
|
219
219
|
|
|
220
|
-
// Re-assert: restore the host if tenant code detaches or hides it.
|
|
220
|
+
// Re-assert: restore the host if tenant code detaches or hides it. The repair
|
|
221
|
+
// writes exactly what this observer watches, so it runs disconnected: a repair
|
|
222
|
+
// that is not perfectly idempotent can never re-trigger itself and spin the
|
|
223
|
+
// main thread in an unbounded microtask loop.
|
|
224
|
+
const observeHost = () => {
|
|
225
|
+
observer.observe(document.body, { childList: true });
|
|
226
|
+
observer.observe(host, {
|
|
227
|
+
attributes: true,
|
|
228
|
+
attributeFilter: ["style", "class", "hidden"],
|
|
229
|
+
});
|
|
230
|
+
};
|
|
221
231
|
const ensure = () => {
|
|
222
|
-
|
|
223
|
-
|
|
232
|
+
observer.disconnect();
|
|
233
|
+
try {
|
|
234
|
+
if (!host.isConnected || document.body.lastElementChild !== host) {
|
|
235
|
+
// Moving the host blurs whatever the gate had focused; give it back so a
|
|
236
|
+
// keyboard visitor is never stranded by a script appending to <body>.
|
|
237
|
+
const focused = shadow.activeElement as HTMLElement | null;
|
|
238
|
+
document.body.appendChild(host);
|
|
239
|
+
focused?.focus({ preventScroll: true });
|
|
240
|
+
}
|
|
241
|
+
if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
|
|
242
|
+
hardenComplianceHost(host);
|
|
243
|
+
backgroundIsolation?.refresh();
|
|
244
|
+
} finally {
|
|
245
|
+
observeHost();
|
|
224
246
|
}
|
|
225
|
-
if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
|
|
226
|
-
hardenComplianceHost(host);
|
|
227
|
-
backgroundIsolation?.refresh();
|
|
228
247
|
};
|
|
229
248
|
const observer = new MutationObserver(ensure);
|
|
230
|
-
|
|
231
|
-
observer.observe(host, {
|
|
232
|
-
attributes: true,
|
|
233
|
-
attributeFilter: ["style", "class", "hidden"],
|
|
234
|
-
});
|
|
249
|
+
observeHost();
|
|
235
250
|
|
|
236
251
|
return () => {
|
|
237
252
|
observer.disconnect();
|
|
@@ -1426,7 +1426,8 @@ stable `id` (a governed-action-registry substitute, independent of the visible `
|
|
|
1426
1426
|
to `ChromeHeader`/`ChromeFooter`, it exports `ChromeBrand`, `ChromeSearch`, `ChromeUtilityNav`,
|
|
1427
1427
|
`ChromeAnnouncement` (`{ variant: "marquee" | "bar" | "solid"; messages: string[]; label?;
|
|
1428
1428
|
dismissible?; rotateMs?; storageKey? }`), `ChromeAgeGate` (`{ minAge: number; storageKey?; title?;
|
|
1429
|
-
lede?; yesLabel?; noLabel?; denyText?; fineText?; mark
|
|
1429
|
+
lede?; yesLabel?; noLabel?; denyText?; fineText?; mark?; showNo?; eyebrow?: string | false;
|
|
1430
|
+
helpLink?: { label; href } }`), `ChromeFooterBrand`, `ChromeTrustMark`,
|
|
1430
1431
|
`ChromeHead`, `ChromeCta`, `ChromeNavItem`, and `ChromeConfig` itself.
|
|
1431
1432
|
|
|
1432
1433
|
### `lib/chrome/parseConfig.ts` — the validation gate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.36",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -228,6 +228,17 @@ export interface ChromeAgeGate {
|
|
|
228
228
|
fineText?: string;
|
|
229
229
|
/** Optional styled wordmark above the title (spans, like ChromeBrand.wordmark). */
|
|
230
230
|
mark?: { text: string; className?: string }[];
|
|
231
|
+
/** `false` renders a single affirm button; the deny flow is the default. */
|
|
232
|
+
showNo?: boolean;
|
|
233
|
+
/** The line above the title when no `mark` is set: text replaces "Age verification"; `false` omits it. */
|
|
234
|
+
eyebrow?: string | false;
|
|
235
|
+
/** Help/FAQ link under the actions. `href` is a same-site path or an https URL. */
|
|
236
|
+
helpLink?: { label: string; href: string };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A help link the platform-owned gate will render: a same-site path or an https URL. */
|
|
240
|
+
export function isSafeAgeGateHref(href: string): boolean {
|
|
241
|
+
return /^\/(?![/\\])/.test(href) || /^https:\/\/[^/\\]/i.test(href);
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
/** Platform-owned persistence scope for a tenant's enforced age affirmation. */
|
|
@@ -590,6 +601,24 @@ function validateAgeGate(v: unknown, path: string, errors: string[]): v is Chrom
|
|
|
590
601
|
});
|
|
591
602
|
}
|
|
592
603
|
}
|
|
604
|
+
if (v.showNo !== undefined && typeof v.showNo !== "boolean") {
|
|
605
|
+
errors.push(`${path}.showNo: must be a boolean`);
|
|
606
|
+
}
|
|
607
|
+
if (v.eyebrow !== undefined && v.eyebrow !== false && !isNonEmptyString(v.eyebrow)) {
|
|
608
|
+
errors.push(`${path}.eyebrow: must be a non-empty string or false`);
|
|
609
|
+
}
|
|
610
|
+
if (v.helpLink !== undefined) {
|
|
611
|
+
if (!isObject(v.helpLink)) {
|
|
612
|
+
errors.push(`${path}.helpLink: must be an object`);
|
|
613
|
+
} else {
|
|
614
|
+
if (!isNonEmptyString(v.helpLink.label)) {
|
|
615
|
+
errors.push(`${path}.helpLink.label: required non-empty string`);
|
|
616
|
+
}
|
|
617
|
+
if (typeof v.helpLink.href !== "string" || !isSafeAgeGateHref(v.helpLink.href)) {
|
|
618
|
+
errors.push(`${path}.helpLink.href: required; a same-site path ("/pages/faq") or an https:// URL`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
593
622
|
return errors.length === before;
|
|
594
623
|
}
|
|
595
624
|
|