@tokenoftrust/storefront-runner 3.12.2 → 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
  }
@@ -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
+ }
@@ -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"]>([
@@ -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";
@@ -103,11 +105,12 @@ const marketingBlocks: MarketingBlock[] = marketing
103
105
  const collections = marketing ? [] : await tot.listCollections();
104
106
 
105
107
  // --- Resolve each block into a typed, render-ready view-model -----------------
106
- type Resolved =
108
+ type Resolved = (
107
109
  | { kind: "hero"; block: HeroBlock; product: CatalogProduct | null; instanceKey: string }
108
110
  | { kind: "collections"; title?: string; cols: CatalogCollection[] }
109
111
  | { kind: "products"; title?: string; products: CatalogProduct[]; presentation?: FeaturedProductsBlock["presentation"] }
110
- | CommerceStaticResolved;
112
+ | CommerceStaticResolved
113
+ ) & { blockId: string };
111
114
 
112
115
  async function resolveProducts(
113
116
  source: FeaturedProductsBlock["source"],
@@ -122,10 +125,11 @@ async function resolveProducts(
122
125
  }
123
126
 
124
127
  const resolved: Resolved[] = [];
125
- for (const block of marketing ? [] : home.blocks) {
128
+ for (const [index, block] of (marketing ? [] : home.blocks).entries()) {
129
+ const blockId = homeBlockId(block, index);
126
130
  const staticBlock = resolveCommerceStaticBlock(block);
127
131
  if (staticBlock) {
128
- resolved.push(staticBlock);
132
+ resolved.push({ ...staticBlock, blockId });
129
133
  continue;
130
134
  }
131
135
  switch (block.component) {
@@ -136,7 +140,7 @@ for (const block of marketing ? [] : home.blocks) {
136
140
  const firstHandle = col?.product_handles[0];
137
141
  if (firstHandle) product = await tot.getProductByHandle(firstHandle);
138
142
  }
139
- resolved.push({ kind: "hero", block, product, instanceKey: `home-${resolved.length}` });
143
+ resolved.push({ kind: "hero", block, product, instanceKey: `home-${resolved.length}`, blockId });
140
144
  break;
141
145
  }
142
146
  case "featured_collections": {
@@ -146,7 +150,7 @@ for (const block of marketing ? [] : home.blocks) {
146
150
  const cols = handles
147
151
  .map((h) => collections.find((c) => c.handle === h))
148
152
  .filter((c): c is CatalogCollection => !!c);
149
- resolved.push({ kind: "collections", title: block.title, cols });
153
+ resolved.push({ kind: "collections", title: block.title, cols, blockId });
150
154
  break;
151
155
  }
152
156
  case "featured_products": {
@@ -155,7 +159,7 @@ for (const block of marketing ? [] : home.blocks) {
155
159
  block.handles,
156
160
  block.limit ?? 4,
157
161
  );
158
- resolved.push({ kind: "products", title: block.title, products, presentation: block.presentation });
162
+ resolved.push({ kind: "products", title: block.title, products, presentation: block.presentation, blockId });
159
163
  break;
160
164
  }
161
165
  }
@@ -201,17 +205,29 @@ const homeSectionWidgets = marketing
201
205
  {
202
206
  !marketing && resolved.map((r) => {
203
207
  if (r.kind === "hero") {
204
- 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
+ );
205
213
  }
206
214
  if (r.kind === "promo_tiles") {
207
- 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
+ );
208
220
  }
209
221
  if (r.kind === "trust_strip") {
210
- 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
+ );
211
227
  }
212
228
  if (r.kind === "collections") {
213
229
  return (
214
- <Section title={r.title} eyebrow="Collections" tone="bg">
230
+ <Section title={r.title} eyebrow="Collections" tone="bg" blockId={r.blockId}>
215
231
  <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
216
232
  {r.cols.map((c) => (
217
233
  <CollectionCard collection={c} productCount={c.product_handles.length} />
@@ -222,9 +238,9 @@ const homeSectionWidgets = marketing
222
238
  }
223
239
  if (r.kind === "products") {
224
240
  if (featuredProductsPresentationView(r.presentation) === "carousel") {
225
- 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} />;
226
242
  }
227
- 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} />;
228
244
  }
229
245
  if (r.kind === "membership_band") {
230
246
  const b = r.block;
@@ -235,7 +251,7 @@ const homeSectionWidgets = marketing
235
251
  t.cta ? { ...t, cta: { ...t.cta, href: withBase(basePath, t.cta.href) } } : t,
236
252
  );
237
253
  return (
238
- <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}>
239
255
  <TierCards tiers={tiers} eyebrow={b.eyebrow} heading={b.title} subhead={b.subhead} />
240
256
  </section>
241
257
  );
@@ -243,10 +259,11 @@ const homeSectionWidgets = marketing
243
259
  if (r.kind === "editorial") {
244
260
  const b = r.block;
245
261
  const media = editorialMediaView(b, tenant.tenant_id);
262
+ const fields = editorialBlockView(b);
246
263
  const right = b.align !== "left";
247
264
  const textOnly = media.kind === "none";
248
265
  return (
249
- <Section tone="bg">
266
+ <Section tone="bg" blockId={r.blockId}>
250
267
  <div
251
268
  class:list={[
252
269
  textOnly ? "max-w-[68ch]" : "grid items-center gap-10 md:grid-cols-2",
@@ -259,15 +276,26 @@ const homeSectionWidgets = marketing
259
276
  <h2 class="display-md">
260
277
  {b.title}
261
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>}
262
281
  <div class="rte mt-5 text-[var(--color-muted)]" set:html={b.body_html} />
263
- {b.ctaLabel && b.ctaHref && (
282
+ {fields.cta && (
264
283
  <div class="mt-7">
265
- <a
266
- href={withBase(basePath, b.ctaHref)}
267
- class="link-accent link-accent--cta font-display text-sm font-medium"
268
- >
269
- {b.ctaLabel} →
270
- </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
+ )}
271
299
  </div>
272
300
  )}
273
301
  </div>
@@ -304,7 +332,7 @@ const homeSectionWidgets = marketing
304
332
  if (r.kind === "newsletter") {
305
333
  const b = r.block;
306
334
  return (
307
- <Section tone="surface" id="newsletter">
335
+ <Section tone="surface" id="newsletter" blockId={r.blockId}>
308
336
  <div class="mx-auto max-w-xl text-center" data-reveal>
309
337
  <h2 class="display-md">
310
338
  {b.title}
@@ -337,7 +365,11 @@ const homeSectionWidgets = marketing
337
365
  );
338
366
  }
339
367
  if (r.kind === "faq") {
340
- return <Faq block={r.block} />;
368
+ return (
369
+ <div data-block={r.blockId}>
370
+ <Faq block={r.block} />
371
+ </div>
372
+ );
341
373
  }
342
374
  return null;
343
375
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.12.2",
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);