@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.
@@ -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
- <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" />
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={1920}
115
- height={650}
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
- </a>
165
+ </picture>
122
166
  ) : (
123
167
  <img
124
168
  src={block.image}
125
169
  alt={block.imageAlt ?? block.headline}
126
- width={1920}
127
- height={650}
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
- <img
230
- src={heroImg}
231
- alt={block.imageAlt ?? block.headline}
232
- width={1600}
233
- height={1000}
234
- loading="eager"
235
- fetchpriority="high"
236
- decoding="sync"
237
- class="h-full w-full object-cover"
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; aspect-ratio: 1920 / 650; background: var(--color-bg); }
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: grid; }
296
- .hero-slideshow__slide > a { min-width: 0; }
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 — a placeholder regulated store used to demonstrate the
45
- // compliance affordance (age-gate + nicotine warning) WITHOUT any real merchant.
46
- // Routed at /sample-store.example/. Content comes from the developer's grafted
47
- // checkout (tenants/sample-store.example/); with none present it degrades to the
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
- // Regulated-store demo: shows the 21+/PACT compliance gate is CONFIG-driven.
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
- siteType: "marketing",
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
- <PactActNotice enabled={compliance?.pactAct} minAge={compliance?.minAge} variant="footer" />
410
- <ShippingRestrictionNotice text={compliance?.shippingRestriction} />
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
- ...slide,
27
- href: slide.ctaHref ? withBase(basePath, slide.ctaHref) : undefined,
28
- checked: index === 0,
29
- loading: index === 0 ? ("eager" as const) : ("lazy" as const),
30
- fetchpriority: index === 0 ? ("high" as const) : ("auto" as const),
31
- decoding: index === 0 ? ("sync" as const) : ("async" as const),
32
- width: 1920,
33
- height: 650,
34
- radioId: `hero-slides-${instanceKey}-${index}`,
35
- radioLabel: `Show slide ${index + 1}: ${slide.headline ?? slide.imageAlt}`,
36
- groupRole: "group" as const,
37
- groupLabel: `${index + 1} of ${slides.length}`,
38
- focusTarget: "slide" as const,
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 { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
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: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
97
- complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
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 { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
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: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
85
- complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
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.0",
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`));