@tokenoftrust/storefront-runner 3.12.2 → 3.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/public/shared/commerce-chrome.css +11 -2
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +35 -1
- package/apps/storefront/src/components/catalog/storefront/commerce/ProductCarousel.astro +4 -1
- package/apps/storefront/src/components/catalog/storefront/commerce/ProductGrid.astro +4 -1
- package/apps/storefront/src/components/content/storefront/Section.astro +4 -1
- package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +13 -0
- package/apps/storefront/src/components/content/storefront/marketing/MarketingBlocks.astro +53 -11
- package/apps/storefront/src/layouts/Layout.astro +19 -9
- package/apps/storefront/src/lib/checkout/commerce/cardCartForm.ts +85 -0
- package/apps/storefront/src/lib/content/shared/homeVisualParity.ts +48 -0
- package/apps/storefront/src/lib/content/shared/rawChrome.ts +7 -4
- package/apps/storefront/src/lib/content/storyblok/content-model.ts +16 -4
- package/apps/storefront/src/pages/index.astro +56 -24
- package/apps/storefront/src/styles/global.css +15 -3
- package/package.json +1 -1
- package/scripts/tenant/validate.mjs +18 -0
|
@@ -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
|
|
|
@@ -453,10 +454,16 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
453
454
|
.site-footer[data-variant="light-collapsible"] .footer-disclaimer p { color: var(--color-text); }
|
|
454
455
|
|
|
455
456
|
/* --- Reveal-on-scroll (raw-chrome markup uses .reveal/.in) ---------------- */
|
|
456
|
-
.reveal
|
|
457
|
+
/* Unmarked .reveal stays visible. Opacity 0 applies only with .is-below,
|
|
458
|
+
the same below-the-fold mark as [data-reveal], so an in-viewport block
|
|
459
|
+
is not hidden before a script adds .in. */
|
|
460
|
+
.reveal { opacity: 1; }
|
|
461
|
+
.reveal.is-below { transition: opacity .5s ease, transform .5s ease; }
|
|
462
|
+
.reveal.is-below:not(.in) { opacity: 0; transform: translateY(18px); }
|
|
457
463
|
.reveal.in { opacity: 1; transform: none; }
|
|
458
464
|
@media (prefers-reduced-motion: reduce) {
|
|
459
|
-
.reveal
|
|
465
|
+
.reveal,
|
|
466
|
+
.reveal.is-below:not(.in) { opacity: 1; transform: none; transition: none; }
|
|
460
467
|
}
|
|
461
468
|
|
|
462
469
|
/* --- Responsive ----------------------------------------------------------- */
|
|
@@ -522,6 +529,8 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
522
529
|
.site-header[data-variant="compact-centered"] .header-search-trigger,
|
|
523
530
|
.site-header[data-variant="compact-centered"] .header-actions > a[aria-label="Account"],
|
|
524
531
|
.site-header[data-variant="compact-centered"] .member-cue { display: none; }
|
|
532
|
+
.site-header[data-variant="compact-centered"] .header-search--phone { display: block; }
|
|
533
|
+
.site-header[data-variant="compact-centered"] .header-search--phone.header-search--labelled { display: flex; }
|
|
525
534
|
.mobile-bottom-nav {
|
|
526
535
|
position: fixed; inset: auto 0 0; z-index: 130; min-height: 62px;
|
|
527
536
|
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
|
|
32
|
+
return (
|
|
33
|
+
<div data-block={blockId}>
|
|
34
|
+
<MarketingHero block={block} basePath={basePath} />
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
31
37
|
case "trust_bar":
|
|
32
|
-
return
|
|
38
|
+
return (
|
|
39
|
+
<div data-block={blockId}>
|
|
40
|
+
<TrustBar block={block} />
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
33
43
|
case "split_compare":
|
|
34
|
-
return
|
|
44
|
+
return (
|
|
45
|
+
<div data-block={blockId}>
|
|
46
|
+
<SplitCompare block={block} />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
35
49
|
case "steps":
|
|
36
|
-
return
|
|
50
|
+
return (
|
|
51
|
+
<div data-block={blockId}>
|
|
52
|
+
<Steps block={block} />
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
37
55
|
case "card_grid":
|
|
38
|
-
return
|
|
56
|
+
return (
|
|
57
|
+
<div data-block={blockId}>
|
|
58
|
+
<CardGrid block={block} basePath={basePath} />
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
39
61
|
case "integrations":
|
|
40
|
-
return
|
|
62
|
+
return (
|
|
63
|
+
<div data-block={blockId}>
|
|
64
|
+
<Integrations block={block} basePath={basePath} />
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
41
67
|
case "proof_strip":
|
|
42
|
-
return
|
|
68
|
+
return (
|
|
69
|
+
<div data-block={blockId}>
|
|
70
|
+
<ProofStrip block={block} />
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
43
73
|
case "testimonials":
|
|
44
|
-
return
|
|
74
|
+
return (
|
|
75
|
+
<div data-block={blockId}>
|
|
76
|
+
<Testimonials block={block} />
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
45
79
|
case "faq":
|
|
46
|
-
return
|
|
80
|
+
return (
|
|
81
|
+
<div data-block={blockId}>
|
|
82
|
+
<Faq block={block} />
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
47
85
|
case "cta_band":
|
|
48
|
-
return
|
|
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
|
}
|
|
@@ -582,14 +582,26 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
582
582
|
mountWidgetPostMessageBridge();
|
|
583
583
|
</script>
|
|
584
584
|
|
|
585
|
-
{/* Scroll-reveal
|
|
585
|
+
{/* Scroll-reveal. Initial-viewport blocks stay visible (no .is-below).
|
|
586
|
+
Only blocks below the fold start hidden, then .is-in shows them. */}
|
|
586
587
|
<script>
|
|
587
|
-
// Reveal-on-scroll
|
|
588
|
+
// Reveal-on-scroll. Reduced motion stays visible via CSS and .is-in.
|
|
588
589
|
const els = document.querySelectorAll("[data-reveal]");
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
) {
|
|
590
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
591
|
+
const fold = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
592
|
+
const below: Element[] = [];
|
|
593
|
+
for (const el of els) {
|
|
594
|
+
const rect = el.getBoundingClientRect();
|
|
595
|
+
const laidOut = rect.width > 0 && rect.height > 0;
|
|
596
|
+
const inView = !laidOut || fold <= 0 || (rect.bottom > 0 && rect.top < fold);
|
|
597
|
+
if (reduce || inView || typeof IntersectionObserver !== "function") {
|
|
598
|
+
el.classList.add("is-in");
|
|
599
|
+
} else {
|
|
600
|
+
el.classList.add("is-below");
|
|
601
|
+
below.push(el);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (below.length) {
|
|
593
605
|
const io = new IntersectionObserver(
|
|
594
606
|
(entries) => {
|
|
595
607
|
for (const e of entries) {
|
|
@@ -601,9 +613,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
601
613
|
},
|
|
602
614
|
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
|
603
615
|
);
|
|
604
|
-
|
|
605
|
-
} else {
|
|
606
|
-
els.forEach((el) => el.classList.add("is-in"));
|
|
616
|
+
for (const el of below) io.observe(el);
|
|
607
617
|
}
|
|
608
618
|
</script>
|
|
609
619
|
|
|
@@ -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,
|
|
285
|
-
const id =
|
|
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${
|
|
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!,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
282
|
+
{fields.cta && (
|
|
264
283
|
<div class="mt-7">
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
368
|
+
return (
|
|
369
|
+
<div data-block={r.blockId}>
|
|
370
|
+
<Faq block={r.block} />
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
341
373
|
}
|
|
342
374
|
return null;
|
|
343
375
|
})
|
|
@@ -335,18 +335,30 @@
|
|
|
335
335
|
h2:is(.display-xl, .display-lg, .display-md) { font-size: var(--heading-h2, var(--display-size)); }
|
|
336
336
|
h3:is(.display-xl, .display-lg, .display-md) { font-size: var(--heading-h3, var(--display-size)); }
|
|
337
337
|
|
|
338
|
-
/*
|
|
338
|
+
/* Scroll-reveal only after the script marks a block below the fold.
|
|
339
|
+
An unmarked [data-reveal] stays at opacity 1, so a block already in the
|
|
340
|
+
initial viewport is visible with no IntersectionObserver and no tenant flag. */
|
|
339
341
|
[data-reveal] {
|
|
342
|
+
opacity: 1;
|
|
343
|
+
}
|
|
344
|
+
[data-reveal].is-below {
|
|
345
|
+
transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
|
346
|
+
}
|
|
347
|
+
[data-reveal].is-below:not(.is-in) {
|
|
340
348
|
opacity: 0;
|
|
341
349
|
transform: translateY(14px);
|
|
342
|
-
transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
|
343
350
|
}
|
|
344
351
|
[data-reveal].is-in {
|
|
345
352
|
opacity: 1;
|
|
346
353
|
transform: none;
|
|
347
354
|
}
|
|
348
355
|
@media (prefers-reduced-motion: reduce) {
|
|
349
|
-
[data-reveal]
|
|
356
|
+
[data-reveal],
|
|
357
|
+
[data-reveal].is-below:not(.is-in) {
|
|
358
|
+
opacity: 1;
|
|
359
|
+
transform: none;
|
|
360
|
+
transition: none;
|
|
361
|
+
}
|
|
350
362
|
}
|
|
351
363
|
|
|
352
364
|
/* Island animations (MobileNav drawer slide-in). Globally neutralized by the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "3.12.
|
|
3
|
+
"version": "3.12.4",
|
|
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);
|