@tokenoftrust/storefront-runner 4.4.0 → 4.6.0
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/agency-kit/tools/lib/catalog-model/files.mjs +30 -3
- package/agency-kit/tools/lib/catalog-model/sku.d.mts +16 -0
- package/agency-kit/tools/lib/catalog-model/sku.mjs +146 -0
- package/apps/storefront/src/components/catalog/storefront/plp/FacetSidebar.astro +29 -13
- package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +20 -2
- package/apps/storefront/src/layouts/Layout.astro +20 -2
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +9 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +120 -29
- package/apps/storefront/src/lib/content/shared/rawChrome.ts +15 -2
- package/apps/storefront/src/lib/content/shared/rawMarketingHtml.ts +26 -0
- package/apps/storefront/src/lib/inventory/allocations.ts +3 -3
- package/apps/storefront/src/lib/inventory/mirrorStock.ts +3 -1
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +342 -0
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +1 -1
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +10 -4
- package/apps/storefront/src/lib/orders/data/ordersStore.ts +48 -0
- package/apps/storefront/src/lib/publication/shared/url.ts +19 -0
- package/apps/storefront/src/middleware/renderChain.ts +3 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +15 -30
- package/apps/storefront/src/pages/api/admin/orders.ts +15 -32
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +39 -15
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +12 -2
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +43 -7
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/src/checkout.ts +13 -3
- package/packages/public-runtime/src/preview-url.d.mts +10 -0
- package/packages/public-runtime/src/preview-url.mjs +54 -0
- package/packages/public-runtime/src/sku.ts +19 -0
- package/packages/public-runtime/src/tenant.ts +3 -0
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* - `created_at` / `updated_at` / `migrated_at` / `source_checksum` are optional.
|
|
19
19
|
* - `variants[].inventory` is never read, and writers leave it out. Availability is hot data and
|
|
20
20
|
* lives behind the store's declared `inventory.provider`, never in a content file.
|
|
21
|
+
* - `variants[].sku` follows the SKU contract (`./sku.mjs`): reader and writer trim it, a blank
|
|
22
|
+
* one is no SKU, and one with a control character or over the length cap refuses the file.
|
|
21
23
|
*/
|
|
22
24
|
import { validateRecord } from "./model.mjs";
|
|
25
|
+
import { parseSku, skuFindings } from "./sku.mjs";
|
|
23
26
|
|
|
24
27
|
/** Store-relative directories, one per record kind. */
|
|
25
28
|
export const CATALOG_CONTENT_DIRS = {
|
|
@@ -66,14 +69,37 @@ export function classifyCatalogPath(relative) {
|
|
|
66
69
|
|
|
67
70
|
// --- Writing ----------------------------------------------------------------------------------
|
|
68
71
|
|
|
69
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* A variant's SKU in its stored form: trimmed, a blank one removed. A SKU the contract refuses is
|
|
74
|
+
* left as written so the validator names it.
|
|
75
|
+
*/
|
|
76
|
+
function storedSkuVariant(variant) {
|
|
77
|
+
if (!isObject(variant) || typeof variant.sku !== "string") return variant;
|
|
78
|
+
const parsed = parseSku(variant.sku);
|
|
79
|
+
if (parsed.ok) return parsed.sku === variant.sku ? variant : { ...variant, sku: parsed.sku };
|
|
80
|
+
if (parsed.reason !== "empty") return variant;
|
|
81
|
+
const { sku: _blank, ...rest } = variant;
|
|
82
|
+
return rest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every refused variant SKU in a product record, as validator issues. */
|
|
86
|
+
function skuIssues(record) {
|
|
87
|
+
if (!isObject(record) || !Array.isArray(record.variants)) return [];
|
|
88
|
+
return record.variants.flatMap((variant, index) =>
|
|
89
|
+
isObject(variant) && variant.sku !== undefined
|
|
90
|
+
? skuFindings(variant.sku).filter((f) => f.level === "error").map((f) => `variants[${index}].sku: ${f.message}`)
|
|
91
|
+
: [],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A record as its file holds it: every variant's `inventory` left out, every SKU stored form. */
|
|
70
96
|
export function catalogFileRecord(kind, record) {
|
|
71
97
|
if (kind !== "product" || !isObject(record) || !Array.isArray(record.variants)) return record;
|
|
72
98
|
return {
|
|
73
99
|
...record,
|
|
74
100
|
variants: record.variants.map((variant) => {
|
|
75
101
|
if (!isObject(variant)) return variant;
|
|
76
|
-
const { inventory: _inventory, ...rest } = variant;
|
|
102
|
+
const { inventory: _inventory, ...rest } = storedSkuVariant(variant);
|
|
77
103
|
return rest;
|
|
78
104
|
}),
|
|
79
105
|
};
|
|
@@ -157,7 +183,7 @@ function stamp(kind, doc, tenantScope, availability) {
|
|
|
157
183
|
const productId = typeof doc.id === "string" ? doc.id : "";
|
|
158
184
|
out.variants = doc.variants.map((variant) =>
|
|
159
185
|
isObject(variant)
|
|
160
|
-
? { ...variant, tenant_id: tenantScope, product_id: productId, inventory: { ...availability } }
|
|
186
|
+
? { ...storedSkuVariant(variant), tenant_id: tenantScope, product_id: productId, inventory: { ...availability } }
|
|
161
187
|
: variant,
|
|
162
188
|
);
|
|
163
189
|
}
|
|
@@ -185,6 +211,7 @@ function parse(relative, raw, tenantScope, availability) {
|
|
|
185
211
|
const issues = validateRecord(where.kind === "product" ? "CatalogProduct" : "CatalogCollection", record).map(
|
|
186
212
|
(i) => (i.path ? `${i.path}: ${i.message}` : i.message),
|
|
187
213
|
);
|
|
214
|
+
if (where.kind === "product") issues.push(...skuIssues(record));
|
|
188
215
|
if (isObject(doc) && typeof doc.handle === "string" && doc.handle !== where.handle) {
|
|
189
216
|
issues.push(`handle: "${doc.handle}" does not match the file name "${where.handle}.json"`);
|
|
190
217
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const SKU_MAX_LENGTH: number;
|
|
2
|
+
export const CHECKOUT_CODE_MAX_LENGTH: number;
|
|
3
|
+
export const SKU_TOKEN_MAX_LENGTH: number;
|
|
4
|
+
export type SkuRefusal = "not-string" | "empty" | "control-character" | "too-long";
|
|
5
|
+
export function parseSku(value: unknown): { ok: true; sku: string } | { ok: false; reason: SkuRefusal };
|
|
6
|
+
export function isStoredSku(value: unknown): value is string;
|
|
7
|
+
export function skuKey(sku: string): string;
|
|
8
|
+
export function skuToken(sku: string): string;
|
|
9
|
+
export function skuFromToken(token: unknown): string | null;
|
|
10
|
+
export interface SkuFinding {
|
|
11
|
+
level: "error" | "warning";
|
|
12
|
+
code: string;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
export function skuFindings(value: unknown): SkuFinding[];
|
|
16
|
+
export function skuCollisions<T extends { sku: string }>(entries: Iterable<T>): Array<{ key: string; entries: T[] }>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SKU contract: one rule for every reader and writer of a merchant's SKU.
|
|
3
|
+
*
|
|
4
|
+
* A SKU is merchant data, kept exactly as the merchant wrote it. The only rewrite is trimming
|
|
5
|
+
* leading and trailing whitespace on the way in. A SKU is refused only when it is empty, carries a
|
|
6
|
+
* control character, or is longer than {@link SKU_MAX_LENGTH} characters. Anything else, a space
|
|
7
|
+
* included, is a valid SKU; a space is a lint warning, never an error.
|
|
8
|
+
*
|
|
9
|
+
* Two SKUs are the same SKU when their {@link skuKey} is equal: trimmed, NBSP read as a space, and
|
|
10
|
+
* case-folded. The key is for comparison only and is never stored.
|
|
11
|
+
*
|
|
12
|
+
* A SKU is never used raw where a boundary needs a safe token (a path, a file name, a URL segment,
|
|
13
|
+
* a checkout product code). {@link skuToken} is the one encoding for those boundaries: reversible,
|
|
14
|
+
* injective, and the identity for a SKU of letters, digits, `.`, `_` and `-`.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no filesystem, no network, no dependencies. `packages/cli/src/sku.mjs` is a byte-identical
|
|
17
|
+
* copy (the CLI installs without the kit), pinned by `packages/cli/test/sku-copy.test.mjs`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Longest SKU accepted, in characters (code points). */
|
|
21
|
+
export const SKU_MAX_LENGTH = 250;
|
|
22
|
+
/** The checkout provider's product-code limit; a longer {@link skuToken} is a lint warning. */
|
|
23
|
+
export const CHECKOUT_CODE_MAX_LENGTH = 200;
|
|
24
|
+
/** Longest {@link skuToken} any SKU can produce (four UTF-8 bytes, three characters each). */
|
|
25
|
+
export const SKU_TOKEN_MAX_LENGTH = SKU_MAX_LENGTH * 12;
|
|
26
|
+
|
|
27
|
+
const CONTROL = /\p{Cc}/u;
|
|
28
|
+
const WHITESPACE = /\s/u;
|
|
29
|
+
const TOKEN_SAFE = /^[A-Za-z0-9._-]$/;
|
|
30
|
+
const TOKEN = /^(?:[A-Za-z0-9._-]|%[0-9A-F]{2})+$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a SKU as it arrives from a merchant, a file or an import.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} value
|
|
36
|
+
* @returns {{ ok: true, sku: string } | { ok: false, reason: "not-string" | "empty" | "control-character" | "too-long" }}
|
|
37
|
+
*/
|
|
38
|
+
export function parseSku(value) {
|
|
39
|
+
if (typeof value !== "string") return { ok: false, reason: "not-string" };
|
|
40
|
+
const sku = value.trim();
|
|
41
|
+
if (sku === "") return { ok: false, reason: "empty" };
|
|
42
|
+
if (CONTROL.test(sku)) return { ok: false, reason: "control-character" };
|
|
43
|
+
if ([...sku].length > SKU_MAX_LENGTH) return { ok: false, reason: "too-long" };
|
|
44
|
+
return { ok: true, sku };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when `value` is a SKU in its stored form: valid and already trimmed. */
|
|
48
|
+
export function isStoredSku(value) {
|
|
49
|
+
const parsed = parseSku(value);
|
|
50
|
+
return parsed.ok && parsed.sku === value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The comparison key two SKUs share when they are the same SKU. Never stored. */
|
|
54
|
+
export function skuKey(sku) {
|
|
55
|
+
return String(sku).replace(/ /g, " ").trim().toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The SKU as a token safe in a path, file name, URL segment or checkout product code. Letters,
|
|
60
|
+
* digits, `.`, `_` and `-` pass through; every other character is its UTF-8 bytes as `%XX`.
|
|
61
|
+
*/
|
|
62
|
+
export function skuToken(sku) {
|
|
63
|
+
let out = "";
|
|
64
|
+
for (const char of String(sku)) {
|
|
65
|
+
if (TOKEN_SAFE.test(char)) {
|
|
66
|
+
out += char;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const byte of new TextEncoder().encode(char)) out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The stored SKU a {@link skuToken} encodes, or null when `token` is not one. */
|
|
75
|
+
export function skuFromToken(token) {
|
|
76
|
+
if (typeof token !== "string" || token.length > SKU_TOKEN_MAX_LENGTH || !TOKEN.test(token)) return null;
|
|
77
|
+
const bytes = [];
|
|
78
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
79
|
+
if (token[i] === "%") {
|
|
80
|
+
bytes.push(Number.parseInt(token.slice(i + 1, i + 3), 16));
|
|
81
|
+
i += 2;
|
|
82
|
+
} else {
|
|
83
|
+
bytes.push(token.charCodeAt(i));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Bytes that are not UTF-8 decode to U+FFFD, whose token differs, so the round trip refuses them.
|
|
87
|
+
const sku = new TextDecoder().decode(new Uint8Array(bytes));
|
|
88
|
+
return isStoredSku(sku) && skuToken(sku) === token ? sku : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const REASONS = {
|
|
92
|
+
"not-string": "a SKU must be text",
|
|
93
|
+
empty: "a SKU cannot be empty",
|
|
94
|
+
"control-character": "a SKU cannot contain a control character (tab, newline, ...)",
|
|
95
|
+
"too-long": `a SKU is at most ${SKU_MAX_LENGTH} characters`,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Lint findings for one SKU. `error` means the SKU is refused; `warning` means it works.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @returns {Array<{ level: "error" | "warning", code: string, message: string }>}
|
|
103
|
+
*/
|
|
104
|
+
export function skuFindings(value) {
|
|
105
|
+
const parsed = parseSku(value);
|
|
106
|
+
if (!parsed.ok) return [{ level: "error", code: `sku-${parsed.reason}`, message: REASONS[parsed.reason] }];
|
|
107
|
+
/** @type {Array<{ level: "error" | "warning", code: string, message: string }>} */
|
|
108
|
+
const findings = [];
|
|
109
|
+
if (parsed.sku !== value) {
|
|
110
|
+
findings.push({ level: "warning", code: "sku-untrimmed", message: "leading or trailing whitespace is trimmed when the SKU is read" });
|
|
111
|
+
}
|
|
112
|
+
if (WHITESPACE.test(parsed.sku)) {
|
|
113
|
+
findings.push({
|
|
114
|
+
level: "warning",
|
|
115
|
+
code: "sku-whitespace",
|
|
116
|
+
message: "works, but we recommend against spaces in a SKU: spreadsheets, scanners and other systems often trim or split on them",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (skuToken(parsed.sku).length > CHECKOUT_CODE_MAX_LENGTH) {
|
|
120
|
+
findings.push({
|
|
121
|
+
level: "warning",
|
|
122
|
+
code: "sku-checkout-code-long",
|
|
123
|
+
message: `its checkout product code is longer than ${CHECKOUT_CODE_MAX_LENGTH} characters, which the checkout provider may refuse`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return findings;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* SKUs that are the same SKU under {@link skuKey}. Each group lists every entry that shares a key.
|
|
131
|
+
*
|
|
132
|
+
* @template {{ sku: string }} T
|
|
133
|
+
* @param {Iterable<T>} entries
|
|
134
|
+
* @returns {Array<{ key: string, entries: T[] }>}
|
|
135
|
+
*/
|
|
136
|
+
export function skuCollisions(entries) {
|
|
137
|
+
/** @type {Map<string, T[]>} */
|
|
138
|
+
const groups = new Map();
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
const key = skuKey(entry.sku);
|
|
141
|
+
const group = groups.get(key);
|
|
142
|
+
if (group) group.push(entry);
|
|
143
|
+
else groups.set(key, [entry]);
|
|
144
|
+
}
|
|
145
|
+
return [...groups].filter(([, group]) => group.length > 1).map(([key, group]) => ({ key, entries: group }));
|
|
146
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Encoding handled by src/lib/publication/shared/url.ts (opt.<Name>=<value>, tag=, price, avail).
|
|
8
8
|
*/
|
|
9
9
|
import type { Facet, ListProductsQuery } from "@tot/public-runtime";
|
|
10
|
-
import { toggleFacet, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/publication/shared/url";
|
|
10
|
+
import { toggleFacet, splitFacetValues, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/publication/shared/url";
|
|
11
11
|
import { PRICE_BUCKETS, bucketByKey } from "@/lib/catalog/shared/priceBuckets";
|
|
12
12
|
|
|
13
13
|
interface Props {
|
|
@@ -52,17 +52,29 @@ const clearHref =
|
|
|
52
52
|
|
|
53
53
|
<div class="space-y-7">
|
|
54
54
|
{
|
|
55
|
-
ordered.map((facet) =>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
ordered.map((facet) => {
|
|
56
|
+
const hasSelected = facet.values.some((fv) => selected(facet.name, fv.value));
|
|
57
|
+
const label =
|
|
58
|
+
facet.name === "tags" ? "Tags" : facet.name === "availability" ? "Availability" : facet.name;
|
|
59
|
+
// The first values (plus any selected one) are listed; the rest sit in a nested
|
|
60
|
+
// "Show more" <details>. A selected value is never in the remainder.
|
|
61
|
+
const { shown, rest } = splitFacetValues(facet.values, (v) => selected(facet.name, v));
|
|
62
|
+
const segments = [
|
|
63
|
+
{ more: false, values: shown },
|
|
64
|
+
...(rest.length > 0 ? [{ more: true, values: rest }] : []),
|
|
65
|
+
];
|
|
66
|
+
return (
|
|
67
|
+
<details open={hasSelected} data-facet-group={facet.name}>
|
|
68
|
+
<summary class="mb-2.5 cursor-pointer font-display text-[0.95rem] font-medium">
|
|
69
|
+
{label} ({facet.values.length})
|
|
70
|
+
</summary>
|
|
71
|
+
{segments.map((seg) => {
|
|
72
|
+
const Wrap = seg.more ? "details" : Fragment;
|
|
73
|
+
return (
|
|
74
|
+
<Wrap>
|
|
75
|
+
{seg.more && <summary class="cursor-pointer py-1 text-xs text-[var(--color-muted)]">Show more</summary>}
|
|
64
76
|
<ul class="space-y-1.5">
|
|
65
|
-
{
|
|
77
|
+
{seg.values.map((fv) => {
|
|
66
78
|
const isSel = selected(facet.name, fv.value);
|
|
67
79
|
const href =
|
|
68
80
|
basePath + toggleFacet(query, facet.name, fv.value);
|
|
@@ -101,8 +113,12 @@ const clearHref =
|
|
|
101
113
|
);
|
|
102
114
|
})}
|
|
103
115
|
</ul>
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
</Wrap>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</details>
|
|
120
|
+
);
|
|
121
|
+
})
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
{/* Price range — bucketed quick-select (live counts) + a free-text form
|
|
@@ -64,6 +64,12 @@ interface Props {
|
|
|
64
64
|
*/
|
|
65
65
|
layout?: HeaderVariant;
|
|
66
66
|
sticky?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Render the brand link inside `<h1 class="brand-h1">` (margin:0; font:inherit).
|
|
69
|
+
* The caller sets it on the home route only, and only when the page content
|
|
70
|
+
* supplies no h1 of its own — exactly one h1 per page.
|
|
71
|
+
*/
|
|
72
|
+
brandAsH1?: boolean;
|
|
67
73
|
class?: string;
|
|
68
74
|
}
|
|
69
75
|
|
|
@@ -85,6 +91,7 @@ const {
|
|
|
85
91
|
variant = "light",
|
|
86
92
|
layout = "primary",
|
|
87
93
|
sticky = true,
|
|
94
|
+
brandAsH1 = false,
|
|
88
95
|
class: className,
|
|
89
96
|
} = Astro.props;
|
|
90
97
|
|
|
@@ -96,7 +103,16 @@ const dark = variant === "dark";
|
|
|
96
103
|
const minimal = layout === "minimal";
|
|
97
104
|
const compact = layout === "compact-centered";
|
|
98
105
|
|
|
99
|
-
|
|
106
|
+
// Home brand heading: the brand link sits inside <h1 class="brand-h1"> (look unchanged);
|
|
107
|
+
// the compact layout's positioning classes move to the h1, which is then the grid child.
|
|
108
|
+
const brandCompactClass =
|
|
109
|
+
"absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center";
|
|
110
|
+
const BrandWrap = brandAsH1 ? "h1" : Fragment;
|
|
111
|
+
const brandWrapProps = brandAsH1
|
|
112
|
+
? { class: ["brand-h1", compact && brandCompactClass].filter(Boolean).join(" "), style: "margin:0;font:inherit" }
|
|
113
|
+
: {};
|
|
114
|
+
|
|
115
|
+
const ctaClass =(v?: string) =>
|
|
100
116
|
v === "secondary" || v === "ghost"
|
|
101
117
|
? "inline-flex items-center rounded-full border border-current px-4 py-2 font-display text-sm font-medium transition-opacity hover:opacity-80"
|
|
102
118
|
: "inline-flex items-center rounded-full bg-accent px-4 py-2 font-display text-sm font-medium text-accent-contrast transition-transform hover:-translate-y-0.5";
|
|
@@ -151,11 +167,12 @@ const currentPath = Astro.url.pathname;
|
|
|
151
167
|
)
|
|
152
168
|
}
|
|
153
169
|
<!-- Logo / wordmark -->
|
|
170
|
+
<BrandWrap {...brandWrapProps}>
|
|
154
171
|
<a
|
|
155
172
|
href={homeHref}
|
|
156
173
|
class:list={[
|
|
157
174
|
"flex shrink-0 items-center transition-opacity hover:opacity-80",
|
|
158
|
-
|
|
175
|
+
!brandAsH1 && compact && brandCompactClass,
|
|
159
176
|
]}
|
|
160
177
|
aria-label={logo?.alt ?? wordmark ?? "Home"}
|
|
161
178
|
>
|
|
@@ -203,6 +220,7 @@ const currentPath = Astro.url.pathname;
|
|
|
203
220
|
)
|
|
204
221
|
}
|
|
205
222
|
</a>
|
|
223
|
+
</BrandWrap>
|
|
206
224
|
|
|
207
225
|
<!-- Search (live typeahead island) -->
|
|
208
226
|
{
|
|
@@ -29,7 +29,14 @@ import { organizationLd, websiteLd } from "@/lib/content/shared/jsonld";
|
|
|
29
29
|
import { composeStructuredData, structuredDataProfile, type StructuredDataPage } from "@/lib/content/seo/structuredData";
|
|
30
30
|
import { canonicalUrl } from "@/lib/content/seo/urlForm";
|
|
31
31
|
import { documentTitle } from "@/lib/content/seo/documentTitle";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
nonceInlineTags,
|
|
34
|
+
prefixSharedChrome,
|
|
35
|
+
CHROME_BODY_MARKER,
|
|
36
|
+
demoteBrandH1,
|
|
37
|
+
hasContentH1,
|
|
38
|
+
isHomePath,
|
|
39
|
+
} from "@/lib/content/shared/rawMarketingHtml";
|
|
33
40
|
import { withBase } from "@/lib/publication/shared/basePath";
|
|
34
41
|
import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/content/shared/rawChrome";
|
|
35
42
|
import { ASYNC_CSS_SWAP_JS } from "@/lib/content/shared/asyncCss";
|
|
@@ -218,6 +225,16 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
|
|
|
218
225
|
};
|
|
219
226
|
}
|
|
220
227
|
|
|
228
|
+
// One-h1 contract: on the home route the header brand is the page's <h1> unless the
|
|
229
|
+
// page content supplies its own (then the brand stays a plain link). Only home
|
|
230
|
+
// renders the slot to a string to inspect it.
|
|
231
|
+
const isHome = isHomePath(path);
|
|
232
|
+
const homeSlotHtml = isHome ? await Astro.slots.render("default") : null;
|
|
233
|
+
const brandIsH1 = isHome && !hasContentH1(homeSlotHtml ?? "");
|
|
234
|
+
if (sharedChrome && !brandIsH1) {
|
|
235
|
+
sharedChrome.header = demoteBrandH1(sharedChrome.header);
|
|
236
|
+
}
|
|
237
|
+
|
|
221
238
|
const compliance = tenant.compliance;
|
|
222
239
|
const sections = complianceSections({
|
|
223
240
|
compliance,
|
|
@@ -395,6 +412,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
395
412
|
nav={chrome.header.nav}
|
|
396
413
|
variant={headerDark ? "dark" : "light"}
|
|
397
414
|
layout={resolvedHeaderVariant}
|
|
415
|
+
brandAsH1={brandIsH1}
|
|
398
416
|
announcement={chrome.header.announcement?.messages[0]}
|
|
399
417
|
searchAction={
|
|
400
418
|
features.productSearch
|
|
@@ -415,7 +433,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
415
433
|
)}
|
|
416
434
|
|
|
417
435
|
<main id="main">
|
|
418
|
-
<slot />
|
|
436
|
+
{homeSlotHtml !== null ? <Fragment set:html={homeSlotHtml} /> : <slot />}
|
|
419
437
|
</main>
|
|
420
438
|
|
|
421
439
|
{sections.includes("pact-act") && (
|
|
@@ -30,6 +30,15 @@ export interface SeamView {
|
|
|
30
30
|
codes?: readonly string[];
|
|
31
31
|
/** Flavor labels that triggered a `flavored_prohibited` block, for the refusal. */
|
|
32
32
|
flavors?: readonly string[];
|
|
33
|
+
/** Every refused block with the compliance engine's own shopper-facing sentence. */
|
|
34
|
+
lines?: readonly RefusedLine[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RefusedLine {
|
|
38
|
+
code: string;
|
|
39
|
+
message: string;
|
|
40
|
+
sku?: string;
|
|
41
|
+
title?: string;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export interface FinalCartRequest {
|