@tokenoftrust/storefront-runner 2.0.40 → 2.0.42
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/platform/google-measurement/v2/entry.mjs +1 -1
- 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 +2 -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/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/csp-compiler.ts +6 -3
- 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 +34 -6
- 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.d.mts +2 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +35 -1
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +45 -12
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +28 -7
- package/packages/public-runtime/src/integration/native-ga4.ts +18 -2
- package/packages/public-runtime/src/integration/platform-adapters.ts +14 -3
- 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 +12 -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
|
@@ -35,6 +35,10 @@ interface Props {
|
|
|
35
35
|
nav?: ChromeNavItem[];
|
|
36
36
|
/** Search form action (GET). Omit to hide search. Mounts the live SearchTypeahead island. */
|
|
37
37
|
searchAction?: string;
|
|
38
|
+
/** Search input placeholder (defaults to "Search products"). */
|
|
39
|
+
searchPlaceholder?: string;
|
|
40
|
+
/** Accessible search label (defaults to "Search"). */
|
|
41
|
+
searchLabel?: string;
|
|
38
42
|
/** Announcement text surfaced inside the mobile drawer (the desktop banner is a Layout-level sibling, e.g. <AnnouncementBar>). */
|
|
39
43
|
announcement?: string;
|
|
40
44
|
/** Generic account-icon link (no session awareness). Prefer `authLink` for a real session-aware entry point — passing both renders two affordances. */
|
|
@@ -69,6 +73,8 @@ const {
|
|
|
69
73
|
brandHref,
|
|
70
74
|
nav = [],
|
|
71
75
|
searchAction,
|
|
76
|
+
searchPlaceholder,
|
|
77
|
+
searchLabel,
|
|
72
78
|
announcement,
|
|
73
79
|
accountHref,
|
|
74
80
|
authLink,
|
|
@@ -87,6 +93,7 @@ const homeHref = withBase(basePath, brandHref ?? "/");
|
|
|
87
93
|
const showLogo = !!logo && /^(https?:|\/)/.test(logo.src);
|
|
88
94
|
const dark = variant === "dark";
|
|
89
95
|
const minimal = layout === "minimal";
|
|
96
|
+
const compact = layout === "compact-centered";
|
|
90
97
|
|
|
91
98
|
const ctaClass = (v?: string) =>
|
|
92
99
|
v === "secondary" || v === "ghost"
|
|
@@ -120,7 +127,7 @@ const mobileNav = nav.map((item) => ({
|
|
|
120
127
|
const currentPath = Astro.url.pathname;
|
|
121
128
|
---
|
|
122
129
|
|
|
123
|
-
<header class:list={[sticky && "sticky top-0 z-40", className]}>
|
|
130
|
+
<header class:list={[sticky && !compact && "sticky top-0 z-40", className]} data-layout={layout}>
|
|
124
131
|
<div
|
|
125
132
|
class:list={[
|
|
126
133
|
"border-b backdrop-blur",
|
|
@@ -129,11 +136,26 @@ const currentPath = Astro.url.pathname;
|
|
|
129
136
|
: "border-border bg-surface/95 text-text",
|
|
130
137
|
]}
|
|
131
138
|
>
|
|
132
|
-
<div
|
|
139
|
+
<div
|
|
140
|
+
class:list={[
|
|
141
|
+
"container-prose h-16 items-center gap-4",
|
|
142
|
+
compact ? "relative grid grid-cols-[1fr_auto_1fr]" : "flex",
|
|
143
|
+
]}
|
|
144
|
+
>
|
|
145
|
+
{
|
|
146
|
+
compact && nav.length > 0 && (
|
|
147
|
+
<div class="hidden min-w-0 md:block">
|
|
148
|
+
<MegaMenu items={nav} />
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
133
152
|
<!-- Logo / wordmark -->
|
|
134
153
|
<a
|
|
135
154
|
href={homeHref}
|
|
136
|
-
class=
|
|
155
|
+
class:list={[
|
|
156
|
+
"flex shrink-0 items-center transition-opacity hover:opacity-80",
|
|
157
|
+
compact && "absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center",
|
|
158
|
+
]}
|
|
137
159
|
aria-label={logo?.alt ?? wordmark ?? "Home"}
|
|
138
160
|
>
|
|
139
161
|
{
|
|
@@ -142,9 +164,23 @@ const currentPath = Astro.url.pathname;
|
|
|
142
164
|
Brand logo: a bare merchant URL shown at a fixed header height via
|
|
143
165
|
object-contain. It can't go through <Img> — that forces an
|
|
144
166
|
aspect-ratio crop box + a placeholder background, which would alter
|
|
145
|
-
a transparent logo's appearance.
|
|
146
|
-
|
|
147
|
-
|
|
167
|
+
a transparent logo's appearance.
|
|
168
|
+
|
|
169
|
+
BOTH dimensions are pinned in CSS, deliberately. `h-9 w-auto` looks
|
|
170
|
+
like it reserves space — the width/height attributes give an
|
|
171
|
+
aspect ratio — but the width still RESOLVES from the image: the
|
|
172
|
+
declared ratio before load, the real file's ratio after. When those
|
|
173
|
+
disagree the header resizes on load and everything below it jumps.
|
|
174
|
+
Measured 2026-09-21: declared 176x69, rendered 87x34, and the
|
|
175
|
+
resulting reflow of an 18,666px <main> scored CLS 0.82 — 96% of the
|
|
176
|
+
page's total, and enough to make the whole run unmeasurable.
|
|
177
|
+
|
|
178
|
+
Computing the width here from the DECLARED dimensions makes the box
|
|
179
|
+
a pure function of config: identical before and after load, for
|
|
180
|
+
every tenant, whether or not their declared size matches the file.
|
|
181
|
+
object-contain then letterboxes any mismatch instead of resizing
|
|
182
|
+
the box, which is exactly the failure mode we can never see coming
|
|
183
|
+
because it depends on a merchant's asset.
|
|
148
184
|
*/
|
|
149
185
|
<img
|
|
150
186
|
src={logo!.src}
|
|
@@ -153,7 +189,11 @@ const currentPath = Astro.url.pathname;
|
|
|
153
189
|
height={logo!.height ?? 48}
|
|
154
190
|
loading="eager"
|
|
155
191
|
decoding="async"
|
|
156
|
-
class="h-9
|
|
192
|
+
class="h-9 object-contain"
|
|
193
|
+
style={`width:${Math.min(
|
|
194
|
+
180,
|
|
195
|
+
Math.round((36 * (logo!.width ?? 160)) / (logo!.height ?? 48)),
|
|
196
|
+
)}px`}
|
|
157
197
|
/>
|
|
158
198
|
) : (
|
|
159
199
|
<span class="font-display text-lg font-bold tracking-tight">
|
|
@@ -165,15 +205,34 @@ const currentPath = Astro.url.pathname;
|
|
|
165
205
|
|
|
166
206
|
<!-- Search (live typeahead island) -->
|
|
167
207
|
{
|
|
168
|
-
!minimal && searchAction && (
|
|
208
|
+
!minimal && !compact && searchAction && (
|
|
169
209
|
<div class="hidden flex-1 md:flex">
|
|
170
|
-
<SearchTypeahead
|
|
210
|
+
<SearchTypeahead
|
|
211
|
+
client:idle
|
|
212
|
+
action={searchAction}
|
|
213
|
+
placeholder={searchPlaceholder}
|
|
214
|
+
label={searchLabel}
|
|
215
|
+
/>
|
|
171
216
|
</div>
|
|
172
217
|
)
|
|
173
218
|
}
|
|
174
219
|
|
|
175
220
|
<!-- Actions: saved items + marketing CTAs + auth + account + cart (link only) + mobile menu -->
|
|
176
|
-
<div class="
|
|
221
|
+
<div class:list={["flex items-center gap-1", compact ? "col-start-3 justify-self-end" : "ml-auto"]}>
|
|
222
|
+
{
|
|
223
|
+
compact && searchAction && (
|
|
224
|
+
<a
|
|
225
|
+
href={withBase(basePath, searchAction)}
|
|
226
|
+
aria-label={searchLabel ?? "Search"}
|
|
227
|
+
class="hidden h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent md:grid"
|
|
228
|
+
>
|
|
229
|
+
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
|
|
230
|
+
<circle cx="11" cy="11" r="7" />
|
|
231
|
+
<path d="M21 21l-4.3-4.3" />
|
|
232
|
+
</svg>
|
|
233
|
+
</a>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
177
236
|
{
|
|
178
237
|
!minimal && savedItemsHref && (
|
|
179
238
|
<a
|
|
@@ -247,7 +306,7 @@ const currentPath = Astro.url.pathname;
|
|
|
247
306
|
cartHref && (
|
|
248
307
|
<a
|
|
249
308
|
href={withBase(basePath, cartHref)}
|
|
250
|
-
aria-label=
|
|
309
|
+
aria-label={`Cart, ${cartCount} ${cartCount === 1 ? "item" : "items"}`}
|
|
251
310
|
class="relative grid h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent"
|
|
252
311
|
>
|
|
253
312
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
|
|
@@ -269,7 +328,7 @@ const currentPath = Astro.url.pathname;
|
|
|
269
328
|
<!-- Mobile menu: the MobileNav island (focus-trap, scroll-lock, Escape-to-close) -->
|
|
270
329
|
{
|
|
271
330
|
!minimal && nav.length > 0 && (
|
|
272
|
-
<div class="md:hidden">
|
|
331
|
+
<div class:list={["md:hidden", compact && "absolute left-0 top-1/2 -translate-y-1/2"]}>
|
|
273
332
|
<MobileNav client:idle nav={mobileNav} announcement={announcement} currentPath={currentPath} />
|
|
274
333
|
</div>
|
|
275
334
|
)
|
|
@@ -288,7 +347,7 @@ const currentPath = Astro.url.pathname;
|
|
|
288
347
|
[data-mobile-nav-fallback] effect) so JS-enabled visitors get the drawer
|
|
289
348
|
as the primary/enhanced experience; this is additive, not a replacement. -->
|
|
290
349
|
{
|
|
291
|
-
!minimal && nav.length > 0 && (
|
|
350
|
+
!minimal && !compact && nav.length > 0 && (
|
|
292
351
|
<div class="border-b border-border bg-surface">
|
|
293
352
|
<div class="container-prose">
|
|
294
353
|
<MegaMenu items={nav} />
|
|
@@ -310,3 +369,14 @@ const currentPath = Astro.url.pathname;
|
|
|
310
369
|
)
|
|
311
370
|
}
|
|
312
371
|
</header>
|
|
372
|
+
|
|
373
|
+
{
|
|
374
|
+
compact && (
|
|
375
|
+
<nav class:list={["fixed inset-x-0 bottom-0 z-50 grid h-[62px] border-t border-border bg-surface shadow-[0_-3px_14px_color-mix(in_srgb,var(--color-text)_10%,transparent)] md:hidden", cartHref ? "grid-cols-4" : "grid-cols-3"]} aria-label="Quick links">
|
|
376
|
+
<a href={homeHref} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Home</a>
|
|
377
|
+
<a href={withBase(basePath, searchAction ?? "/search")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Search</a>
|
|
378
|
+
<a href={accountHref ? withBase(basePath, accountHref) : (authLink?.href ?? "/auth/login")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Account</a>
|
|
379
|
+
{cartHref && <a href={withBase(basePath, cartHref)} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Cart</a>}
|
|
380
|
+
</nav>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
@@ -39,7 +39,7 @@ const label =
|
|
|
39
39
|
: `Rated ${clamped} out of ${max}`;
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
-
<span class:list={["inline-flex items-center gap-1.5", className]} aria-label={label}>
|
|
42
|
+
<span class:list={["inline-flex items-center gap-1.5", className]} role="img" aria-label={label}>
|
|
43
43
|
<span class:list={["relative inline-block leading-none tracking-[0.08em]", dim]} aria-hidden="true">
|
|
44
44
|
<span class="text-[var(--color-border)]">{stars}</span>
|
|
45
45
|
<span
|
|
@@ -56,7 +56,7 @@ const TONE_TEXT: Record<Tone, string> = {
|
|
|
56
56
|
aria-label={label}
|
|
57
57
|
data-compliance={hook}
|
|
58
58
|
>
|
|
59
|
-
<p class={`container-prose py-2 text-center text-
|
|
59
|
+
<p class={`container-prose py-2 text-center text-sm font-semibold uppercase tracking-[0.05em] ${TONE_TEXT[tone]}`}>
|
|
60
60
|
{symbol && <span aria-hidden="true" class="mr-1">{symbol}</span>}
|
|
61
61
|
<slot />
|
|
62
62
|
</p>
|
|
@@ -64,7 +64,7 @@ const TONE_TEXT: Record<Tone, string> = {
|
|
|
64
64
|
) : variant === "footer" ? (
|
|
65
65
|
<div class="border-t border-[var(--color-border)] bg-[var(--color-bg)]" data-compliance={hook}>
|
|
66
66
|
<p
|
|
67
|
-
class="container-prose py-3 text-center text-
|
|
67
|
+
class="container-prose py-3 text-center text-sm text-[var(--color-muted)]"
|
|
68
68
|
role="note"
|
|
69
69
|
aria-label={label}
|
|
70
70
|
>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Static, provider-neutral commerce promo presentations. */
|
|
3
|
+
import Section from "@/components/Section.astro";
|
|
4
|
+
import type { PromoTilesBlock } from "@/lib/storyblok/content-model";
|
|
5
|
+
import { promoTilesView } from "@/lib/homeVisualParity";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
block: PromoTilesBlock;
|
|
9
|
+
basePath: string;
|
|
10
|
+
tenantId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { block, basePath, tenantId } = Astro.props;
|
|
14
|
+
const view = promoTilesView(block, basePath, tenantId);
|
|
15
|
+
const railId = `promo-${tenantId.replace(/[^a-z0-9]+/gi, "-")}-${(block.title ?? "tiles").replace(/[^a-z0-9]+/gi, "-")}`;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<Section title={block.title} eyebrow={view.eyebrow} tone="bg" class={view.isLogoStrip ? "py-8" : undefined}>
|
|
19
|
+
<div
|
|
20
|
+
id={view.isPortrait ? railId : undefined}
|
|
21
|
+
class={view.gridClass}
|
|
22
|
+
data-presentation={view.presentation}
|
|
23
|
+
aria-label={view.isPortrait ? (block.title ?? "Featured collections") : undefined}
|
|
24
|
+
>
|
|
25
|
+
{view.tiles.map((tile) => (
|
|
26
|
+
<a
|
|
27
|
+
href={tile.href}
|
|
28
|
+
class:list={[
|
|
29
|
+
"group block",
|
|
30
|
+
view.isOverlay && "relative aspect-[16/9] overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]",
|
|
31
|
+
view.isLogoStrip && "flex min-h-16 items-center justify-center px-3 py-2",
|
|
32
|
+
view.isPortrait && "promo-portrait-card snap-start",
|
|
33
|
+
]}
|
|
34
|
+
>
|
|
35
|
+
{tile.image && (
|
|
36
|
+
<img
|
|
37
|
+
src={tile.image}
|
|
38
|
+
alt={tile.imageAlt ?? tile.label}
|
|
39
|
+
width={tile.imageWidth}
|
|
40
|
+
height={tile.imageHeight}
|
|
41
|
+
loading="lazy"
|
|
42
|
+
class:list={tile.imageClass}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
{view.isOverlay ? (
|
|
46
|
+
<span class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-4 font-display text-lg font-semibold text-white">
|
|
47
|
+
{tile.label}
|
|
48
|
+
</span>
|
|
49
|
+
) : !view.isLogoStrip ? (
|
|
50
|
+
<span class="mt-3 block">
|
|
51
|
+
<span class="block font-display text-base font-semibold text-[var(--color-text)]">{tile.label}</span>
|
|
52
|
+
{tile.meta && <span class="mt-1 block text-sm text-[var(--color-muted)]">{tile.meta}</span>}
|
|
53
|
+
</span>
|
|
54
|
+
) : (
|
|
55
|
+
<span class="sr-only">{tile.label}</span>
|
|
56
|
+
)}
|
|
57
|
+
</a>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
{view.isPortrait && view.tiles.length > 1 && (
|
|
61
|
+
<div class="promo-portrait-dots" aria-hidden="true">
|
|
62
|
+
{view.tiles.map(() => <span />)}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
{view.cta && (
|
|
66
|
+
<div class="mt-8 text-center">
|
|
67
|
+
<a
|
|
68
|
+
href={view.cta.href}
|
|
69
|
+
class="link-accent link-accent--cta font-display text-sm font-medium"
|
|
70
|
+
>
|
|
71
|
+
{view.cta.label} →
|
|
72
|
+
</a>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</Section>
|
|
76
|
+
|
|
77
|
+
<style>
|
|
78
|
+
/* Fixed track widths, not `minmax(0, …)`: a shrinkable minimum lets the grid
|
|
79
|
+
squeeze every card into the container instead of overflowing, which is what
|
|
80
|
+
makes this a scroll-snap rail at all. */
|
|
81
|
+
.promo-portrait-rail {
|
|
82
|
+
display: grid;
|
|
83
|
+
grid-auto-flow: column;
|
|
84
|
+
grid-auto-columns: 78%;
|
|
85
|
+
gap: 1rem;
|
|
86
|
+
overflow-x: auto;
|
|
87
|
+
overscroll-behavior-inline: contain;
|
|
88
|
+
padding: .125rem max(1rem, calc((100vw - var(--container, 80rem)) / 2));
|
|
89
|
+
margin-inline: min(-1rem, calc((var(--container, 80rem) - 100vw) / 2));
|
|
90
|
+
scroll-padding-inline: 1rem;
|
|
91
|
+
scroll-snap-type: inline mandatory;
|
|
92
|
+
scrollbar-width: none;
|
|
93
|
+
}
|
|
94
|
+
.promo-portrait-rail::-webkit-scrollbar { display: none; }
|
|
95
|
+
.promo-portrait-card { min-width: 0; }
|
|
96
|
+
/* Position indicators, not controls: one dot per card is ~9px wide at any
|
|
97
|
+
realistic card count, far under the 44px tap minimum, and every card it
|
|
98
|
+
would scroll to is already a focusable link in the rail above. */
|
|
99
|
+
.promo-portrait-dots { display: flex; justify-content: center; gap: .45rem; margin-top: 1.25rem; }
|
|
100
|
+
.promo-portrait-dots span { inline-size: .55rem; block-size: .55rem; border-radius: 999px; background: color-mix(in srgb, var(--color-text) 24%, transparent); }
|
|
101
|
+
@media (min-width: 40rem) {
|
|
102
|
+
.promo-portrait-rail { grid-auto-columns: 42%; }
|
|
103
|
+
}
|
|
104
|
+
@media (min-width: 64rem) {
|
|
105
|
+
.promo-portrait-rail { grid-auto-columns: calc((100% - 4rem) / 5); scroll-padding-inline: 0; padding-inline: 0; margin-inline: 0; }
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
@@ -9,36 +9,52 @@
|
|
|
9
9
|
* data-widget="trust-strip".
|
|
10
10
|
*/
|
|
11
11
|
import type { TrustStripItem } from "@/lib/content/trustStrip";
|
|
12
|
-
import { sanitizeTrustStrip, ratingAriaLabel } from "@/lib/content/trustStrip";
|
|
12
|
+
import { orderTrustStripForMobile, sanitizeTrustStrip, ratingAriaLabel } from "@/lib/content/trustStrip";
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
items: TrustStripItem[];
|
|
16
|
+
tenantId?: string;
|
|
17
|
+
mobile?: { hidden?: boolean; order?: string[] };
|
|
16
18
|
}
|
|
17
|
-
const items = sanitizeTrustStrip(Astro.props.items);
|
|
19
|
+
const items = sanitizeTrustStrip(Astro.props.items, Astro.props.tenantId);
|
|
20
|
+
const mobileItems = orderTrustStripForMobile(items, Astro.props.mobile?.order);
|
|
18
21
|
---
|
|
19
22
|
{
|
|
20
23
|
items.length > 0 && (
|
|
21
24
|
<div
|
|
22
|
-
class="border-y border-[var(--color-border)] bg-[var(--color-surface)]"
|
|
25
|
+
class:list={["border-y border-[var(--color-border)] bg-[var(--color-surface)]", Astro.props.mobile?.hidden && "max-md:hidden"]}
|
|
23
26
|
data-widget="trust-strip"
|
|
24
27
|
>
|
|
25
|
-
<ul class="container-prose
|
|
28
|
+
<ul class="container-prose hidden items-stretch text-center text-sm text-[var(--color-text)] md:grid md:grid-cols-4">
|
|
26
29
|
{items.map((item) =>
|
|
27
30
|
item.kind === "rating" ? (
|
|
28
|
-
<li
|
|
29
|
-
class="flex items-center gap-1.5 text-[var(--color-text)]"
|
|
30
|
-
data-trust="rating"
|
|
31
|
-
>
|
|
31
|
+
<li class="flex min-h-16 items-center justify-center gap-1.5 border-[var(--color-border)] px-4 py-3 md:[&:not(:last-child)]:border-r" data-trust="rating">
|
|
32
32
|
<span aria-hidden="true" class="text-[var(--color-warning)]">★</span>
|
|
33
|
-
<span aria-hidden="true">
|
|
34
|
-
{Math.round(item.average * 10) / 10}
|
|
35
|
-
{item.label ? ` ${item.label}` : ` (${item.count})`}
|
|
36
|
-
</span>
|
|
33
|
+
<span aria-hidden="true">{Math.round(item.average * 10) / 10}{item.label ? ` ${item.label}` : ` (${item.count})`}</span>
|
|
37
34
|
<span class="sr-only">{ratingAriaLabel(item)}</span>
|
|
38
35
|
</li>
|
|
39
36
|
) : (
|
|
40
|
-
<li data-trust="claim">
|
|
41
|
-
|
|
37
|
+
<li class="flex min-h-16 items-center justify-center gap-3 border-[var(--color-border)] px-4 py-3 md:[&:not(:last-child)]:border-r" data-trust="claim">
|
|
38
|
+
{item.icon && <img src={item.icon} alt={item.iconAlt ?? ""} width="28" height="28" loading="lazy" class="h-7 w-7 shrink-0 object-contain" />}
|
|
39
|
+
<span>{item.label}</span>
|
|
40
|
+
</li>
|
|
41
|
+
)
|
|
42
|
+
)}
|
|
43
|
+
</ul>
|
|
44
|
+
<ul class="container-prose grid grid-cols-2 items-stretch text-center text-sm text-[var(--color-text)] md:hidden">
|
|
45
|
+
{mobileItems.map((item) =>
|
|
46
|
+
item.kind === "rating" ? (
|
|
47
|
+
<li class="flex min-h-16 items-center justify-center gap-1.5 border-[var(--color-border)] px-4 py-3 [&:nth-child(odd)]:border-r" data-trust="rating">
|
|
48
|
+
<span aria-hidden="true" class="text-[var(--color-warning)]">★</span>
|
|
49
|
+
<span aria-hidden="true">{Math.round(item.average * 10) / 10}{item.label ? ` ${item.label}` : ` (${item.count})`}</span>
|
|
50
|
+
<span class="sr-only">{ratingAriaLabel(item)}</span>
|
|
51
|
+
</li>
|
|
52
|
+
) : (
|
|
53
|
+
<li class="flex min-h-16 items-center justify-center gap-3 border-[var(--color-border)] px-4 py-3 [&:nth-child(odd)]:border-r" data-trust="claim">
|
|
54
|
+
{item.icon && <img src={item.icon} alt={item.iconAlt ?? ""} width="28" height="28" loading="lazy" class="h-7 w-7 shrink-0 object-contain" />}
|
|
55
|
+
<span>{item.label}</span>
|
|
56
|
+
</li>
|
|
57
|
+
)
|
|
42
58
|
)}
|
|
43
59
|
</ul>
|
|
44
60
|
</div>
|
|
@@ -1,31 +1,117 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* Homepage hero — the SIGNATURE moment (brief §6: "hero is a thesis", spend
|
|
4
|
-
* boldness in ONE place).
|
|
4
|
+
* boldness in ONE place). It has a governed slideshow plus three heroVariant
|
|
5
|
+
* treatments:
|
|
6
|
+
* - non-empty `block.slides`: a static, accessible slideshow whose first
|
|
7
|
+
* rehosted slide is the LCP image. Slides take precedence over heroVariant.
|
|
5
8
|
* - "bold" (default): full-bleed primary background, light contrast text.
|
|
6
9
|
* - "light": hero on the page background, brand color as an accent on the
|
|
7
10
|
* headline + CTA — matches merchants whose own site is a light page with
|
|
8
11
|
* color accents. The brand adopter sets "light" when it captures a hero image.
|
|
9
|
-
*
|
|
12
|
+
* - "banner": a merchant-supplied wide campaign asset edge-to-edge, for
|
|
13
|
+
* visual-parity migrations where the artwork owns the visible copy.
|
|
14
|
+
* The feature image is the merchant's own hero image (block.image) when adopted,
|
|
10
15
|
* else the hero collection's first product.
|
|
11
16
|
*/
|
|
12
17
|
import type { HeroBlock } from "@/lib/storyblok/content-model";
|
|
13
18
|
import type { CatalogProduct } from "@tot/public-runtime";
|
|
14
19
|
import Img from "@/components/Img.astro";
|
|
15
20
|
import { withBase } from "@/lib/basePath";
|
|
21
|
+
import { heroSlideshowView } from "@/lib/heroSlideshow";
|
|
16
22
|
|
|
17
23
|
interface Props {
|
|
18
24
|
block: HeroBlock;
|
|
19
25
|
featureProduct?: CatalogProduct | null;
|
|
26
|
+
instanceKey?: string;
|
|
20
27
|
}
|
|
21
|
-
const { block, featureProduct } = Astro.props;
|
|
28
|
+
const { block, featureProduct, instanceKey = "home" } = Astro.props;
|
|
22
29
|
const { basePath } = Astro.locals;
|
|
23
|
-
const
|
|
30
|
+
const heroVariant = Astro.locals.tenant?.theme_tokens?.brand?.heroVariant ?? "bold";
|
|
31
|
+
const light = heroVariant === "light";
|
|
32
|
+
const banner = heroVariant === "banner" && Boolean(block.image);
|
|
24
33
|
const prodImg = featureProduct?.images[0];
|
|
25
34
|
const heroImg = block.image;
|
|
26
35
|
const hasImage = Boolean(heroImg || (prodImg && featureProduct));
|
|
36
|
+
const slideshowView = heroSlideshowView(block, instanceKey, basePath);
|
|
37
|
+
const slides = slideshowView.slides;
|
|
38
|
+
const slideshow = slides.length > 0;
|
|
39
|
+
const slideId = slideshowView.id;
|
|
27
40
|
---
|
|
28
41
|
|
|
42
|
+
{slideshow ? (
|
|
43
|
+
<section
|
|
44
|
+
class="hero-slideshow bg-[var(--color-bg)]"
|
|
45
|
+
aria-roledescription="carousel"
|
|
46
|
+
aria-label={block.headline}
|
|
47
|
+
data-hero-slideshow
|
|
48
|
+
data-autoplay={slideshowView.autoplay ? "true" : "false"}
|
|
49
|
+
data-interval={slideshowView.intervalMs}
|
|
50
|
+
>
|
|
51
|
+
<h1 class="sr-only">{block.headline}</h1>
|
|
52
|
+
<div class="hero-slideshow__slides">
|
|
53
|
+
{slides.map((slide) => (
|
|
54
|
+
<Fragment>
|
|
55
|
+
<input
|
|
56
|
+
class="hero-slideshow__radio"
|
|
57
|
+
type="radio"
|
|
58
|
+
name={slideId}
|
|
59
|
+
id={slide.radioId}
|
|
60
|
+
checked={slide.checked}
|
|
61
|
+
aria-label={slide.radioLabel}
|
|
62
|
+
/>
|
|
63
|
+
<div class="hero-slideshow__slide" role={slide.groupRole} aria-roledescription="slide" aria-label={slide.groupLabel} data-focus-target={slide.focusTarget}>
|
|
64
|
+
{slide.ctaHref ? (
|
|
65
|
+
<a href={slide.href} aria-label={slide.ctaLabel ?? slide.headline ?? slide.imageAlt} class="block w-full">
|
|
66
|
+
<img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
|
|
67
|
+
</a>
|
|
68
|
+
) : (
|
|
69
|
+
<img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</Fragment>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
{slides.length > 1 && (
|
|
76
|
+
<div class="hero-slideshow__controls" aria-label="Hero slides">
|
|
77
|
+
{slides.map((slide, index) => <label for={slide.radioId}>{index + 1}<span class="sr-only">: {slide.headline ?? slide.imageAlt}</span></label>)}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</section>
|
|
81
|
+
) : banner ? (
|
|
82
|
+
<section class="bg-[var(--color-bg)]">
|
|
83
|
+
<h1 class="sr-only">{block.headline}</h1>
|
|
84
|
+
{block.ctaHref ? (
|
|
85
|
+
<a
|
|
86
|
+
href={withBase(basePath, block.ctaHref)}
|
|
87
|
+
aria-label={block.ctaLabel ?? block.headline}
|
|
88
|
+
class="block w-full"
|
|
89
|
+
>
|
|
90
|
+
<img
|
|
91
|
+
src={block.image}
|
|
92
|
+
alt={block.imageAlt ?? ""}
|
|
93
|
+
width={1920}
|
|
94
|
+
height={650}
|
|
95
|
+
loading="eager"
|
|
96
|
+
fetchpriority="high"
|
|
97
|
+
decoding="sync"
|
|
98
|
+
class="block h-auto w-full"
|
|
99
|
+
/>
|
|
100
|
+
</a>
|
|
101
|
+
) : (
|
|
102
|
+
<img
|
|
103
|
+
src={block.image}
|
|
104
|
+
alt={block.imageAlt ?? block.headline}
|
|
105
|
+
width={1920}
|
|
106
|
+
height={650}
|
|
107
|
+
loading="eager"
|
|
108
|
+
fetchpriority="high"
|
|
109
|
+
decoding="sync"
|
|
110
|
+
class="block h-auto w-full"
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
</section>
|
|
114
|
+
) : (
|
|
29
115
|
<section
|
|
30
116
|
class:list={[
|
|
31
117
|
"relative overflow-hidden",
|
|
@@ -176,3 +262,69 @@ const hasImage = Boolean(heroImg || (prodImg && featureProduct));
|
|
|
176
262
|
>
|
|
177
263
|
</div>
|
|
178
264
|
</section>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
<style>
|
|
268
|
+
.hero-slideshow { position: relative; overflow: hidden; }
|
|
269
|
+
.hero-slideshow__slides { display: grid; aspect-ratio: 1920 / 650; background: var(--color-bg); }
|
|
270
|
+
.hero-slideshow__radio { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; }
|
|
271
|
+
.hero-slideshow__slide { grid-area: 1 / 1; display: none; min-width: 0; }
|
|
272
|
+
.hero-slideshow__radio:checked + .hero-slideshow__slide { display: grid; }
|
|
273
|
+
.hero-slideshow__slide > a { min-width: 0; }
|
|
274
|
+
.hero-slideshow__image { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
275
|
+
.hero-slideshow__controls { position: absolute; inset-inline: 0; bottom: 1rem; z-index: 1; display: flex; justify-content: center; gap: .5rem; }
|
|
276
|
+
.hero-slideshow__controls label { display: grid; place-items: center; inline-size: 2.75rem; block-size: 2.75rem; border-radius: 999px; background: color-mix(in srgb, var(--color-bg) 86%, transparent); color: var(--color-text); cursor: pointer; font: 600 .75rem/1 var(--font-mono); }
|
|
277
|
+
.hero-slideshow__radio:focus-visible + .hero-slideshow__slide { outline: 2px solid var(--color-accent); outline-offset: -2px; }
|
|
278
|
+
@media (max-width: 40rem) {
|
|
279
|
+
.hero-slideshow__controls { bottom: .35rem; gap: .3rem; }
|
|
280
|
+
.hero-slideshow__controls label { inline-size: 2.75rem; block-size: 2.75rem; font-size: .65rem; }
|
|
281
|
+
}
|
|
282
|
+
@media (prefers-reduced-motion: reduce) { .hero-slideshow__image { scroll-behavior: auto; } }
|
|
283
|
+
</style>
|
|
284
|
+
|
|
285
|
+
<script>
|
|
286
|
+
import { heroAutoplayAllowed, nextHeroSlideIndex } from "@/lib/heroSlideshow";
|
|
287
|
+
|
|
288
|
+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
289
|
+
|
|
290
|
+
for (const root of document.querySelectorAll<HTMLElement>("[data-hero-slideshow]")) {
|
|
291
|
+
const radios = Array.from(root.querySelectorAll<HTMLInputElement>(".hero-slideshow__radio"));
|
|
292
|
+
const interval = Number(root.dataset.interval) || 6000;
|
|
293
|
+
let timer: number | undefined;
|
|
294
|
+
let paused = false;
|
|
295
|
+
|
|
296
|
+
const stop = () => {
|
|
297
|
+
if (timer !== undefined) window.clearInterval(timer);
|
|
298
|
+
timer = undefined;
|
|
299
|
+
};
|
|
300
|
+
const start = () => {
|
|
301
|
+
stop();
|
|
302
|
+
if (!heroAutoplayAllowed({
|
|
303
|
+
enabled: root.dataset.autoplay === "true",
|
|
304
|
+
reducedMotion: reducedMotion.matches,
|
|
305
|
+
paused,
|
|
306
|
+
pageVisible: !document.hidden,
|
|
307
|
+
slideCount: radios.length,
|
|
308
|
+
})) return;
|
|
309
|
+
timer = window.setInterval(() => {
|
|
310
|
+
const current = radios.findIndex((radio) => radio.checked);
|
|
311
|
+
radios[nextHeroSlideIndex(current, radios.length)]?.click();
|
|
312
|
+
}, interval);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
root.addEventListener("pointerenter", () => { paused = true; stop(); });
|
|
316
|
+
root.addEventListener("pointerleave", () => { paused = false; start(); });
|
|
317
|
+
root.addEventListener("focusin", () => { paused = true; stop(); });
|
|
318
|
+
root.addEventListener("focusout", (event) => {
|
|
319
|
+
if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return;
|
|
320
|
+
paused = false;
|
|
321
|
+
start();
|
|
322
|
+
});
|
|
323
|
+
root.addEventListener("change", (event) => {
|
|
324
|
+
if (event.target instanceof HTMLInputElement && event.target.matches(".hero-slideshow__radio")) start();
|
|
325
|
+
});
|
|
326
|
+
document.addEventListener("visibilitychange", () => document.hidden ? stop() : start());
|
|
327
|
+
reducedMotion.addEventListener("change", start);
|
|
328
|
+
start();
|
|
329
|
+
}
|
|
330
|
+
</script>
|