@tokenoftrust/storefront-runner 3.12.1 → 3.12.3

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.
@@ -175,6 +175,7 @@ body { overflow-x: clip; }
175
175
  }
176
176
  /* The menu-drawer copy of search (header.mobile.search "menu") shows only at narrow widths. */
177
177
  .header-search--menu { display: none; }
178
+ .header-search--phone { display: none; width: 100%; max-width: none; margin: 0 0 10px; }
178
179
  .hamburger { display: none; flex-direction: column; gap: 5px; background: none; border: 0; padding: 6px; min-width: 44px; min-height: 44px; align-items: center; justify-content: center; }
179
180
  .hamburger span { width: 26px; height: 2px; background: var(--color-text); border-radius: 2px; }
180
181
 
@@ -522,6 +523,8 @@ a.article-card__tag:hover { text-decoration: underline; }
522
523
  .site-header[data-variant="compact-centered"] .header-search-trigger,
523
524
  .site-header[data-variant="compact-centered"] .header-actions > a[aria-label="Account"],
524
525
  .site-header[data-variant="compact-centered"] .member-cue { display: none; }
526
+ .site-header[data-variant="compact-centered"] .header-search--phone { display: block; }
527
+ .site-header[data-variant="compact-centered"] .header-search--phone.header-search--labelled { display: flex; }
525
528
  .mobile-bottom-nav {
526
529
  position: fixed; inset: auto 0 0; z-index: 130; min-height: 62px;
527
530
  display: flex; align-items: stretch; justify-content: space-around;
@@ -16,6 +16,7 @@ import PriceDisplay from "@/components/catalog/storefront/commerce/PriceDisplay.
16
16
  import RatingStars from "@/components/catalog/storefront/commerce/RatingStars.astro";
17
17
  import { withBase } from "@/lib/publication/shared/basePath";
18
18
  import { pageUrlForm } from "@/lib/content/seo/urlForm";
19
+ import { buildCardCartForm } from "@/lib/checkout/commerce/cardCartForm";
19
20
 
20
21
  interface Props {
21
22
  product: CatalogProduct;
@@ -29,7 +30,7 @@ interface Props {
29
30
  }
30
31
 
31
32
  const { product, priority = false, rating, reviewCount } = Astro.props;
32
- const { tenant, basePath, features } = Astro.locals;
33
+ const { tenant, basePath, features, commerce, apiBasePath, browserEnvironment } = Astro.locals;
33
34
  const showBadges = features.productBadges;
34
35
 
35
36
  // A compliance-verified item (age/lab/regulatory) earns a "Compliant" badge —
@@ -86,6 +87,14 @@ const snapshot = {
86
87
  };
87
88
 
88
89
  // Richer payload for the quick-view dialog (no variant picker — that's the PDP).
90
+ const cardCart = await buildCardCartForm({
91
+ product,
92
+ tenant,
93
+ commerce,
94
+ apiBasePath,
95
+ environment: browserEnvironment.environment,
96
+ });
97
+
89
98
  const quickview = {
90
99
  ...snapshot,
91
100
  images: product.images.slice(0, 4).map((im) => ({
@@ -200,6 +209,31 @@ const quickview = {
200
209
  </h3>
201
210
  <PriceDisplay product={product} locale={locale} class="mt-1.5" />
202
211
 
212
+ {
213
+ cardCart && (
214
+ <form
215
+ action={cardCart.action}
216
+ method={cardCart.method}
217
+ target="_top"
218
+ data-managed-cart
219
+ data-product-handle={product.handle}
220
+ data-variant-id={cardCart.variantId}
221
+ class="relative z-10 mt-3"
222
+ >
223
+ {cardCart.hidden.map((field) => (
224
+ <input type="hidden" name={field.name} value={field.value} />
225
+ ))}
226
+ <input type="hidden" name={cardCart.quantityName} value="1" />
227
+ <button
228
+ type="submit"
229
+ class="w-full rounded-full bg-[var(--color-primary)] px-4 py-3 font-display text-sm font-medium text-[var(--color-primary-contrast)]"
230
+ >
231
+ Add to cart
232
+ </button>
233
+ </form>
234
+ )
235
+ }
236
+
203
237
  {rating != null && <RatingStars rating={rating} count={reviewCount} class="mt-1.5" />}
204
238
 
205
239
  {
@@ -44,6 +44,8 @@ interface Props {
44
44
  variant?: "default" | "compact";
45
45
  tone?: "bg" | "surface";
46
46
  class?: string;
47
+ /** Stable home-block id forwarded to the section. */
48
+ blockId?: string;
47
49
  }
48
50
 
49
51
  const {
@@ -57,6 +59,7 @@ const {
57
59
  variant = "default",
58
60
  tone = "bg",
59
61
  class: className,
62
+ blockId,
60
63
  } = Astro.props;
61
64
 
62
65
  const tot = Astro.locals.tot;
@@ -94,7 +97,7 @@ const lgWidth: Record<NonNullable<Props["columns"]>, string> = {
94
97
  const gap = variant === "compact" ? "gap-3" : "gap-5";
95
98
  ---
96
99
 
97
- <Section title={title} eyebrow={eyebrow} tone={tone} class={className}>
100
+ <Section title={title} eyebrow={eyebrow} tone={tone} class={className} blockId={blockId}>
98
101
  <div class="tot-carousel">
99
102
  {
100
103
  panels.map((panel, i) => (
@@ -31,6 +31,8 @@ interface Props {
31
31
  /** Hide the whole section when there are no products (default true). */
32
32
  hideWhenEmpty?: boolean;
33
33
  class?: string;
34
+ /** Stable home-block id forwarded to the section. */
35
+ blockId?: string;
34
36
  }
35
37
 
36
38
  const {
@@ -43,6 +45,7 @@ const {
43
45
  tone = "bg",
44
46
  hideWhenEmpty = true,
45
47
  class: className,
48
+ blockId,
46
49
  } = Astro.props;
47
50
 
48
51
  const products =
@@ -60,7 +63,7 @@ const hidden = hideWhenEmpty && products.length === 0;
60
63
 
61
64
  {
62
65
  !hidden && (
63
- <Section title={title} eyebrow={eyebrow} tone={tone} class={className}>
66
+ <Section title={title} eyebrow={eyebrow} tone={tone} class={className} blockId={blockId}>
64
67
  {products.length > 0 ? (
65
68
  <div class:list={["grid gap-5", colClass[columns]]}>
66
69
  {products.map((p, i) => (
@@ -13,14 +13,17 @@ interface Props {
13
13
  id?: string;
14
14
  /** Stable `data-widget` name, so the rendered page says which block this is. */
15
15
  widget?: string;
16
+ /** Stable home-block id. Omitted when the section is not a home block. */
17
+ blockId?: string;
16
18
  }
17
- const { title, eyebrow, tone = "bg", class: className, id, widget } = Astro.props;
19
+ const { title, eyebrow, tone = "bg", class: className, id, widget, blockId } = Astro.props;
18
20
  const bg = tone === "surface" ? "bg-[var(--color-surface)]" : "bg-[var(--color-bg)]";
19
21
  ---
20
22
 
21
23
  <section
22
24
  id={id}
23
25
  data-widget={widget}
26
+ data-block={blockId}
24
27
  class:list={[bg, "py-[clamp(3rem,7vw,6rem)]", className]}
25
28
  >
26
29
  <div class="container-prose">
@@ -337,6 +337,19 @@ const currentPath = Astro.url.pathname;
337
337
  </div>
338
338
  </div>
339
339
 
340
+ {
341
+ compact && searchAction && (
342
+ <div class="border-b border-border bg-surface px-4 py-2 md:hidden" data-header-phone-search>
343
+ <SearchTypeahead
344
+ client:idle
345
+ action={searchAction}
346
+ placeholder={searchPlaceholder}
347
+ label={searchLabel}
348
+ />
349
+ </div>
350
+ )
351
+ }
352
+
340
353
  <!-- Primary nav bar: desktop MegaMenu (hidden md:block) + a no-JS-safe mobile
341
354
  fallback (md:hidden) below it. The fallback renders the SAME nav data as
342
355
  real, crawlable <a href> links via a native <details>/<summary> accordion
@@ -6,6 +6,7 @@
6
6
  * Reused by the homepage and (future) marketing content pages.
7
7
  */
8
8
  import type { MarketingBlock } from "@/lib/content/storyblok/content-model";
9
+ import { homeBlockId } from "@/lib/content/shared/homeVisualParity";
9
10
  import MarketingHero from "@/components/content/storefront/marketing/MarketingHero.astro";
10
11
  import TrustBar from "@/components/content/storefront/marketing/TrustBar.astro";
11
12
  import SplitCompare from "@/components/content/storefront/marketing/SplitCompare.astro";
@@ -24,28 +25,69 @@ interface Props {
24
25
  const { blocks, basePath } = Astro.props;
25
26
  ---
26
27
  {
27
- blocks.map((block) => {
28
+ blocks.map((block, index) => {
29
+ const blockId = homeBlockId(block, index);
28
30
  switch (block.component) {
29
31
  case "marketing_hero":
30
- return <MarketingHero block={block} basePath={basePath} />;
32
+ return (
33
+ <div data-block={blockId}>
34
+ <MarketingHero block={block} basePath={basePath} />
35
+ </div>
36
+ );
31
37
  case "trust_bar":
32
- return <TrustBar block={block} />;
38
+ return (
39
+ <div data-block={blockId}>
40
+ <TrustBar block={block} />
41
+ </div>
42
+ );
33
43
  case "split_compare":
34
- return <SplitCompare block={block} />;
44
+ return (
45
+ <div data-block={blockId}>
46
+ <SplitCompare block={block} />
47
+ </div>
48
+ );
35
49
  case "steps":
36
- return <Steps block={block} />;
50
+ return (
51
+ <div data-block={blockId}>
52
+ <Steps block={block} />
53
+ </div>
54
+ );
37
55
  case "card_grid":
38
- return <CardGrid block={block} basePath={basePath} />;
56
+ return (
57
+ <div data-block={blockId}>
58
+ <CardGrid block={block} basePath={basePath} />
59
+ </div>
60
+ );
39
61
  case "integrations":
40
- return <Integrations block={block} basePath={basePath} />;
62
+ return (
63
+ <div data-block={blockId}>
64
+ <Integrations block={block} basePath={basePath} />
65
+ </div>
66
+ );
41
67
  case "proof_strip":
42
- return <ProofStrip block={block} />;
68
+ return (
69
+ <div data-block={blockId}>
70
+ <ProofStrip block={block} />
71
+ </div>
72
+ );
43
73
  case "testimonials":
44
- return <Testimonials block={block} />;
74
+ return (
75
+ <div data-block={blockId}>
76
+ <Testimonials block={block} />
77
+ </div>
78
+ );
45
79
  case "faq":
46
- return <Faq block={block} />;
80
+ return (
81
+ <div data-block={blockId}>
82
+ <Faq block={block} />
83
+ </div>
84
+ );
47
85
  case "cta_band":
48
- return <CtaBand block={block} basePath={basePath} />;
86
+ return (
87
+ <div data-block={blockId}>
88
+ <CtaBand block={block} basePath={basePath} />
89
+ </div>
90
+ );
49
91
  default:
50
92
  return null;
51
93
  }
@@ -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,85 @@
1
+ /**
2
+ * Add-to-cart form for a catalog card. One default variant — the first
3
+ * available one — signed the same way the product page signs its handoff.
4
+ * No form when the product cannot be bought or the cart cannot be signed.
5
+ */
6
+ import type { CatalogProduct, CatalogVariant, ResolvedManagedCheckout, TenantConfig } from "@tot/public-runtime";
7
+ import {
8
+ ANONYMOUS_MEMBER,
9
+ buildMemberSignedCartPayload,
10
+ commerceTenantId,
11
+ normalizePurchaseOptions,
12
+ SAMPLE_CART_HANDOFF_URL,
13
+ sampleVariantCartPayload,
14
+ visiblePurchaseOptions,
15
+ isAutopilotEnabled,
16
+ workerCartApiPath,
17
+ } from "@tot/public-runtime";
18
+ import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
19
+ import { resolveCartSigning } from "@/lib/checkout/shared/checkoutCommerce";
20
+
21
+ export interface CardCartForm {
22
+ action: string;
23
+ method: "get" | "post";
24
+ variantId: string;
25
+ hidden: { name: string; value: string }[];
26
+ quantityName: string;
27
+ }
28
+
29
+ /** The card buys the first variant that can ship. Sold out yields none. */
30
+ export function defaultCardVariant(product: CatalogProduct): CatalogVariant | null {
31
+ return product.variants.find((variant) => variant.inventory.available) ?? null;
32
+ }
33
+
34
+ export async function buildCardCartForm(input: {
35
+ product: CatalogProduct;
36
+ tenant: TenantConfig;
37
+ commerce: ResolvedManagedCheckout | null | undefined;
38
+ apiBasePath: string;
39
+ environment: string;
40
+ }): Promise<CardCartForm | null> {
41
+ const variant = defaultCardVariant(input.product);
42
+ if (!variant) return null;
43
+ const sampleHandoff = isLocalSampleCheckout(input.tenant.tenant_id);
44
+ if (!sampleHandoff && !input.commerce) return null;
45
+ const signing = sampleHandoff ? { enabled: true as const, secret: undefined } : await resolveCartSigning(input.commerce!);
46
+ if (!signing.enabled) return null;
47
+ try {
48
+ const autopilotEnabled = isAutopilotEnabled(input.tenant.commerce?.autopilot);
49
+ const purchaseOption = visiblePurchaseOptions(normalizePurchaseOptions(input.product), { autopilotEnabled })[0];
50
+ if (!purchaseOption) return null;
51
+ const payload = sampleHandoff
52
+ ? sampleVariantCartPayload(input.product.handle, variant.id)
53
+ : await buildMemberSignedCartPayload(input.product, variant, {
54
+ mode: input.commerce!.mode,
55
+ secret: signing.secret,
56
+ purchaseOption,
57
+ pricing: { member: ANONYMOUS_MEMBER, program: input.tenant.commerce?.membership },
58
+ autopilotEnabled,
59
+ tenantId: commerceTenantId(input.tenant),
60
+ currency: input.tenant.currency,
61
+ });
62
+ const provider = input.tenant.serving?.provider === "s3-overwrite" ? "s3-overwrite" : "worker-r2-pointer";
63
+ return {
64
+ action: sampleHandoff
65
+ ? SAMPLE_CART_HANDOFF_URL
66
+ : workerCartApiPath({
67
+ provider,
68
+ phase: input.environment === "live" ? "publication" : "preview",
69
+ apiBasePath: input.apiBasePath,
70
+ endpoint: "/api/commerce/checkout-handoff",
71
+ }),
72
+ method: sampleHandoff ? "get" : "post",
73
+ variantId: variant.id,
74
+ hidden: payload.hidden,
75
+ quantityName: payload.quantityName,
76
+ };
77
+ } catch (err) {
78
+ if (!(err instanceof Error) || err.name !== "CartSigningUnavailableError") {
79
+ console.warn(
80
+ `[storefront] Invalid checkout purchase options for ${input.product.handle}; cart disabled for this product.`,
81
+ );
82
+ }
83
+ return null;
84
+ }
85
+ }
@@ -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
+ }
@@ -94,6 +94,54 @@ export function promoTilesView(
94
94
  };
95
95
  }
96
96
 
97
+ const BLOCK_ID_MAX = 64;
98
+
99
+ function blockToken(value: string): string {
100
+ return value
101
+ .trim()
102
+ .toLowerCase()
103
+ .replace(/[^a-z0-9]+/g, "-")
104
+ .replace(/^-+|-+$/g, "")
105
+ .slice(0, BLOCK_ID_MAX);
106
+ }
107
+
108
+ /**
109
+ * Stable `data-block` id. A declared `id` wins. Otherwise the component name
110
+ * plus the block's index. Never random, and never a tenant id.
111
+ */
112
+ export function homeBlockId(block: { component: string; id?: string }, index: number): string {
113
+ const declared = typeof block.id === "string" ? blockToken(block.id) : "";
114
+ if (declared) return declared;
115
+ const component = blockToken(block.component) || "block";
116
+ const position = Number.isInteger(index) && index >= 0 ? index : 0;
117
+ return `${component}-${position}`.slice(0, BLOCK_ID_MAX);
118
+ }
119
+
120
+ export interface EditorialBlockView {
121
+ excerpt?: string;
122
+ date?: string;
123
+ cta: { label: string; href: string; variant: "link" | "button" } | null;
124
+ }
125
+
126
+ function declaredText(value: string | undefined): string | undefined {
127
+ const trimmed = value?.trim();
128
+ return trimmed ? trimmed : undefined;
129
+ }
130
+
131
+ /** Excerpt, date, and CTA. A button variant carries the label with no arrow. */
132
+ export function editorialBlockView(block: EditorialBlock): EditorialBlockView {
133
+ const label = declaredText(block.ctaLabel);
134
+ const href = declaredText(block.ctaHref);
135
+ return {
136
+ excerpt: declaredText(block.excerpt),
137
+ date: declaredText(block.date),
138
+ cta:
139
+ label && href && isSafeStorefrontHref(href)
140
+ ? { label, href, variant: block.ctaVariant === "button" ? "button" : "link" }
141
+ : null,
142
+ };
143
+ }
144
+
97
145
  /**
98
146
  * The editorial block's media column: the block image, else generated art when
99
147
  * the block asks for it with `imageSeed`, else none — a text-only block.
@@ -281,13 +281,14 @@ function renderNavItem(item: ChromeNavItem, index: number, currentPath?: string)
281
281
  * The header search form. `inMenu` is the copy placed inside the menu drawer
282
282
  * (header.mobile.search "menu"); it carries its own input id so both can exist.
283
283
  */
284
- function renderSearchForm(search: ChromeSearch, inMenu = false): string {
285
- const id = inMenu ? "hdr-q-menu" : "hdr-q";
284
+ function renderSearchForm(search: ChromeSearch, placement: "row" | "menu" | "phone" = "row"): string {
285
+ const id = placement === "menu" ? "hdr-q-menu" : placement === "phone" ? "hdr-q-phone" : "hdr-q";
286
286
  const button = search.buttonLabel
287
287
  ? `<button type="submit" class="header-search__submit">${esc(search.buttonLabel)}</button>`
288
288
  : `<button type="submit" aria-label="Search">${ICON_SEARCH}</button>`;
289
+ const placementClass = placement === "menu" ? " header-search--menu" : placement === "phone" ? " header-search--phone" : "";
289
290
  return (
290
- `<form class="header-search${inMenu ? " header-search--menu" : ""}${search.buttonLabel ? " header-search--labelled" : ""}" action="${esc(search.action ?? "/search")}" method="get" role="search">\n` +
291
+ `<form class="header-search${placementClass}${search.buttonLabel ? " header-search--labelled" : ""}" action="${esc(search.action ?? "/search")}" method="get" role="search">\n` +
291
292
  ` <label class="sr-only" for="${id}">${esc(search.label ?? "Search")}</label>\n` +
292
293
  ` <input id="${id}" type="search" name="q" placeholder="${esc(search.placeholder ?? "Search")}" autocomplete="off">\n` +
293
294
  ` ${button}\n` +
@@ -381,7 +382,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
381
382
  const navItems = header.nav
382
383
  .map((item, i) => renderNavItem(item, i, currentPath))
383
384
  .join("\n ");
384
- const menuSearch = searchInMenu ? ` ${renderSearchForm(header.search!, true)}\n` : "";
385
+ const menuSearch = searchInMenu ? ` ${renderSearchForm(header.search!, "menu")}\n` : "";
385
386
  const primaryNav =
386
387
  `<nav class="primary-nav" id="primaryNav" aria-label="Primary">\n` +
387
388
  menuSearch +
@@ -402,6 +403,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
402
403
  compact && header.search
403
404
  ? `<a class="header-search-trigger" data-header-fixed-action href="${esc(header.search.action ?? "/search")}" aria-label="${esc(header.search.label ?? "Search")}">${ICON_SEARCH}</a>`
404
405
  : "";
406
+ const phoneSearch = compact && header.search ? ` ${renderSearchForm(header.search, "phone")}\n` : "";
405
407
  const mobileBottom =
406
408
  compact
407
409
  ? `<nav class="mobile-bottom-nav" aria-label="Quick links">` +
@@ -426,6 +428,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
426
428
  (!minimal && !compact && search ? ` ${search}\n` : "") +
427
429
  ` <div class="header-actions">${memberCue}${ctas}${compactSearch}${actionLinks}${account}${cart}${menuLeft ? "" : hamburger}</div>\n` +
428
430
  ` </div>\n` +
431
+ phoneSearch +
429
432
  navBar +
430
433
  `</header>` +
431
434
  mobileBottom
@@ -14,7 +14,9 @@ import type { Tier } from "@/lib/membership/model";
14
14
  import type { TrustStripItem } from "@/lib/content/trustStrip";
15
15
  import type { PromoTilePresentation } from "@tot/public-runtime";
16
16
 
17
- export type HomeBlock =
17
+ type BlockIdentity = { id?: string };
18
+
19
+ export type HomeBlock = (
18
20
  | HeroBlock
19
21
  | PromoTilesBlock
20
22
  | CommerceTrustStripBlock
@@ -23,7 +25,9 @@ export type HomeBlock =
23
25
  | MembershipBandBlock
24
26
  | EditorialBlock
25
27
  | NewsletterBlock
26
- | FaqBlock;
28
+ | FaqBlock
29
+ ) &
30
+ BlockIdentity;
27
31
 
28
32
  export interface CommerceTrustStripBlock {
29
33
  component: "trust_strip";
@@ -139,8 +143,14 @@ export interface EditorialBlock {
139
143
  image?: string;
140
144
  imageAlt?: string;
141
145
  align?: "left" | "right";
146
+ /** Plain-text dek. Rendered only when declared. */
147
+ excerpt?: string;
148
+ /** Plain-text publication date. Rendered only when declared. */
149
+ date?: string;
142
150
  ctaLabel?: string;
143
151
  ctaHref?: string;
152
+ /** `button` is a label with no forced arrow. Absent or `link` keeps the arrow link. */
153
+ ctaVariant?: "link" | "button";
144
154
  }
145
155
 
146
156
  export interface NewsletterBlock {
@@ -278,7 +288,7 @@ export interface CtaBandBlock {
278
288
  ctas?: MarketingCta[];
279
289
  }
280
290
 
281
- export type MarketingBlock =
291
+ export type MarketingBlock = (
282
292
  | MarketingHeroBlock
283
293
  | TrustBarBlock
284
294
  | SplitCompareBlock
@@ -288,7 +298,9 @@ export type MarketingBlock =
288
298
  | ProofStripBlock
289
299
  | TestimonialsBlock
290
300
  | FaqBlock
291
- | CtaBandBlock;
301
+ | CtaBandBlock
302
+ ) &
303
+ BlockIdentity;
292
304
 
293
305
  /** Discriminators that identify a marketing block (vs a commerce HomeBlock). */
294
306
  export const MARKETING_COMPONENTS = new Set<MarketingBlock["component"]>([
@@ -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";
@@ -38,7 +38,9 @@ import {
38
38
  import { resolveSectionSource } from "@/lib/content/shared/sections";
39
39
  import { featuredProductsPresentationView } from "@/lib/catalog/shared/featuredProductsPresentation";
40
40
  import {
41
+ editorialBlockView,
41
42
  editorialMediaView,
43
+ homeBlockId,
42
44
  resolveCommerceStaticBlock,
43
45
  type CommerceStaticResolved,
44
46
  } from "@/lib/content/shared/homeVisualParity";
@@ -73,6 +75,12 @@ if (rawHtml && rawPolicy.allowed) {
73
75
  shippingRestriction: tenant.compliance?.shippingRestriction,
74
76
  exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
75
77
  };
78
+ const complianceChrome = rawComplianceChrome(complianceSections({
79
+ compliance: tenant.compliance,
80
+ ageVerificationEnabled: capabilities.ageVerification.enabled,
81
+ exciseTaxEnabled: capabilities.exciseTax.enabled,
82
+ catalog: features.catalog,
83
+ }), notices);
76
84
  return rawHtmlResponse(rawHtml, {
77
85
  nonce: Astro.locals.cspNonce,
78
86
  basePath,
@@ -81,8 +89,8 @@ if (rawHtml && rawPolicy.allowed) {
81
89
  plan: Astro.locals.integrationPlan,
82
90
  consentSnapshot: Astro.locals.consentSnapshot,
83
91
  canonicalUrl: new URL("/", canonicalBase + "/").toString(),
84
- complianceTop: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
85
- complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
92
+ complianceTop: complianceChrome.top || undefined,
93
+ complianceBottom: complianceChrome.bottom || undefined,
86
94
  });
87
95
  }
88
96
 
@@ -97,11 +105,12 @@ const marketingBlocks: MarketingBlock[] = marketing
97
105
  const collections = marketing ? [] : await tot.listCollections();
98
106
 
99
107
  // --- Resolve each block into a typed, render-ready view-model -----------------
100
- type Resolved =
108
+ type Resolved = (
101
109
  | { kind: "hero"; block: HeroBlock; product: CatalogProduct | null; instanceKey: string }
102
110
  | { kind: "collections"; title?: string; cols: CatalogCollection[] }
103
111
  | { kind: "products"; title?: string; products: CatalogProduct[]; presentation?: FeaturedProductsBlock["presentation"] }
104
- | CommerceStaticResolved;
112
+ | CommerceStaticResolved
113
+ ) & { blockId: string };
105
114
 
106
115
  async function resolveProducts(
107
116
  source: FeaturedProductsBlock["source"],
@@ -116,10 +125,11 @@ async function resolveProducts(
116
125
  }
117
126
 
118
127
  const resolved: Resolved[] = [];
119
- for (const block of marketing ? [] : home.blocks) {
128
+ for (const [index, block] of (marketing ? [] : home.blocks).entries()) {
129
+ const blockId = homeBlockId(block, index);
120
130
  const staticBlock = resolveCommerceStaticBlock(block);
121
131
  if (staticBlock) {
122
- resolved.push(staticBlock);
132
+ resolved.push({ ...staticBlock, blockId });
123
133
  continue;
124
134
  }
125
135
  switch (block.component) {
@@ -130,7 +140,7 @@ for (const block of marketing ? [] : home.blocks) {
130
140
  const firstHandle = col?.product_handles[0];
131
141
  if (firstHandle) product = await tot.getProductByHandle(firstHandle);
132
142
  }
133
- resolved.push({ kind: "hero", block, product, instanceKey: `home-${resolved.length}` });
143
+ resolved.push({ kind: "hero", block, product, instanceKey: `home-${resolved.length}`, blockId });
134
144
  break;
135
145
  }
136
146
  case "featured_collections": {
@@ -140,7 +150,7 @@ for (const block of marketing ? [] : home.blocks) {
140
150
  const cols = handles
141
151
  .map((h) => collections.find((c) => c.handle === h))
142
152
  .filter((c): c is CatalogCollection => !!c);
143
- resolved.push({ kind: "collections", title: block.title, cols });
153
+ resolved.push({ kind: "collections", title: block.title, cols, blockId });
144
154
  break;
145
155
  }
146
156
  case "featured_products": {
@@ -149,7 +159,7 @@ for (const block of marketing ? [] : home.blocks) {
149
159
  block.handles,
150
160
  block.limit ?? 4,
151
161
  );
152
- resolved.push({ kind: "products", title: block.title, products, presentation: block.presentation });
162
+ resolved.push({ kind: "products", title: block.title, products, presentation: block.presentation, blockId });
153
163
  break;
154
164
  }
155
165
  }
@@ -195,17 +205,29 @@ const homeSectionWidgets = marketing
195
205
  {
196
206
  !marketing && resolved.map((r) => {
197
207
  if (r.kind === "hero") {
198
- return <Hero block={r.block} featureProduct={r.product} instanceKey={r.instanceKey} />;
208
+ return (
209
+ <div data-block={r.blockId}>
210
+ <Hero block={r.block} featureProduct={r.product} instanceKey={r.instanceKey} />
211
+ </div>
212
+ );
199
213
  }
200
214
  if (r.kind === "promo_tiles") {
201
- return <PromoTiles block={r.block} basePath={basePath} tenantId={tenant.tenant_id} />;
215
+ return (
216
+ <div data-block={r.blockId}>
217
+ <PromoTiles block={r.block} basePath={basePath} tenantId={tenant.tenant_id} />
218
+ </div>
219
+ );
202
220
  }
203
221
  if (r.kind === "trust_strip") {
204
- return <TrustStrip items={r.block.items} tenantId={tenant.tenant_id} mobile={r.block.mobile} />;
222
+ return (
223
+ <div data-block={r.blockId}>
224
+ <TrustStrip items={r.block.items} tenantId={tenant.tenant_id} mobile={r.block.mobile} />
225
+ </div>
226
+ );
205
227
  }
206
228
  if (r.kind === "collections") {
207
229
  return (
208
- <Section title={r.title} eyebrow="Collections" tone="bg">
230
+ <Section title={r.title} eyebrow="Collections" tone="bg" blockId={r.blockId}>
209
231
  <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
210
232
  {r.cols.map((c) => (
211
233
  <CollectionCard collection={c} productCount={c.product_handles.length} />
@@ -216,9 +238,9 @@ const homeSectionWidgets = marketing
216
238
  }
217
239
  if (r.kind === "products") {
218
240
  if (featuredProductsPresentationView(r.presentation) === "carousel") {
219
- return <ProductCarousel products={r.products} title={r.title} eyebrow="Shop" tone="surface" />;
241
+ return <ProductCarousel products={r.products} title={r.title} eyebrow="Shop" tone="surface" blockId={r.blockId} />;
220
242
  }
221
- return <ProductGrid products={r.products} title={r.title} eyebrow="Shop" tone="surface" />;
243
+ return <ProductGrid products={r.products} title={r.title} eyebrow="Shop" tone="surface" blockId={r.blockId} />;
222
244
  }
223
245
  if (r.kind === "membership_band") {
224
246
  const b = r.block;
@@ -229,7 +251,7 @@ const homeSectionWidgets = marketing
229
251
  t.cta ? { ...t, cta: { ...t.cta, href: withBase(basePath, t.cta.href) } } : t,
230
252
  );
231
253
  return (
232
- <section class="bg-[var(--color-surface)] py-[clamp(3rem,7vw,6rem)]">
254
+ <section class="bg-[var(--color-surface)] py-[clamp(3rem,7vw,6rem)]" data-block={r.blockId}>
233
255
  <TierCards tiers={tiers} eyebrow={b.eyebrow} heading={b.title} subhead={b.subhead} />
234
256
  </section>
235
257
  );
@@ -237,10 +259,11 @@ const homeSectionWidgets = marketing
237
259
  if (r.kind === "editorial") {
238
260
  const b = r.block;
239
261
  const media = editorialMediaView(b, tenant.tenant_id);
262
+ const fields = editorialBlockView(b);
240
263
  const right = b.align !== "left";
241
264
  const textOnly = media.kind === "none";
242
265
  return (
243
- <Section tone="bg">
266
+ <Section tone="bg" blockId={r.blockId}>
244
267
  <div
245
268
  class:list={[
246
269
  textOnly ? "max-w-[68ch]" : "grid items-center gap-10 md:grid-cols-2",
@@ -253,15 +276,26 @@ const homeSectionWidgets = marketing
253
276
  <h2 class="display-md">
254
277
  {b.title}
255
278
  </h2>
279
+ {fields.date && <p class="mt-3 font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-muted)]">{fields.date}</p>}
280
+ {fields.excerpt && <p class="mt-4 text-[var(--color-text)]">{fields.excerpt}</p>}
256
281
  <div class="rte mt-5 text-[var(--color-muted)]" set:html={b.body_html} />
257
- {b.ctaLabel && b.ctaHref && (
282
+ {fields.cta && (
258
283
  <div class="mt-7">
259
- <a
260
- href={withBase(basePath, b.ctaHref)}
261
- class="link-accent link-accent--cta font-display text-sm font-medium"
262
- >
263
- {b.ctaLabel} →
264
- </a>
284
+ {fields.cta.variant === "button" ? (
285
+ <a
286
+ href={withBase(basePath, fields.cta.href)}
287
+ class="inline-flex rounded-full bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-medium text-[var(--color-primary-contrast)]"
288
+ >
289
+ {fields.cta.label}
290
+ </a>
291
+ ) : (
292
+ <a
293
+ href={withBase(basePath, fields.cta.href)}
294
+ class="link-accent link-accent--cta font-display text-sm font-medium"
295
+ >
296
+ {fields.cta.label} →
297
+ </a>
298
+ )}
265
299
  </div>
266
300
  )}
267
301
  </div>
@@ -298,7 +332,7 @@ const homeSectionWidgets = marketing
298
332
  if (r.kind === "newsletter") {
299
333
  const b = r.block;
300
334
  return (
301
- <Section tone="surface" id="newsletter">
335
+ <Section tone="surface" id="newsletter" blockId={r.blockId}>
302
336
  <div class="mx-auto max-w-xl text-center" data-reveal>
303
337
  <h2 class="display-md">
304
338
  {b.title}
@@ -331,7 +365,11 @@ const homeSectionWidgets = marketing
331
365
  );
332
366
  }
333
367
  if (r.kind === "faq") {
334
- return <Faq block={r.block} />;
368
+ return (
369
+ <div data-block={r.blockId}>
370
+ <Faq block={r.block} />
371
+ </div>
372
+ );
335
373
  }
336
374
  return null;
337
375
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.12.1",
3
+ "version": "3.12.3",
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",
@@ -569,6 +569,20 @@ function validateFaq(block, file, at, out) {
569
569
  });
570
570
  }
571
571
 
572
+ function validateEditorial(block, file, at, out) {
573
+ for (const prop of ["excerpt", "date", "id"]) {
574
+ if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
575
+ out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
576
+ }
577
+ }
578
+ if (block.ctaVariant !== undefined && block.ctaVariant !== "button" && block.ctaVariant !== "link") {
579
+ out.push(mk(ERROR, "block-prop", file, `${at}.ctaVariant must be button or link`));
580
+ }
581
+ if (typeof block.ctaHref === "string" && !isSafeStorefrontHref(block.ctaHref)) {
582
+ out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
583
+ }
584
+ }
585
+
572
586
  function validateHomeDoc(doc, file, tenantId) {
573
587
  const out = [];
574
588
  if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
@@ -620,12 +634,16 @@ function validateHomeDoc(doc, file, tenantId) {
620
634
  if (component === "hero" && block.notice !== undefined && (typeof block.notice !== "string" || !block.notice.trim())) {
621
635
  out.push(mk(ERROR, "block-prop", file, `${at}.notice must be a non-empty plain-text string`));
622
636
  }
637
+ if (block.id !== undefined && (typeof block.id !== "string" || !block.id.trim())) {
638
+ out.push(mk(ERROR, "block-prop", file, `${at}.id must be a non-empty string`));
639
+ }
623
640
  if (component === "editorial" && block.image !== undefined) {
624
641
  validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
625
642
  if (typeof block.imageAlt !== "string") {
626
643
  out.push(mk(ERROR, "block-required", file, `${at}.imageAlt is required when ${at}.image is supplied`));
627
644
  }
628
645
  }
646
+ if (component === "editorial") validateEditorial(block, file, at, out);
629
647
  if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
630
648
  if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
631
649
  if (component === "faq") validateFaq(block, file, at, out);