@tokenoftrust/storefront-runner 3.12.0 → 3.12.2
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/src/components/content/storefront/home/Hero.astro +85 -23
- package/apps/storefront/src/config/tenants.ts +16 -6
- package/apps/storefront/src/layouts/Layout.astro +16 -5
- package/apps/storefront/src/lib/compliance/complianceSections.ts +113 -0
- package/apps/storefront/src/lib/content/shared/heroSlideshow.ts +43 -15
- package/apps/storefront/src/lib/content/storyblok/content-model.ts +10 -0
- package/apps/storefront/src/pages/[...slug].astro +9 -3
- package/apps/storefront/src/pages/index.astro +9 -3
- package/package.json +1 -1
- package/scripts/tenant/validate.mjs +15 -0
|
@@ -24,7 +24,7 @@ import type { CatalogProduct } from "@tot/public-runtime";
|
|
|
24
24
|
import Img from "@/components/assets/storefront/Img.astro";
|
|
25
25
|
import { withBase } from "@/lib/publication/shared/basePath";
|
|
26
26
|
import { pageUrlForm } from "@/lib/content/seo/urlForm";
|
|
27
|
-
import { heroSlideshowView } from "@/lib/content/shared/heroSlideshow";
|
|
27
|
+
import { heroFrame, heroSlideshowView, HERO_PHONE_MEDIA } from "@/lib/content/shared/heroSlideshow";
|
|
28
28
|
|
|
29
29
|
interface Props {
|
|
30
30
|
block: HeroBlock;
|
|
@@ -44,6 +44,8 @@ const heroImg = block.image;
|
|
|
44
44
|
const hasImage = Boolean(heroImg || (prodImg && featureProduct));
|
|
45
45
|
const slideshowView = heroSlideshowView(block, instanceKey, basePath);
|
|
46
46
|
const slides = slideshowView.slides;
|
|
47
|
+
const bannerFrame = heroFrame(block.width, block.height);
|
|
48
|
+
const featureFrame = heroFrame(block.width, block.height, 1600, 1000);
|
|
47
49
|
const slideshow = slides.length > 0;
|
|
48
50
|
const slideId = slideshowView.id;
|
|
49
51
|
const notice = block.notice?.trim();
|
|
@@ -76,11 +78,23 @@ const notice = block.notice?.trim();
|
|
|
76
78
|
checked={slide.checked}
|
|
77
79
|
aria-label={slide.radioLabel}
|
|
78
80
|
/>
|
|
79
|
-
<div class="hero-slideshow__slide" role={slide.groupRole} aria-roledescription="slide" aria-label={slide.groupLabel} data-focus-target={slide.focusTarget}>
|
|
81
|
+
<div class="hero-slideshow__slide" role={slide.groupRole} aria-roledescription="slide" aria-label={slide.groupLabel} data-focus-target={slide.focusTarget} style={`--hero-aspect: ${slide.aspectRatio}`}>
|
|
80
82
|
{slide.ctaHref ? (
|
|
81
83
|
<a href={slide.href} aria-label={slide.ctaLabel ?? slide.headline ?? slide.imageAlt} class="block w-full">
|
|
82
|
-
|
|
84
|
+
{slide.imageMobile ? (
|
|
85
|
+
<picture>
|
|
86
|
+
<source media={HERO_PHONE_MEDIA} srcset={slide.imageMobile} />
|
|
87
|
+
<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" />
|
|
88
|
+
</picture>
|
|
89
|
+
) : (
|
|
90
|
+
<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" />
|
|
91
|
+
)}
|
|
83
92
|
</a>
|
|
93
|
+
) : slide.imageMobile ? (
|
|
94
|
+
<picture>
|
|
95
|
+
<source media={HERO_PHONE_MEDIA} srcset={slide.imageMobile} />
|
|
96
|
+
<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" />
|
|
97
|
+
</picture>
|
|
84
98
|
) : (
|
|
85
99
|
<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" />
|
|
86
100
|
)}
|
|
@@ -108,23 +122,53 @@ const notice = block.notice?.trim();
|
|
|
108
122
|
aria-label={block.ctaLabel ?? block.headline}
|
|
109
123
|
class="block w-full"
|
|
110
124
|
>
|
|
125
|
+
{block.imageMobile ? (
|
|
126
|
+
<picture>
|
|
127
|
+
<source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
|
|
128
|
+
<img
|
|
129
|
+
src={block.image}
|
|
130
|
+
alt={block.imageAlt ?? ""}
|
|
131
|
+
width={bannerFrame.width}
|
|
132
|
+
height={bannerFrame.height}
|
|
133
|
+
loading="eager"
|
|
134
|
+
fetchpriority="high"
|
|
135
|
+
decoding="sync"
|
|
136
|
+
class="block h-auto w-full"
|
|
137
|
+
/>
|
|
138
|
+
</picture>
|
|
139
|
+
) : (
|
|
140
|
+
<img
|
|
141
|
+
src={block.image}
|
|
142
|
+
alt={block.imageAlt ?? ""}
|
|
143
|
+
width={bannerFrame.width}
|
|
144
|
+
height={bannerFrame.height}
|
|
145
|
+
loading="eager"
|
|
146
|
+
fetchpriority="high"
|
|
147
|
+
decoding="sync"
|
|
148
|
+
class="block h-auto w-full"
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
</a>
|
|
152
|
+
) : block.imageMobile ? (
|
|
153
|
+
<picture>
|
|
154
|
+
<source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
|
|
111
155
|
<img
|
|
112
156
|
src={block.image}
|
|
113
|
-
alt={block.imageAlt ??
|
|
114
|
-
width={
|
|
115
|
-
height={
|
|
157
|
+
alt={block.imageAlt ?? block.headline}
|
|
158
|
+
width={bannerFrame.width}
|
|
159
|
+
height={bannerFrame.height}
|
|
116
160
|
loading="eager"
|
|
117
161
|
fetchpriority="high"
|
|
118
162
|
decoding="sync"
|
|
119
163
|
class="block h-auto w-full"
|
|
120
164
|
/>
|
|
121
|
-
</
|
|
165
|
+
</picture>
|
|
122
166
|
) : (
|
|
123
167
|
<img
|
|
124
168
|
src={block.image}
|
|
125
169
|
alt={block.imageAlt ?? block.headline}
|
|
126
|
-
width={
|
|
127
|
-
height={
|
|
170
|
+
width={bannerFrame.width}
|
|
171
|
+
height={bannerFrame.height}
|
|
128
172
|
loading="eager"
|
|
129
173
|
fetchpriority="high"
|
|
130
174
|
decoding="sync"
|
|
@@ -226,16 +270,32 @@ const notice = block.notice?.trim();
|
|
|
226
270
|
width/height attributes mean the final render still uses the
|
|
227
271
|
image's natural ratio; the dims below only size the pre-load box.
|
|
228
272
|
*/}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
273
|
+
{block.imageMobile ? (
|
|
274
|
+
<picture>
|
|
275
|
+
<source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
|
|
276
|
+
<img
|
|
277
|
+
src={heroImg}
|
|
278
|
+
alt={block.imageAlt ?? block.headline}
|
|
279
|
+
width={featureFrame.width}
|
|
280
|
+
height={featureFrame.height}
|
|
281
|
+
loading="eager"
|
|
282
|
+
fetchpriority="high"
|
|
283
|
+
decoding="sync"
|
|
284
|
+
class="h-full w-full object-cover"
|
|
285
|
+
/>
|
|
286
|
+
</picture>
|
|
287
|
+
) : (
|
|
288
|
+
<img
|
|
289
|
+
src={heroImg}
|
|
290
|
+
alt={block.imageAlt ?? block.headline}
|
|
291
|
+
width={featureFrame.width}
|
|
292
|
+
height={featureFrame.height}
|
|
293
|
+
loading="eager"
|
|
294
|
+
fetchpriority="high"
|
|
295
|
+
decoding="sync"
|
|
296
|
+
class="h-full w-full object-cover"
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
239
299
|
</div>
|
|
240
300
|
</div>
|
|
241
301
|
) : (
|
|
@@ -289,11 +349,13 @@ const notice = block.notice?.trim();
|
|
|
289
349
|
|
|
290
350
|
<style>
|
|
291
351
|
.hero-slideshow { position: relative; overflow: hidden; }
|
|
292
|
-
.hero-slideshow__slides { display: grid;
|
|
352
|
+
.hero-slideshow__slides { display: grid; background: var(--color-bg); }
|
|
293
353
|
.hero-slideshow__radio { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; }
|
|
294
|
-
.hero-slideshow__slide { grid-area: 1 / 1; display: none; min-width: 0; }
|
|
295
|
-
.hero-slideshow__radio:checked + .hero-slideshow__slide { display:
|
|
296
|
-
.hero-slideshow__slide > a
|
|
354
|
+
.hero-slideshow__slide { grid-area: 1 / 1; display: none; width: 100%; height: auto; align-self: start; justify-self: stretch; min-width: 0; aspect-ratio: var(--hero-aspect, 1920 / 650); }
|
|
355
|
+
.hero-slideshow__radio:checked + .hero-slideshow__slide { display: block; }
|
|
356
|
+
.hero-slideshow__slide > a,
|
|
357
|
+
.hero-slideshow__slide > picture,
|
|
358
|
+
.hero-slideshow__slide > a > picture { display: block; width: 100%; height: 100%; min-width: 0; }
|
|
297
359
|
.hero-slideshow__image { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
298
360
|
.hero-slideshow__controls { position: absolute; inset-inline: 0; bottom: 1rem; z-index: 1; display: flex; justify-content: center; gap: .5rem; }
|
|
299
361
|
.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); }
|
|
@@ -41,10 +41,12 @@ export const HOME_TENANT: TenantConfig = {
|
|
|
41
41
|
siteType: "marketing",
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
// Generic SAMPLE tenant —
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
44
|
+
// Generic SAMPLE tenant — the public fallback renderer's regulated store.
|
|
45
|
+
// Its page bands come from the same complianceSections() gates as a primary
|
|
46
|
+
// vape-nicotine-us tenant (nicotine warning, shipping restriction, age gate,
|
|
47
|
+
// excise disclosure). No real merchant. Routed at /sample-store.example/.
|
|
48
|
+
// Content comes from the developer's grafted checkout
|
|
49
|
+
// (tenants/sample-store.example/); with none present it degrades to the
|
|
48
50
|
// default scope, and the compliance overlay still renders from this config.
|
|
49
51
|
export const SAMPLE_TENANT: TenantConfig = {
|
|
50
52
|
tenant_id: "sample-store.example",
|
|
@@ -62,14 +64,22 @@ export const SAMPLE_TENANT: TenantConfig = {
|
|
|
62
64
|
repo: "sample-store.example",
|
|
63
65
|
defaultBranch: "main",
|
|
64
66
|
})],
|
|
65
|
-
//
|
|
67
|
+
// Same section inputs as a primary vape-nicotine-us commerce tenant.
|
|
68
|
+
// pactAct stays unset: the primary page does not emit that band unless the
|
|
69
|
+
// flag is set. ruleProfile selects the checkout ruleset only.
|
|
66
70
|
compliance: {
|
|
71
|
+
ruleProfile: "vape-nicotine-us",
|
|
67
72
|
minAge: 21,
|
|
68
73
|
nicotineWarning: true,
|
|
69
74
|
shippingRestriction:
|
|
70
75
|
"Adult signature (21+) required on delivery. Some destinations are restricted — verified at checkout.",
|
|
71
76
|
},
|
|
72
|
-
|
|
77
|
+
capabilities: {
|
|
78
|
+
cartCheckout: { enabled: true },
|
|
79
|
+
ageVerification: { enabled: true },
|
|
80
|
+
exciseTax: { enabled: true },
|
|
81
|
+
},
|
|
82
|
+
siteType: "commerce",
|
|
73
83
|
};
|
|
74
84
|
|
|
75
85
|
const REGISTRY: TenantConfig[] = [HOME_TENANT, SAMPLE_TENANT];
|
|
@@ -20,6 +20,7 @@ import PactActNotice from "@/components/compliance/storefront/compliance/PactAct
|
|
|
20
20
|
import ShippingRestrictionNotice from "@/components/compliance/storefront/compliance/ShippingRestrictionNotice.astro";
|
|
21
21
|
import ExciseTaxNotice from "@/components/compliance/storefront/compliance/ExciseTaxNotice.astro";
|
|
22
22
|
import { EXCISE_TAX_DISCLOSURE_TEXT } from "@/lib/compliance/rawComplianceNotices";
|
|
23
|
+
import { complianceSections } from "@/lib/compliance/complianceSections";
|
|
23
24
|
import IsolatedAgeGate from "@/components/compliance/storefront/islands/IsolatedAgeGate.tsx";
|
|
24
25
|
import AuthBadge from "@/components/access/storefront/auth/AuthBadge.astro";
|
|
25
26
|
import { referenceTheme } from "@/themes/reference";
|
|
@@ -215,6 +216,12 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
|
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
const compliance = tenant.compliance;
|
|
219
|
+
const sections = complianceSections({
|
|
220
|
+
compliance,
|
|
221
|
+
ageVerificationEnabled: capabilities.ageVerification.enabled,
|
|
222
|
+
exciseTaxEnabled: capabilities.exciseTax.enabled,
|
|
223
|
+
catalog: features.catalog,
|
|
224
|
+
});
|
|
218
225
|
const cartHref = managedCartHref(Astro.locals.commerce);
|
|
219
226
|
|
|
220
227
|
// Site-chrome data resolution (chrome/* is the canonical, prop-driven
|
|
@@ -368,7 +375,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
368
375
|
|
|
369
376
|
<AuthBadge />
|
|
370
377
|
|
|
371
|
-
{compliance?.nicotineWarning && <NicotineWarning variant="bar" declaration={compliance.nicotineWarning} />}
|
|
378
|
+
{sections.includes("nicotine-warning") && compliance?.nicotineWarning && <NicotineWarning variant="bar" declaration={compliance.nicotineWarning} />}
|
|
372
379
|
|
|
373
380
|
{sharedChrome ? (
|
|
374
381
|
<Fragment set:html={sharedChrome.header} />
|
|
@@ -406,8 +413,12 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
406
413
|
<slot />
|
|
407
414
|
</main>
|
|
408
415
|
|
|
409
|
-
|
|
410
|
-
|
|
416
|
+
{sections.includes("pact-act") && (
|
|
417
|
+
<PactActNotice enabled={compliance?.pactAct} minAge={compliance?.minAge} variant="footer" />
|
|
418
|
+
)}
|
|
419
|
+
{sections.includes("shipping-restriction") && (
|
|
420
|
+
<ShippingRestrictionNotice text={compliance?.shippingRestriction} />
|
|
421
|
+
)}
|
|
411
422
|
|
|
412
423
|
{sharedChrome ? (
|
|
413
424
|
<>
|
|
@@ -460,7 +471,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
460
471
|
</div>
|
|
461
472
|
))}
|
|
462
473
|
|
|
463
|
-
{capabilities.ageVerification.enabled && compliance?.minAge && (
|
|
474
|
+
{sections.includes("age-gate") && capabilities.ageVerification.enabled && compliance?.minAge && (
|
|
464
475
|
<IsolatedAgeGate
|
|
465
476
|
client:only="preact"
|
|
466
477
|
minAge={compliance.minAge}
|
|
@@ -477,7 +488,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
477
488
|
{/* Excise tax (Track B) — when enabled for this environment, the excise line
|
|
478
489
|
is calculated + added inside the managed cart. This is the storefront's
|
|
479
490
|
honest shopper-facing signal of it. */}
|
|
480
|
-
{capabilities.exciseTax.enabled && features.catalog && (
|
|
491
|
+
{sections.includes("excise-tax") && capabilities.exciseTax.enabled && features.catalog && (
|
|
481
492
|
<ExciseTaxNotice variant="footer" config={{ note: EXCISE_TAX_DISCLOSURE_TEXT }} />
|
|
482
493
|
)}
|
|
483
494
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page compliance bands, in document order.
|
|
3
|
+
*
|
|
4
|
+
* Layout.astro (primary) and the public fallback renderer (the sanitized
|
|
5
|
+
* sample tenant in the published runner) both decide which bands to emit from
|
|
6
|
+
* this list. `ruleProfile` selects the checkout ruleset; it does not add a
|
|
7
|
+
* band. A vape-nicotine-us store shows pact-act only when `compliance.pactAct`
|
|
8
|
+
* is set, the same gate the primary tenants use today.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
resolveCapabilities,
|
|
12
|
+
resolveTenantFeatures,
|
|
13
|
+
type ComplianceConfig,
|
|
14
|
+
type TenantConfig,
|
|
15
|
+
} from "@tot/public-runtime";
|
|
16
|
+
import {
|
|
17
|
+
exciseTaxNoticeHtml,
|
|
18
|
+
nicotineWarningBarHtml,
|
|
19
|
+
pactActNoticeHtml,
|
|
20
|
+
shippingRestrictionNoticeHtml,
|
|
21
|
+
type RawComplianceChromeInput,
|
|
22
|
+
} from "./rawComplianceNotices";
|
|
23
|
+
|
|
24
|
+
export const COMPLIANCE_SECTION_IDS = [
|
|
25
|
+
"nicotine-warning",
|
|
26
|
+
"pact-act",
|
|
27
|
+
"shipping-restriction",
|
|
28
|
+
"age-gate",
|
|
29
|
+
"excise-tax",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export type ComplianceSectionId = (typeof COMPLIANCE_SECTION_IDS)[number];
|
|
33
|
+
|
|
34
|
+
export interface ComplianceSectionInput {
|
|
35
|
+
compliance?: ComplianceConfig | null;
|
|
36
|
+
ageVerificationEnabled: boolean;
|
|
37
|
+
exciseTaxEnabled: boolean;
|
|
38
|
+
catalog: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Bands Layout.astro mounts, under the same conditions, in document order. */
|
|
42
|
+
export function complianceSections(input: ComplianceSectionInput): ComplianceSectionId[] {
|
|
43
|
+
const compliance = input.compliance;
|
|
44
|
+
const sections: ComplianceSectionId[] = [];
|
|
45
|
+
if (compliance?.nicotineWarning) sections.push("nicotine-warning");
|
|
46
|
+
if (compliance?.pactAct) sections.push("pact-act");
|
|
47
|
+
if (compliance?.shippingRestriction) sections.push("shipping-restriction");
|
|
48
|
+
if (input.ageVerificationEnabled && compliance?.minAge) sections.push("age-gate");
|
|
49
|
+
if (input.exciseTaxEnabled && input.catalog) sections.push("excise-tax");
|
|
50
|
+
return sections;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A primary vape-nicotine-us tenant as the registry declares one: the profile,
|
|
55
|
+
* 21+, the nicotine warning, a shipping line, commerce (catalog on), and the
|
|
56
|
+
* three capabilities. `pactAct` stays unset, so the list has no pact-act band.
|
|
57
|
+
*/
|
|
58
|
+
export const VAPE_NICOTINE_US_PRIMARY: ComplianceSectionInput = {
|
|
59
|
+
compliance: {
|
|
60
|
+
ruleProfile: "vape-nicotine-us",
|
|
61
|
+
minAge: 21,
|
|
62
|
+
nicotineWarning: true,
|
|
63
|
+
shippingRestriction:
|
|
64
|
+
"Adult signature (21+) required on delivery. Some destinations are restricted — verified at checkout.",
|
|
65
|
+
},
|
|
66
|
+
ageVerificationEnabled: true,
|
|
67
|
+
exciseTaxEnabled: true,
|
|
68
|
+
catalog: true,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function vapeNicotineUsSections(): ComplianceSectionId[] {
|
|
72
|
+
return complianceSections(VAPE_NICOTINE_US_PRIMARY);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Raw-HTML injection for the bands in `sections`. Age-gate is not injectable. */
|
|
76
|
+
export function rawComplianceChrome(
|
|
77
|
+
sections: readonly ComplianceSectionId[],
|
|
78
|
+
notices: RawComplianceChromeInput,
|
|
79
|
+
): { top: string; bottom: string } {
|
|
80
|
+
const on = (id: ComplianceSectionId) => sections.includes(id);
|
|
81
|
+
const top = on("nicotine-warning") && notices.nicotineWarning
|
|
82
|
+
? nicotineWarningBarHtml(notices.nicotineWarning)
|
|
83
|
+
: "";
|
|
84
|
+
let bottom = "";
|
|
85
|
+
if (on("pact-act") && notices.pactAct) bottom += pactActNoticeHtml(notices.minAge);
|
|
86
|
+
if (on("shipping-restriction")) bottom += shippingRestrictionNoticeHtml(notices.shippingRestriction);
|
|
87
|
+
if (on("excise-tax") && notices.exciseTaxEnabled) bottom += exciseTaxNoticeHtml();
|
|
88
|
+
return { top, bottom };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The same bands Layout would mount for this tenant, as raw-HTML chrome. */
|
|
92
|
+
export function complianceChromeForTenant(
|
|
93
|
+
tenant: Pick<TenantConfig, "compliance" | "siteType" | "capabilities" | "features">,
|
|
94
|
+
): { top: string; bottom: string } {
|
|
95
|
+
const capabilities = resolveCapabilities(tenant);
|
|
96
|
+
const features = resolveTenantFeatures(tenant);
|
|
97
|
+
const notices: RawComplianceChromeInput = {
|
|
98
|
+
nicotineWarning: tenant.compliance?.nicotineWarning,
|
|
99
|
+
pactAct: !!tenant.compliance?.pactAct,
|
|
100
|
+
minAge: tenant.compliance?.minAge,
|
|
101
|
+
shippingRestriction: tenant.compliance?.shippingRestriction,
|
|
102
|
+
exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
|
|
103
|
+
};
|
|
104
|
+
return rawComplianceChrome(
|
|
105
|
+
complianceSections({
|
|
106
|
+
compliance: tenant.compliance,
|
|
107
|
+
ageVerificationEnabled: capabilities.ageVerification.enabled,
|
|
108
|
+
exciseTaxEnabled: capabilities.exciseTax.enabled,
|
|
109
|
+
catalog: features.catalog,
|
|
110
|
+
}),
|
|
111
|
+
notices,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -16,26 +16,54 @@ export function nextHeroSlideIndex(current: number, slideCount: number) {
|
|
|
16
16
|
return current < 0 ? 0 : (current + 1) % slideCount;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Reserved slideshow box when a slide does not declare its own frame. */
|
|
20
|
+
export const HERO_FRAME_WIDTH = 1920;
|
|
21
|
+
export const HERO_FRAME_HEIGHT = 650;
|
|
22
|
+
|
|
23
|
+
/** Matches the slideshow's own phone layout. One `<source>` so that viewport fetches one file. */
|
|
24
|
+
export const HERO_PHONE_MEDIA = "(max-width: 40rem)";
|
|
25
|
+
|
|
26
|
+
export function heroFrame(
|
|
27
|
+
width: number | undefined,
|
|
28
|
+
height: number | undefined,
|
|
29
|
+
fallbackWidth = HERO_FRAME_WIDTH,
|
|
30
|
+
fallbackHeight = HERO_FRAME_HEIGHT,
|
|
31
|
+
) {
|
|
32
|
+
const declared = typeof width === "number"
|
|
33
|
+
&& typeof height === "number"
|
|
34
|
+
&& Number.isFinite(width)
|
|
35
|
+
&& Number.isFinite(height)
|
|
36
|
+
&& width > 0
|
|
37
|
+
&& height > 0;
|
|
38
|
+
const frameWidth = declared ? width : fallbackWidth;
|
|
39
|
+
const frameHeight = declared ? height : fallbackHeight;
|
|
40
|
+
return { width: frameWidth, height: frameHeight, aspectRatio: `${frameWidth} / ${frameHeight}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
export function heroSlideshowView(block: HeroBlock, instanceKey: string, basePath: string) {
|
|
20
44
|
const slides = block.slides ?? [];
|
|
21
45
|
return {
|
|
22
46
|
id: `hero-slides-${instanceKey}`,
|
|
23
47
|
autoplay: slides.length > 1,
|
|
24
48
|
intervalMs: 6_000,
|
|
25
|
-
slides: slides.map((slide, index) =>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
slides: slides.map((slide, index) => {
|
|
50
|
+
const frame = heroFrame(slide.width, slide.height);
|
|
51
|
+
return {
|
|
52
|
+
...slide,
|
|
53
|
+
href: slide.ctaHref ? withBase(basePath, slide.ctaHref) : undefined,
|
|
54
|
+
checked: index === 0,
|
|
55
|
+
loading: index === 0 ? ("eager" as const) : ("lazy" as const),
|
|
56
|
+
fetchpriority: index === 0 ? ("high" as const) : ("auto" as const),
|
|
57
|
+
decoding: index === 0 ? ("sync" as const) : ("async" as const),
|
|
58
|
+
width: frame.width,
|
|
59
|
+
height: frame.height,
|
|
60
|
+
aspectRatio: frame.aspectRatio,
|
|
61
|
+
radioId: `hero-slides-${instanceKey}-${index}`,
|
|
62
|
+
radioLabel: `Show slide ${index + 1}: ${slide.headline ?? slide.imageAlt}`,
|
|
63
|
+
groupRole: "group" as const,
|
|
64
|
+
groupLabel: `${index + 1} of ${slides.length}`,
|
|
65
|
+
focusTarget: "slide" as const,
|
|
66
|
+
};
|
|
67
|
+
}),
|
|
40
68
|
};
|
|
41
69
|
}
|
|
@@ -73,11 +73,21 @@ export interface HeroBlock {
|
|
|
73
73
|
featureCollection?: string;
|
|
74
74
|
/** Direct hero image URL (e.g. the merchant's own homepage hero slide). */
|
|
75
75
|
image?: string;
|
|
76
|
+
/** Phone asset for the single image. The desktop `image` stays the fallback. */
|
|
77
|
+
imageMobile?: string;
|
|
76
78
|
imageAlt?: string;
|
|
79
|
+
/** Intrinsic frame for the single image. Both are required together. */
|
|
80
|
+
width?: number;
|
|
81
|
+
height?: number;
|
|
77
82
|
/** Optional static, governed campaign slides. The first slide is the LCP image. */
|
|
78
83
|
slides?: Array<{
|
|
79
84
|
image: string;
|
|
80
85
|
imageAlt: string;
|
|
86
|
+
/** Phone asset. The desktop `image` stays the fallback. */
|
|
87
|
+
imageMobile?: string;
|
|
88
|
+
/** Intrinsic frame. Both are required together; omitted keeps the reserved box. */
|
|
89
|
+
width?: number;
|
|
90
|
+
height?: number;
|
|
81
91
|
headline?: string;
|
|
82
92
|
ctaLabel?: string;
|
|
83
93
|
ctaHref?: string;
|
|
@@ -14,7 +14,7 @@ import { breadcrumbLd } from "@/lib/content/shared/jsonld";
|
|
|
14
14
|
import { truncate } from "@/lib/content/shared/format";
|
|
15
15
|
import { faqFromHtml, faqFromSections } from "@/lib/content/seo/faqItems";
|
|
16
16
|
import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/content/shared/rawMarketingHtml";
|
|
17
|
-
import {
|
|
17
|
+
import { complianceSections, rawComplianceChrome } from "@/lib/compliance/complianceSections";
|
|
18
18
|
import { styleGuideAliasRewrite } from "@/lib/content/shared/styleGuidePaths";
|
|
19
19
|
import { getBlogSettings } from "@/lib/content/blog/provider.js";
|
|
20
20
|
import { loadBlogPostViewV1, type BlogPostViewPropsV1 } from "@/lib/content/blog/postView.js";
|
|
@@ -85,6 +85,12 @@ if (!blogPostProps) {
|
|
|
85
85
|
shippingRestriction: tenant.compliance?.shippingRestriction,
|
|
86
86
|
exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
|
|
87
87
|
};
|
|
88
|
+
const complianceChrome = rawComplianceChrome(complianceSections({
|
|
89
|
+
compliance: tenant.compliance,
|
|
90
|
+
ageVerificationEnabled: capabilities.ageVerification.enabled,
|
|
91
|
+
exciseTaxEnabled: capabilities.exciseTax.enabled,
|
|
92
|
+
catalog: features.catalog,
|
|
93
|
+
}), notices);
|
|
88
94
|
return rawHtmlResponse(rawHtml, {
|
|
89
95
|
nonce: Astro.locals.cspNonce,
|
|
90
96
|
basePath,
|
|
@@ -93,8 +99,8 @@ if (!blogPostProps) {
|
|
|
93
99
|
plan: Astro.locals.integrationPlan,
|
|
94
100
|
consentSnapshot: Astro.locals.consentSnapshot,
|
|
95
101
|
canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
|
|
96
|
-
complianceTop:
|
|
97
|
-
complianceBottom:
|
|
102
|
+
complianceTop: complianceChrome.top || undefined,
|
|
103
|
+
complianceBottom: complianceChrome.bottom || undefined,
|
|
98
104
|
});
|
|
99
105
|
}
|
|
100
106
|
|
|
@@ -20,7 +20,7 @@ import MarketingBlocks from "@/components/content/storefront/marketing/Marketing
|
|
|
20
20
|
import TierCards from "@/components/membership/storefront/membership/TierCards.astro";
|
|
21
21
|
import { withBase } from "@/lib/publication/shared/basePath";
|
|
22
22
|
import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/content/shared/rawMarketingHtml";
|
|
23
|
-
import {
|
|
23
|
+
import { complianceSections, rawComplianceChrome } from "@/lib/compliance/complianceSections";
|
|
24
24
|
import AppWidgetFrame from "@/components/integrations/storefront/apps/AppWidgetFrame.astro";
|
|
25
25
|
import { getRegistryService } from "@/lib/integrations/apps/registryAccess.js";
|
|
26
26
|
import { renderWidgetSlot } from "@/lib/integrations/apps/widgets/renderSlot.js";
|
|
@@ -73,6 +73,12 @@ if (rawHtml && rawPolicy.allowed) {
|
|
|
73
73
|
shippingRestriction: tenant.compliance?.shippingRestriction,
|
|
74
74
|
exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
|
|
75
75
|
};
|
|
76
|
+
const complianceChrome = rawComplianceChrome(complianceSections({
|
|
77
|
+
compliance: tenant.compliance,
|
|
78
|
+
ageVerificationEnabled: capabilities.ageVerification.enabled,
|
|
79
|
+
exciseTaxEnabled: capabilities.exciseTax.enabled,
|
|
80
|
+
catalog: features.catalog,
|
|
81
|
+
}), notices);
|
|
76
82
|
return rawHtmlResponse(rawHtml, {
|
|
77
83
|
nonce: Astro.locals.cspNonce,
|
|
78
84
|
basePath,
|
|
@@ -81,8 +87,8 @@ if (rawHtml && rawPolicy.allowed) {
|
|
|
81
87
|
plan: Astro.locals.integrationPlan,
|
|
82
88
|
consentSnapshot: Astro.locals.consentSnapshot,
|
|
83
89
|
canonicalUrl: new URL("/", canonicalBase + "/").toString(),
|
|
84
|
-
complianceTop:
|
|
85
|
-
complianceBottom:
|
|
90
|
+
complianceTop: complianceChrome.top || undefined,
|
|
91
|
+
complianceBottom: complianceChrome.bottom || undefined,
|
|
86
92
|
});
|
|
87
93
|
}
|
|
88
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "3.12.
|
|
3
|
+
"version": "3.12.2",
|
|
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",
|
|
@@ -492,6 +492,19 @@ function validatePromoTiles(block, file, at, out, tenantId) {
|
|
|
492
492
|
}
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
+
function validateHeroMediaFields(owner, file, at, out, tenantId) {
|
|
496
|
+
if ((owner.width === undefined) !== (owner.height === undefined)) out.push(mk(ERROR, "block-prop", file, `${at}.width and ${at}.height must be supplied together`));
|
|
497
|
+
for (const prop of ["width", "height"]) {
|
|
498
|
+
if (owner[prop] !== undefined && (typeof owner[prop] !== "number" || !Number.isInteger(owner[prop]) || owner[prop] <= 0)) {
|
|
499
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a positive integer`));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (owner.imageMobile !== undefined) {
|
|
503
|
+
if (typeof owner.imageMobile !== "string" || !owner.imageMobile.trim()) out.push(mk(ERROR, "block-prop", file, `${at}.imageMobile must be a non-empty string`));
|
|
504
|
+
else validateVisualAsset(owner.imageMobile, file, `${at}.imageMobile`, out, tenantId);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
495
508
|
function validateHeroSlides(slides, file, at, out, tenantId) {
|
|
496
509
|
if (!Array.isArray(slides) || slides.length === 0) { out.push(mk(ERROR, "block-prop", file, `${at}.slides must be a non-empty array`)); return; }
|
|
497
510
|
slides.forEach((slide, index) => {
|
|
@@ -499,6 +512,7 @@ function validateHeroSlides(slides, file, at, out, tenantId) {
|
|
|
499
512
|
if (slide == null || typeof slide !== "object" || Array.isArray(slide)) { out.push(mk(ERROR, "block-prop", file, `${slideAt} must be an object`)); return; }
|
|
500
513
|
for (const prop of ["image", "imageAlt"]) if (typeof slide[prop] !== "string" || !slide[prop].trim()) out.push(mk(ERROR, "block-required", file, `${slideAt}.${prop} must be a non-empty string`));
|
|
501
514
|
validateVisualAsset(slide.image, file, `${slideAt}.image`, out, tenantId);
|
|
515
|
+
validateHeroMediaFields(slide, file, slideAt, out, tenantId);
|
|
502
516
|
for (const prop of ["headline", "ctaLabel", "ctaHref"]) if (slide[prop] !== undefined && (typeof slide[prop] !== "string" || !slide[prop].trim())) out.push(mk(ERROR, "block-prop", file, `${slideAt}.${prop} must be a non-empty string`));
|
|
503
517
|
if ((slide.ctaLabel === undefined) !== (slide.ctaHref === undefined)) out.push(mk(ERROR, "block-required", file, `${slideAt}.ctaLabel and ${slideAt}.ctaHref must be supplied together`));
|
|
504
518
|
if (typeof slide.ctaHref === "string" && !isSafeStorefrontHref(slide.ctaHref)) out.push(mk(ERROR, "navigation-href", file, `${slideAt}.ctaHref must be a same-storefront root-relative path`));
|
|
@@ -601,6 +615,7 @@ function validateHomeDoc(doc, file, tenantId) {
|
|
|
601
615
|
if (component === "hero" && block.image !== undefined) {
|
|
602
616
|
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
603
617
|
}
|
|
618
|
+
if (component === "hero") validateHeroMediaFields(block, file, at, out, tenantId);
|
|
604
619
|
if (component === "hero" && block.slides !== undefined) validateHeroSlides(block.slides, file, at, out, tenantId);
|
|
605
620
|
if (component === "hero" && block.notice !== undefined && (typeof block.notice !== "string" || !block.notice.trim())) {
|
|
606
621
|
out.push(mk(ERROR, "block-prop", file, `${at}.notice must be a non-empty plain-text string`));
|