@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.
- package/apps/storefront/public/shared/commerce-chrome.css +3 -0
- 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/config/tenants.ts +16 -6
- package/apps/storefront/src/layouts/Layout.astro +16 -5
- package/apps/storefront/src/lib/checkout/commerce/cardCartForm.ts +85 -0
- package/apps/storefront/src/lib/compliance/complianceSections.ts +113 -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/[...slug].astro +9 -3
- package/apps/storefront/src/pages/index.astro +65 -27
- 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
|
|
|
@@ -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
|
|
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
|
}
|
|
@@ -41,10 +41,12 @@ export const HOME_TENANT: TenantConfig = {
|
|
|
41
41
|
siteType: "marketing",
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
// Generic SAMPLE tenant —
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
44
|
+
// Generic SAMPLE tenant — the public fallback renderer's regulated store.
|
|
45
|
+
// Its page bands come from the same complianceSections() gates as a primary
|
|
46
|
+
// vape-nicotine-us tenant (nicotine warning, shipping restriction, age gate,
|
|
47
|
+
// excise disclosure). No real merchant. Routed at /sample-store.example/.
|
|
48
|
+
// Content comes from the developer's grafted checkout
|
|
49
|
+
// (tenants/sample-store.example/); with none present it degrades to the
|
|
48
50
|
// default scope, and the compliance overlay still renders from this config.
|
|
49
51
|
export const SAMPLE_TENANT: TenantConfig = {
|
|
50
52
|
tenant_id: "sample-store.example",
|
|
@@ -62,14 +64,22 @@ export const SAMPLE_TENANT: TenantConfig = {
|
|
|
62
64
|
repo: "sample-store.example",
|
|
63
65
|
defaultBranch: "main",
|
|
64
66
|
})],
|
|
65
|
-
//
|
|
67
|
+
// Same section inputs as a primary vape-nicotine-us commerce tenant.
|
|
68
|
+
// pactAct stays unset: the primary page does not emit that band unless the
|
|
69
|
+
// flag is set. ruleProfile selects the checkout ruleset only.
|
|
66
70
|
compliance: {
|
|
71
|
+
ruleProfile: "vape-nicotine-us",
|
|
67
72
|
minAge: 21,
|
|
68
73
|
nicotineWarning: true,
|
|
69
74
|
shippingRestriction:
|
|
70
75
|
"Adult signature (21+) required on delivery. Some destinations are restricted — verified at checkout.",
|
|
71
76
|
},
|
|
72
|
-
|
|
77
|
+
capabilities: {
|
|
78
|
+
cartCheckout: { enabled: true },
|
|
79
|
+
ageVerification: { enabled: true },
|
|
80
|
+
exciseTax: { enabled: true },
|
|
81
|
+
},
|
|
82
|
+
siteType: "commerce",
|
|
73
83
|
};
|
|
74
84
|
|
|
75
85
|
const REGISTRY: TenantConfig[] = [HOME_TENANT, SAMPLE_TENANT];
|
|
@@ -20,6 +20,7 @@ import PactActNotice from "@/components/compliance/storefront/compliance/PactAct
|
|
|
20
20
|
import ShippingRestrictionNotice from "@/components/compliance/storefront/compliance/ShippingRestrictionNotice.astro";
|
|
21
21
|
import ExciseTaxNotice from "@/components/compliance/storefront/compliance/ExciseTaxNotice.astro";
|
|
22
22
|
import { EXCISE_TAX_DISCLOSURE_TEXT } from "@/lib/compliance/rawComplianceNotices";
|
|
23
|
+
import { complianceSections } from "@/lib/compliance/complianceSections";
|
|
23
24
|
import IsolatedAgeGate from "@/components/compliance/storefront/islands/IsolatedAgeGate.tsx";
|
|
24
25
|
import AuthBadge from "@/components/access/storefront/auth/AuthBadge.astro";
|
|
25
26
|
import { referenceTheme } from "@/themes/reference";
|
|
@@ -215,6 +216,12 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
|
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
const compliance = tenant.compliance;
|
|
219
|
+
const sections = complianceSections({
|
|
220
|
+
compliance,
|
|
221
|
+
ageVerificationEnabled: capabilities.ageVerification.enabled,
|
|
222
|
+
exciseTaxEnabled: capabilities.exciseTax.enabled,
|
|
223
|
+
catalog: features.catalog,
|
|
224
|
+
});
|
|
218
225
|
const cartHref = managedCartHref(Astro.locals.commerce);
|
|
219
226
|
|
|
220
227
|
// Site-chrome data resolution (chrome/* is the canonical, prop-driven
|
|
@@ -368,7 +375,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
368
375
|
|
|
369
376
|
<AuthBadge />
|
|
370
377
|
|
|
371
|
-
{compliance?.nicotineWarning && <NicotineWarning variant="bar" declaration={compliance.nicotineWarning} />}
|
|
378
|
+
{sections.includes("nicotine-warning") && compliance?.nicotineWarning && <NicotineWarning variant="bar" declaration={compliance.nicotineWarning} />}
|
|
372
379
|
|
|
373
380
|
{sharedChrome ? (
|
|
374
381
|
<Fragment set:html={sharedChrome.header} />
|
|
@@ -406,8 +413,12 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
406
413
|
<slot />
|
|
407
414
|
</main>
|
|
408
415
|
|
|
409
|
-
|
|
410
|
-
|
|
416
|
+
{sections.includes("pact-act") && (
|
|
417
|
+
<PactActNotice enabled={compliance?.pactAct} minAge={compliance?.minAge} variant="footer" />
|
|
418
|
+
)}
|
|
419
|
+
{sections.includes("shipping-restriction") && (
|
|
420
|
+
<ShippingRestrictionNotice text={compliance?.shippingRestriction} />
|
|
421
|
+
)}
|
|
411
422
|
|
|
412
423
|
{sharedChrome ? (
|
|
413
424
|
<>
|
|
@@ -460,7 +471,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
460
471
|
</div>
|
|
461
472
|
))}
|
|
462
473
|
|
|
463
|
-
{capabilities.ageVerification.enabled && compliance?.minAge && (
|
|
474
|
+
{sections.includes("age-gate") && capabilities.ageVerification.enabled && compliance?.minAge && (
|
|
464
475
|
<IsolatedAgeGate
|
|
465
476
|
client:only="preact"
|
|
466
477
|
minAge={compliance.minAge}
|
|
@@ -477,7 +488,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
477
488
|
{/* Excise tax (Track B) — when enabled for this environment, the excise line
|
|
478
489
|
is calculated + added inside the managed cart. This is the storefront's
|
|
479
490
|
honest shopper-facing signal of it. */}
|
|
480
|
-
{capabilities.exciseTax.enabled && features.catalog && (
|
|
491
|
+
{sections.includes("excise-tax") && capabilities.exciseTax.enabled && features.catalog && (
|
|
481
492
|
<ExciseTaxNotice variant="footer" config={{ note: EXCISE_TAX_DISCLOSURE_TEXT }} />
|
|
482
493
|
)}
|
|
483
494
|
|
|
@@ -0,0 +1,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,
|
|
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"]>([
|
|
@@ -14,7 +14,7 @@ import { breadcrumbLd } from "@/lib/content/shared/jsonld";
|
|
|
14
14
|
import { truncate } from "@/lib/content/shared/format";
|
|
15
15
|
import { faqFromHtml, faqFromSections } from "@/lib/content/seo/faqItems";
|
|
16
16
|
import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/content/shared/rawMarketingHtml";
|
|
17
|
-
import {
|
|
17
|
+
import { complianceSections, rawComplianceChrome } from "@/lib/compliance/complianceSections";
|
|
18
18
|
import { styleGuideAliasRewrite } from "@/lib/content/shared/styleGuidePaths";
|
|
19
19
|
import { getBlogSettings } from "@/lib/content/blog/provider.js";
|
|
20
20
|
import { loadBlogPostViewV1, type BlogPostViewPropsV1 } from "@/lib/content/blog/postView.js";
|
|
@@ -85,6 +85,12 @@ if (!blogPostProps) {
|
|
|
85
85
|
shippingRestriction: tenant.compliance?.shippingRestriction,
|
|
86
86
|
exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
|
|
87
87
|
};
|
|
88
|
+
const complianceChrome = rawComplianceChrome(complianceSections({
|
|
89
|
+
compliance: tenant.compliance,
|
|
90
|
+
ageVerificationEnabled: capabilities.ageVerification.enabled,
|
|
91
|
+
exciseTaxEnabled: capabilities.exciseTax.enabled,
|
|
92
|
+
catalog: features.catalog,
|
|
93
|
+
}), notices);
|
|
88
94
|
return rawHtmlResponse(rawHtml, {
|
|
89
95
|
nonce: Astro.locals.cspNonce,
|
|
90
96
|
basePath,
|
|
@@ -93,8 +99,8 @@ if (!blogPostProps) {
|
|
|
93
99
|
plan: Astro.locals.integrationPlan,
|
|
94
100
|
consentSnapshot: Astro.locals.consentSnapshot,
|
|
95
101
|
canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
|
|
96
|
-
complianceTop:
|
|
97
|
-
complianceBottom:
|
|
102
|
+
complianceTop: complianceChrome.top || undefined,
|
|
103
|
+
complianceBottom: complianceChrome.bottom || undefined,
|
|
98
104
|
});
|
|
99
105
|
}
|
|
100
106
|
|
|
@@ -20,7 +20,7 @@ import MarketingBlocks from "@/components/content/storefront/marketing/Marketing
|
|
|
20
20
|
import TierCards from "@/components/membership/storefront/membership/TierCards.astro";
|
|
21
21
|
import { withBase } from "@/lib/publication/shared/basePath";
|
|
22
22
|
import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/content/shared/rawMarketingHtml";
|
|
23
|
-
import {
|
|
23
|
+
import { complianceSections, rawComplianceChrome } from "@/lib/compliance/complianceSections";
|
|
24
24
|
import AppWidgetFrame from "@/components/integrations/storefront/apps/AppWidgetFrame.astro";
|
|
25
25
|
import { getRegistryService } from "@/lib/integrations/apps/registryAccess.js";
|
|
26
26
|
import { renderWidgetSlot } from "@/lib/integrations/apps/widgets/renderSlot.js";
|
|
@@ -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:
|
|
85
|
-
complianceBottom:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
282
|
+
{fields.cta && (
|
|
258
283
|
<div class="mt-7">
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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.
|
|
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);
|