@tokenoftrust/storefront-runner 4.3.4 → 4.5.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/apps/storefront/src/components/catalog/storefront/ProductCard.astro +19 -2
- 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/cardCartForm.ts +11 -3
- package/apps/storefront/src/lib/checkout/commerce/cartSession.ts +21 -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/publication/shared/url.ts +19 -0
- package/apps/storefront/src/middleware/renderChain.ts +3 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +58 -19
- package/apps/storefront/src/pages/cart.ts +31 -9
- 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 +1 -0
- package/packages/public-runtime/src/checkout.ts +9 -1
- 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/tenant.ts +3 -0
|
@@ -16,7 +16,8 @@ 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
|
+
import { buildCardCartForm, cardNeedsOptionChoice } from "@/lib/checkout/commerce/cardCartForm";
|
|
20
|
+
import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
|
|
20
21
|
|
|
21
22
|
interface Props {
|
|
22
23
|
product: CatalogProduct;
|
|
@@ -95,6 +96,10 @@ const cardCart = await buildCardCartForm({
|
|
|
95
96
|
environment: browserEnvironment.environment,
|
|
96
97
|
contentVersionId,
|
|
97
98
|
});
|
|
99
|
+
// A product with options is chosen on its product page, never from the card.
|
|
100
|
+
const productHref = withBase(basePath, pageUrlForm(`/products/${product.handle}`, tenant.seo));
|
|
101
|
+
const selectOptions = available && cardNeedsOptionChoice(product) &&
|
|
102
|
+
(!!commerce || isLocalSampleCheckout(tenant.tenant_id));
|
|
98
103
|
|
|
99
104
|
const quickview = {
|
|
100
105
|
...snapshot,
|
|
@@ -201,7 +206,7 @@ const quickview = {
|
|
|
201
206
|
{/* `py-3` on an inline box raises the tap target to the 44px minimum
|
|
202
207
|
without changing the line box, so the card layout is untouched. */}
|
|
203
208
|
<a
|
|
204
|
-
href={
|
|
209
|
+
href={productHref}
|
|
205
210
|
data-astro-prefetch
|
|
206
211
|
class="py-3 after:absolute after:inset-0 after:z-0 transition-colors motion-safe:group-hover:text-primary"
|
|
207
212
|
>
|
|
@@ -234,6 +239,18 @@ const quickview = {
|
|
|
234
239
|
</form>
|
|
235
240
|
)
|
|
236
241
|
}
|
|
242
|
+
{
|
|
243
|
+
selectOptions && (
|
|
244
|
+
<a
|
|
245
|
+
href={productHref}
|
|
246
|
+
data-select-options
|
|
247
|
+
data-product-handle={product.handle}
|
|
248
|
+
class="relative z-10 mt-3 block w-full rounded-full bg-[var(--color-primary)] px-4 py-3 text-center font-display text-sm font-medium text-[var(--color-primary-contrast)]"
|
|
249
|
+
>
|
|
250
|
+
Select options
|
|
251
|
+
</a>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
237
254
|
|
|
238
255
|
{rating != null && <RatingStars rating={rating} count={reviewCount} class="mt-1.5" />}
|
|
239
256
|
|
|
@@ -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") && (
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Add-to-cart form for a catalog card.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Add-to-cart form for a catalog card. Only a product with a single variant
|
|
3
|
+
* buys from its card, signed the same way the product page signs its handoff;
|
|
4
|
+
* a product with options links to its product page ("Select options") so the
|
|
5
|
+
* card never adds a variant the shopper did not choose. No form when the
|
|
6
|
+
* product cannot be bought or the cart cannot be signed.
|
|
5
7
|
*/
|
|
6
8
|
import type { CatalogProduct, CatalogVariant, ResolvedManagedCheckout, TenantConfig } from "@tot/public-runtime";
|
|
7
9
|
import {
|
|
@@ -31,6 +33,11 @@ export function defaultCardVariant(product: CatalogProduct): CatalogVariant | nu
|
|
|
31
33
|
return product.variants.find((variant) => variant.inventory.available) ?? null;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/** A product with more than one variant needs the shopper's choice on its product page. */
|
|
37
|
+
export function cardNeedsOptionChoice(product: CatalogProduct): boolean {
|
|
38
|
+
return product.variants.length > 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
export async function buildCardCartForm(input: {
|
|
35
42
|
product: CatalogProduct;
|
|
36
43
|
tenant: TenantConfig;
|
|
@@ -40,6 +47,7 @@ export async function buildCardCartForm(input: {
|
|
|
40
47
|
/** Candidate content pin this card rendered. Ignored on a live page. */
|
|
41
48
|
contentVersionId?: string;
|
|
42
49
|
}): Promise<CardCartForm | null> {
|
|
50
|
+
if (cardNeedsOptionChoice(input.product)) return null;
|
|
43
51
|
const variant = defaultCardVariant(input.product);
|
|
44
52
|
if (!variant) return null;
|
|
45
53
|
const sampleHandoff = isLocalSampleCheckout(input.tenant.tenant_id);
|
|
@@ -36,6 +36,16 @@ export function cartSessionCookiePath(basePath: string): string {
|
|
|
36
36
|
return `${withLead.replace(/\/+$/, "")}/`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* True when a browser at `pathname` sends the cookie stored at `cookiePath`. A
|
|
41
|
+
* candidate preview page (`/preview/render/<tenant>/<change>/…`) is outside the
|
|
42
|
+
* tenant prefix, so the browser holds this store's session but does not send it there.
|
|
43
|
+
*/
|
|
44
|
+
export function cartSessionCookieVisible(pathname: string, cookiePath: string): boolean {
|
|
45
|
+
if (cookiePath === "/") return true;
|
|
46
|
+
return pathname.startsWith(cookiePath) || `${pathname}/` === cookiePath;
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/** Cart URL that selects one Foxy session. Other params are kept. */
|
|
40
50
|
export function withCartSession(cartUrl: string, id: string): string {
|
|
41
51
|
const url = new URL(cartUrl);
|
|
@@ -47,12 +57,21 @@ function saneSessionId(value: unknown): value is string {
|
|
|
47
57
|
return typeof value === "string" && CART_SESSION_ID.test(value);
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
/**
|
|
61
|
+
* An option name as one key. FC.json spells names for display (measured 2026-10-23:
|
|
62
|
+
* `return_url` reads "return url"), and a signed name carries `||<hash>`, so names
|
|
63
|
+
* compare trimmed, lowercased, and with spaces and underscores as one `_`.
|
|
64
|
+
*/
|
|
65
|
+
function optionKey(raw: string): string {
|
|
66
|
+
return (raw.split("||")[0] ?? "").trim().toLowerCase().replace(/[\s_]+/g, "_");
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
function optionBaseName(option: unknown): string | null {
|
|
51
70
|
if (!option || typeof option !== "object") return null;
|
|
52
71
|
const raw = (option as { name?: unknown; class?: unknown }).name
|
|
53
72
|
?? (option as { class?: unknown }).class;
|
|
54
73
|
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
55
|
-
return raw
|
|
74
|
+
return optionKey(raw);
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
function optionValue(option: unknown): string | null {
|
|
@@ -72,7 +91,7 @@ function itemTenant(item: unknown): string | null {
|
|
|
72
91
|
}
|
|
73
92
|
if (options && typeof options === "object") {
|
|
74
93
|
for (const [key, value] of Object.entries(options as Record<string, unknown>)) {
|
|
75
|
-
if (key
|
|
94
|
+
if (optionKey(key) === "tenant" && typeof value === "string") return value;
|
|
76
95
|
}
|
|
77
96
|
}
|
|
78
97
|
return null;
|
|
@@ -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 {
|
|
@@ -17,10 +17,24 @@
|
|
|
17
17
|
*
|
|
18
18
|
* A cart-origin link click or form submit that beats the drawer is held, not
|
|
19
19
|
* lost: it is cancelled, the loader is fetched, and the same control is activated
|
|
20
|
-
* again once the drawer reports ready
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* again once the drawer client reports `ready.done`, so it opens in the drawer
|
|
21
|
+
* exactly as it would have. Foxy binds its cart-link handler in `FC.client.init`
|
|
22
|
+
* and initialises the side cart on `ready.done`; before that a cart link is a
|
|
23
|
+
* plain navigation to the full cart page. So a held action waits for that signal,
|
|
24
|
+
* not a clock: it follows the link to the cart page only when the drawer cannot
|
|
25
|
+
* come up (the loader or bundle errors, no client appears, or nothing reports
|
|
26
|
+
* within LAZY_CHECKOUT_READY_TIMEOUT_MS). A held action never outlives the page
|
|
27
|
+
* that held it. Holding runs in the window bubble phase, after every page
|
|
28
|
+
* handler, so an action another handler already took over (the compliance gate,
|
|
29
|
+
* or the drawer itself) is left alone.
|
|
30
|
+
*
|
|
31
|
+
* Every cart action that reaches the window still unhandled once the drawer has
|
|
32
|
+
* settled is about to navigate to the cart page. That is recorded: a
|
|
33
|
+
* `[tot-cart]` console warning, a `tot:cart-fallthrough` window event, and
|
|
34
|
+
* `data-cart-fallthrough="<reason>[:<cause>]"` on the root element. The detail
|
|
35
|
+
* carries only the reason, the cause, elapsed ms, whether the action was held,
|
|
36
|
+
* whether `FC.client` existed, Foxy's own full-page and bypass decisions, the
|
|
37
|
+
* document ready state and viewport/screen/body widths — never a URL or session.
|
|
24
38
|
*
|
|
25
39
|
* Every cart open waits for this store's Foxy session. The id comes from the
|
|
26
40
|
* same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
|
|
@@ -43,8 +57,12 @@ import { CART_UNAVAILABLE_MESSAGE } from "@/lib/checkout/commerce/cartSession";
|
|
|
43
57
|
|
|
44
58
|
export const LAZY_CHECKOUT_LOADER_IDLE_MS = 8000;
|
|
45
59
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Upper bound on waiting for the drawer client to report ready before a held action
|
|
62
|
+
* follows its link to the full cart page. Long enough for a cold drawer bundle on a
|
|
63
|
+
* slow phone; reached only when the drawer is not coming up at all.
|
|
64
|
+
*/
|
|
65
|
+
export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 12000;
|
|
48
66
|
|
|
49
67
|
export const LAZY_CHECKOUT_LOADER_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
|
|
50
68
|
|
|
@@ -81,7 +99,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
81
99
|
var referrerPolicy = ${literal(CHECKOUT_NAVIGATION_REFERRER_POLICY)};
|
|
82
100
|
var storeNotPublic = ${options.storeNotPublic ? "true" : "false"};
|
|
83
101
|
var events = ${literal(LAZY_CHECKOUT_LOADER_EVENTS)};
|
|
102
|
+
// idle → loading → ready (the drawer client reported ready.done) | failed (it cannot come up).
|
|
84
103
|
var state = 'idle';
|
|
104
|
+
var failure = null;
|
|
105
|
+
var armedAt = 0;
|
|
106
|
+
var replaying = null;
|
|
85
107
|
var waiters = [];
|
|
86
108
|
var sessionId = null;
|
|
87
109
|
var sessionPromise = null;
|
|
@@ -143,7 +165,10 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
143
165
|
document.addEventListener("visibilitychange", function () {
|
|
144
166
|
if (document.visibilityState === "hidden") endCartNavigation();
|
|
145
167
|
});
|
|
146
|
-
window.addEventListener("pagehide",
|
|
168
|
+
window.addEventListener("pagehide", function () {
|
|
169
|
+
endCartNavigation();
|
|
170
|
+
dropHeld();
|
|
171
|
+
});
|
|
147
172
|
window.addEventListener("pageshow", function (event) {
|
|
148
173
|
if (event.persisted) endCartNavigation();
|
|
149
174
|
});
|
|
@@ -201,20 +226,75 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
201
226
|
});
|
|
202
227
|
return sessionPromise;
|
|
203
228
|
}
|
|
204
|
-
function
|
|
229
|
+
function release(held) {
|
|
230
|
+
if (held.el && held.el.removeAttribute) held.el.removeAttribute('aria-busy');
|
|
231
|
+
}
|
|
232
|
+
function dropHeld() {
|
|
233
|
+
var dropped = waiters;
|
|
205
234
|
waiters = [];
|
|
235
|
+
for (var i = 0; i < dropped.length; i++) release(dropped[i]);
|
|
236
|
+
}
|
|
237
|
+
function markSessionUnavailable() {
|
|
238
|
+
dropHeld();
|
|
206
239
|
state = 'idle';
|
|
207
240
|
document.documentElement.setAttribute('data-cart-session', 'unavailable');
|
|
208
241
|
for (var i = 0; i < events.length; i++) {
|
|
209
242
|
document.addEventListener(events[i], onInput, { capture: true, passive: true });
|
|
210
243
|
}
|
|
211
244
|
}
|
|
245
|
+
function settled() {
|
|
246
|
+
return state === 'ready' || state === 'failed';
|
|
247
|
+
}
|
|
248
|
+
function runHeld() {
|
|
249
|
+
var run = waiters;
|
|
250
|
+
waiters = [];
|
|
251
|
+
for (var i = 0; i < run.length; i++) {
|
|
252
|
+
release(run[i]);
|
|
253
|
+
run[i].fn();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
212
256
|
function ready() {
|
|
213
257
|
if (state === 'ready') return;
|
|
214
258
|
state = 'ready';
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
259
|
+
failure = null;
|
|
260
|
+
runHeld();
|
|
261
|
+
}
|
|
262
|
+
function fail(cause) {
|
|
263
|
+
if (state !== 'loading') return;
|
|
264
|
+
state = 'failed';
|
|
265
|
+
failure = cause;
|
|
266
|
+
runHeld();
|
|
267
|
+
}
|
|
268
|
+
// A cart action reaching the window unhandled after the drawer settled navigates
|
|
269
|
+
// to the full cart page. Record why, with no URL or session in the record.
|
|
270
|
+
function fallthrough(kind, el) {
|
|
271
|
+
var fc = window.FC || {};
|
|
272
|
+
var fullpage = null;
|
|
273
|
+
try {
|
|
274
|
+
if (fc.sidecart && typeof fc.sidecart.shouldUseFullpageCart === 'function') fullpage = !!fc.sidecart.shouldUseFullpageCart();
|
|
275
|
+
} catch (e) {}
|
|
276
|
+
var body = document.body;
|
|
277
|
+
function bypassed(node) {
|
|
278
|
+
return !!(node && node.classList && (node.classList.contains('fc-sidecart--bypass') || node.hasAttribute('data-fc-sidecart-bypass')));
|
|
279
|
+
}
|
|
280
|
+
var reason = state === 'ready' ? 'drawer-declined' : 'drawer-unavailable';
|
|
281
|
+
var detail = {
|
|
282
|
+
reason: reason,
|
|
283
|
+
cause: failure,
|
|
284
|
+
kind: kind,
|
|
285
|
+
held: replaying === el,
|
|
286
|
+
elapsedMs: armedAt ? Date.now() - armedAt : null,
|
|
287
|
+
fcClient: !!fc.client,
|
|
288
|
+
fullpage: fullpage,
|
|
289
|
+
bypass: bypassed(body) || bypassed(el),
|
|
290
|
+
docState: document.readyState,
|
|
291
|
+
viewport: window.innerWidth,
|
|
292
|
+
screenWidth: window.screen ? window.screen.availWidth : null,
|
|
293
|
+
bodyWidth: body ? body.clientWidth : null
|
|
294
|
+
};
|
|
295
|
+
document.documentElement.setAttribute('data-cart-fallthrough', reason + (failure ? ':' + failure : ''));
|
|
296
|
+
try { console.warn('[tot-cart] cart fell through to the cart page', detail); } catch (e) {}
|
|
297
|
+
try { window.dispatchEvent(new window.CustomEvent('tot:cart-fallthrough', { detail: detail })); } catch (e) {}
|
|
218
298
|
}
|
|
219
299
|
function armDrawer(id) {
|
|
220
300
|
document.documentElement.removeAttribute('data-cart-session');
|
|
@@ -225,23 +305,25 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
225
305
|
window.FC.settings.cookiepath = cookiePath;
|
|
226
306
|
window.FC.settings.sitedomain = location.hostname;
|
|
227
307
|
};
|
|
228
|
-
|
|
308
|
+
armedAt = Date.now();
|
|
309
|
+
setTimeout(function () { fail('ready-timeout'); }, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
|
|
229
310
|
var s = document.createElement('script');
|
|
230
311
|
s.src = src;
|
|
231
312
|
s.async = true;
|
|
232
|
-
// The loader appends the drawer bundle and exposes it as fc_script
|
|
233
|
-
//
|
|
313
|
+
// The loader appends the drawer bundle and exposes it as fc_script. The bundle's
|
|
314
|
+
// FC.client.init binds the cart-link handler and ready.done initialises the side
|
|
315
|
+
// cart, so ready.done is the only signal that a cart link will open the drawer.
|
|
234
316
|
s.addEventListener('load', function () {
|
|
235
317
|
var bundle = window.fc_script;
|
|
236
|
-
if (!bundle) return
|
|
318
|
+
if (!bundle) return fail('no-bundle');
|
|
237
319
|
bundle.addEventListener('load', function () {
|
|
238
320
|
var client = window.FC && window.FC.client;
|
|
239
|
-
if (!client || typeof client.on !== 'function') return
|
|
321
|
+
if (!client || typeof client.on !== 'function') return fail('no-client');
|
|
240
322
|
client.on('ready.done', ready);
|
|
241
323
|
});
|
|
242
|
-
bundle.addEventListener('error',
|
|
324
|
+
bundle.addEventListener('error', function () { fail('bundle-error'); });
|
|
243
325
|
});
|
|
244
|
-
s.addEventListener('error',
|
|
326
|
+
s.addEventListener('error', function () { fail('loader-error'); });
|
|
245
327
|
document.body.appendChild(s);
|
|
246
328
|
}
|
|
247
329
|
function start() {
|
|
@@ -253,11 +335,16 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
253
335
|
armDrawer(id);
|
|
254
336
|
});
|
|
255
337
|
}
|
|
256
|
-
function whenReady(fn) {
|
|
257
|
-
if (
|
|
258
|
-
|
|
338
|
+
function whenReady(el, fn) {
|
|
339
|
+
if (settled()) return fn();
|
|
340
|
+
el.setAttribute('aria-busy', 'true');
|
|
341
|
+
waiters.push({ el: el, fn: fn });
|
|
259
342
|
start();
|
|
260
343
|
}
|
|
344
|
+
function replay(el, fn) {
|
|
345
|
+
replaying = el;
|
|
346
|
+
try { fn(); } finally { replaying = null; }
|
|
347
|
+
}
|
|
261
348
|
function onInput() {
|
|
262
349
|
if (commerceReachable()) start();
|
|
263
350
|
}
|
|
@@ -292,16 +379,19 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
292
379
|
var anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
293
380
|
if (!anchor || anchor.target === '_blank' || !targetsCart(anchor.href)) return;
|
|
294
381
|
anchor.referrerPolicy = referrerPolicy;
|
|
295
|
-
if (
|
|
382
|
+
if (settled()) {
|
|
296
383
|
stampAnchor(anchor);
|
|
297
|
-
if (!event.defaultPrevented)
|
|
384
|
+
if (!event.defaultPrevented) {
|
|
385
|
+
fallthrough('link', anchor);
|
|
386
|
+
revealForNavigation();
|
|
387
|
+
}
|
|
298
388
|
return;
|
|
299
389
|
}
|
|
300
390
|
if (event.defaultPrevented) return;
|
|
301
391
|
event.preventDefault();
|
|
302
|
-
whenReady(function () {
|
|
392
|
+
whenReady(anchor, function () {
|
|
303
393
|
stampAnchor(anchor);
|
|
304
|
-
anchor.click();
|
|
394
|
+
replay(anchor, function () { anchor.click(); });
|
|
305
395
|
});
|
|
306
396
|
});
|
|
307
397
|
|
|
@@ -352,10 +442,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
352
442
|
var cartForm = targetsCart(form.getAttribute('action'));
|
|
353
443
|
if (!managed && !cartForm) return;
|
|
354
444
|
if (form.dataset.cartSession === '1') {
|
|
355
|
-
if (!cartForm ||
|
|
356
|
-
if (!event.defaultPrevented &&
|
|
445
|
+
if (!cartForm || settled()) {
|
|
446
|
+
if (!event.defaultPrevented && settled()) {
|
|
357
447
|
form.referrerPolicy = referrerPolicy;
|
|
358
448
|
if (cartForm) {
|
|
449
|
+
fallthrough('form', form);
|
|
359
450
|
form.setAttribute("referrerpolicy", referrerPolicy);
|
|
360
451
|
loosenReferrer();
|
|
361
452
|
revealForNavigation();
|
|
@@ -364,7 +455,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
364
455
|
return;
|
|
365
456
|
}
|
|
366
457
|
event.preventDefault();
|
|
367
|
-
whenReady(function () { stampForm(form); resubmit(form); });
|
|
458
|
+
whenReady(form, function () { stampForm(form); replay(form, function () { resubmit(form); }); });
|
|
368
459
|
return;
|
|
369
460
|
}
|
|
370
461
|
event.preventDefault();
|
|
@@ -372,7 +463,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
372
463
|
openSessionThen(form, function () {
|
|
373
464
|
if (cartForm) {
|
|
374
465
|
stampForm(form);
|
|
375
|
-
whenReady(function () { resubmit(form); });
|
|
466
|
+
whenReady(form, function () { replay(form, function () { resubmit(form); }); });
|
|
376
467
|
return;
|
|
377
468
|
}
|
|
378
469
|
resubmit(form);
|
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
* stray quote/angle-bracket can't break out of an attribute or element; this is
|
|
25
25
|
* defense-in-depth, not a sanitizer for untrusted input.
|
|
26
26
|
*/
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
BRAND_H1_CLASS,
|
|
29
|
+
CHROME_BODY_MARKER,
|
|
30
|
+
CHROME_HEAD_MARKER,
|
|
31
|
+
isHomePath,
|
|
32
|
+
} from "./rawMarketingHtml.js";
|
|
28
33
|
import { isCurrentNavHref } from "../chrome/navCurrent.js";
|
|
29
34
|
import { ctaIdentityAttr } from "../chrome/ctaIdentity.js";
|
|
30
35
|
import { CHROME_PLACEMENT } from "../chrome/trackingPlacements.js";
|
|
@@ -325,6 +330,14 @@ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
|
|
|
325
330
|
}
|
|
326
331
|
|
|
327
332
|
function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?: string): string {
|
|
333
|
+
// One-h1 contract: on the home route (header only, never the footer) the brand
|
|
334
|
+
// renders inside <h1 class="brand-h1">. When the spliced page body supplies its
|
|
335
|
+
// own <h1>, wrapFragmentInChrome / Layout unwrap this one (demoteBrandH1), so
|
|
336
|
+
// every page carries exactly one h1.
|
|
337
|
+
const brandLink = renderBrand(header.brand);
|
|
338
|
+
const brandBlock = isHomePath(currentPath)
|
|
339
|
+
? `<h1 class="${BRAND_H1_CLASS}" style="margin:0;font:inherit">${brandLink}</h1>`
|
|
340
|
+
: brandLink;
|
|
328
341
|
const search = header.search ? renderSearchForm(header.search) : "";
|
|
329
342
|
const memberCue = header.memberCue
|
|
330
343
|
? `<a class="member-cue" href="${esc(header.memberCue.href)}" aria-label="${esc(header.memberCue.label)}">${esc(header.memberCue.label)}</a>`
|
|
@@ -424,7 +437,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
|
|
|
424
437
|
` <div class="container header-row">\n` +
|
|
425
438
|
(menuLeft && hamburger ? ` ${hamburger}\n` : "") +
|
|
426
439
|
(compact ? ` ${primaryNav}\n` : "") +
|
|
427
|
-
` ${
|
|
440
|
+
` ${brandBlock}\n` +
|
|
428
441
|
(!minimal && !compact && search ? ` ${search}\n` : "") +
|
|
429
442
|
` <div class="header-actions">${memberCue}${ctas}${compactSearch}${actionLinks}${account}${cart}${menuLeft ? "" : hamburger}</div>\n` +
|
|
430
443
|
` </div>\n` +
|
|
@@ -56,13 +56,39 @@ export function isFullDocument(html: string): boolean {
|
|
|
56
56
|
return /^\s*<!doctype/i.test(html) || /<html[\s>]/i.test(html);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Class of the home-route header brand heading (`<h1 class="brand-h1">`). */
|
|
60
|
+
export const BRAND_H1_CLASS = "brand-h1";
|
|
61
|
+
|
|
62
|
+
/** True for the home route ("/" with optional query/hash); false when no path is known. */
|
|
63
|
+
export function isHomePath(path: string | undefined): boolean {
|
|
64
|
+
return path !== undefined && (path.split(/[?#]/)[0] ?? "") === "/";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when page content already supplies an `<h1>`. */
|
|
68
|
+
export function hasContentH1(html: string): boolean {
|
|
69
|
+
return /<h1[\s>]/i.test(html);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unwrap the home brand heading (keeping the brand link) so a page whose own
|
|
74
|
+
* content supplies an `<h1>` ends up with exactly one h1.
|
|
75
|
+
*/
|
|
76
|
+
export function demoteBrandH1(html: string): string {
|
|
77
|
+
return html.replace(
|
|
78
|
+
new RegExp(`<h1 class="${BRAND_H1_CLASS}"[^>]*>([\\s\\S]*?)</h1>`),
|
|
79
|
+
"$1",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
/**
|
|
60
84
|
* Splice a body-only fragment into a tenant chrome wrapper at CHROME_BODY_MARKER.
|
|
85
|
+
* When the fragment carries its own `<h1>`, the wrapper's brand h1 is unwrapped.
|
|
61
86
|
* If the wrapper has no marker (misconfigured), the fragment is returned unwrapped
|
|
62
87
|
* so a page never disappears.
|
|
63
88
|
*/
|
|
64
89
|
export function wrapFragmentInChrome(fragment: string, chromeWrapper: string): string {
|
|
65
90
|
if (!chromeWrapper.includes(CHROME_BODY_MARKER)) return fragment;
|
|
91
|
+
if (hasContentH1(fragment)) chromeWrapper = demoteBrandH1(chromeWrapper);
|
|
66
92
|
// A fragment may lead with a <head>…</head> of page-specific SEO tags. Split it
|
|
67
93
|
// out so the shared wrapper keeps per-page title/meta/canonical/JSON-LD.
|
|
68
94
|
let head = "";
|
|
@@ -96,6 +96,25 @@ export function buildQueryString(
|
|
|
96
96
|
return s ? `?${s}` : "";
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Facet values shown before the nested "Show more" disclosure. */
|
|
100
|
+
export const FACET_VISIBLE_VALUES = 8;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split a facet group's values into the always-visible head and the collapsed
|
|
104
|
+
* remainder: the first FACET_VISIBLE_VALUES, plus every selected value wherever
|
|
105
|
+
* it sits, so a selected value is never hidden in the remainder.
|
|
106
|
+
*/
|
|
107
|
+
export function splitFacetValues<T extends { value: string }>(
|
|
108
|
+
values: readonly T[],
|
|
109
|
+
isSelected: (value: string) => boolean,
|
|
110
|
+
limit: number = FACET_VISIBLE_VALUES,
|
|
111
|
+
): { shown: T[]; rest: T[] } {
|
|
112
|
+
const shown: T[] = [];
|
|
113
|
+
const rest: T[] = [];
|
|
114
|
+
values.forEach((fv, i) => (i < limit || isSelected(fv.value) ? shown : rest).push(fv));
|
|
115
|
+
return { shown, rest };
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
/** Toggle a single facet value on/off, returning the new querystring (page reset). */
|
|
100
119
|
export function toggleFacet(
|
|
101
120
|
current: ListProductsQuery,
|
|
@@ -948,6 +948,9 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
948
948
|
const key = pageKey;
|
|
949
949
|
await timed(timing, "page-cache", () => putCachedPage(cache, key, response));
|
|
950
950
|
}
|
|
951
|
+
// A preview-token (`test`) render is unpublished content shown to one viewer: no
|
|
952
|
+
// browser, proxy, or CDN may keep it or hand it to the next visitor of this URL.
|
|
953
|
+
if (customization.env === "test") response.headers.set("Cache-Control", "private, no-store");
|
|
951
954
|
// MATERIALIZE-ONLY capture (ADR 0013): expose the tenant's resolved route-document
|
|
952
955
|
// Cache-Control so the publish/materialize plane can bake it into the bundle
|
|
953
956
|
// manifest (the same capture pattern as CSP). Gated on the materialize probe
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
import type { APIContext } from "astro";
|
|
3
3
|
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
|
|
4
4
|
import {
|
|
5
|
-
commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding,
|
|
6
|
-
type CallerToken, type CatalogProduct, type CommerceEvidence, type CommerceEvidenceProvenance,
|
|
5
|
+
commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding, cartLineCode,
|
|
6
|
+
type CallerToken, type CatalogProduct, type CatalogVariant, type CommerceEvidence, type CommerceEvidenceProvenance,
|
|
7
7
|
type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
|
|
8
8
|
} from "@tot/public-runtime";
|
|
9
9
|
import { fromD1 } from "@/lib/catalog/d1/catalog.js";
|
|
10
10
|
import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
|
|
11
|
-
import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
|
|
11
|
+
import { evaluateFinalCart, type RefusedLine, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
|
|
12
12
|
import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
|
|
13
13
|
import { readMirrorBalance, readMirrorStock } from "@/lib/inventory/mirrorStock.js";
|
|
14
14
|
import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
|
|
@@ -30,6 +30,17 @@ function json(body: unknown, status = 200): Response {
|
|
|
30
30
|
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
|
|
31
31
|
}
|
|
32
32
|
function deny(finding: string, status = 409, owner?: string): Response { return json({ ok: false, finding, ...(owner ? { owner } : {}) }, status); }
|
|
33
|
+
/**
|
|
34
|
+
* Which check refused, and on which cart line (its index in the presented snapshot). Logged on
|
|
35
|
+
* the decision line only: closed check names and an index, never a SKU, price or cart value.
|
|
36
|
+
*/
|
|
37
|
+
interface RefusalDetail { check: string; line?: number }
|
|
38
|
+
const refusalDetails = new WeakMap<Response, RefusalDetail>();
|
|
39
|
+
function refuse(finding: string, detail: RefusalDetail, status = 409): Response {
|
|
40
|
+
const response = deny(finding, status);
|
|
41
|
+
refusalDetails.set(response, detail);
|
|
42
|
+
return response;
|
|
43
|
+
}
|
|
33
44
|
function list(value: string | undefined): string[] | null {
|
|
34
45
|
if (!value) return null;
|
|
35
46
|
try {
|
|
@@ -85,7 +96,11 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
|
|
|
85
96
|
const { decision } = await evaluatePreflight({ locals: { tenant: context.locals.tenant, tot: {
|
|
86
97
|
getProductByHandle: async (handle: string) => selected.get(handle) ?? null,
|
|
87
98
|
} } } as APIContext, {
|
|
88
|
-
lines: snapshot.lines.map(line =>
|
|
99
|
+
lines: snapshot.lines.map(line => {
|
|
100
|
+
const product = products.find(p => p.variants.some(v => cartLineCode(v) === line.sku))!;
|
|
101
|
+
const variant = product.variants.find(v => cartLineCode(v) === line.sku)!;
|
|
102
|
+
return { handle: product.handle, variantId: variant.id, sku: line.sku, quantity: line.quantity };
|
|
103
|
+
}),
|
|
89
104
|
destination: snapshot.destination,
|
|
90
105
|
...(policy.verification === "required" && snapshot.verificationRef ? { verificationRef: snapshot.verificationRef } : {}),
|
|
91
106
|
}, { verification: policy.verification, rules: policy.rules });
|
|
@@ -93,8 +108,19 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
|
|
|
93
108
|
const blocks = decision.blocks;
|
|
94
109
|
if (blocks.length === 0) return { status: "clear", verified };
|
|
95
110
|
const flavors = [...new Set(blocks.flatMap(block => block.code === "flavored_prohibited" ? block.flavors ?? [] : []))];
|
|
111
|
+
const titleOf = (sku: string) => products.find(p => p.variants.some(v => v.sku === sku))?.title;
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
const lines: RefusedLine[] = [];
|
|
114
|
+
for (const block of blocks) {
|
|
115
|
+
if (block.failedClosed === true || typeof block.message !== "string" || !block.message) continue;
|
|
116
|
+
const key = `${block.code}\n${block.sku ?? ""}\n${block.message}`;
|
|
117
|
+
if (seen.has(key)) continue;
|
|
118
|
+
seen.add(key);
|
|
119
|
+
const title = block.sku ? titleOf(block.sku) : undefined;
|
|
120
|
+
lines.push({ code: block.code, message: block.message, ...(block.sku ? { sku: block.sku } : {}), ...(title ? { title } : {}) });
|
|
121
|
+
}
|
|
96
122
|
return { status: "blocked", verified, failedClosed: blocks.some(block => block.failedClosed === true), codes: blocks.map(block => block.code),
|
|
97
|
-
...(flavors.length ? { flavors } : {}) };
|
|
123
|
+
...(flavors.length ? { flavors } : {}), ...(lines.length ? { lines } : {}) };
|
|
98
124
|
} catch { return { status: "absent", verified: false }; }
|
|
99
125
|
}
|
|
100
126
|
function fullCartProduct(value: unknown, handle: string, scope: string): value is CatalogProduct {
|
|
@@ -132,8 +158,11 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
132
158
|
if (response.status !== 200 || threw) {
|
|
133
159
|
try { finding = String((await response.clone().json() as { finding?: unknown }).finding ?? "unreadable").slice(0, 80); } catch { finding = "unreadable"; }
|
|
134
160
|
}
|
|
161
|
+
const detail = refusalDetails.get(response);
|
|
135
162
|
try {
|
|
136
|
-
console.info("commerce-final-cart-decision", JSON.stringify({ correlationId, ok: response.status === 200, finding,
|
|
163
|
+
console.info("commerce-final-cart-decision", JSON.stringify({ correlationId, ok: response.status === 200, finding,
|
|
164
|
+
...(detail ? { check: detail.check, ...(detail.line === undefined ? {} : { line: detail.line }) } : {}),
|
|
165
|
+
status: response.status, threw, ms: Date.now() - started }));
|
|
137
166
|
} catch { /* Diagnostic delivery cannot alter the decision. */ }
|
|
138
167
|
return response;
|
|
139
168
|
}
|
|
@@ -189,31 +218,39 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
189
218
|
try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
|
|
190
219
|
if (!Array.isArray(listing)) return deny("catalog-unavailable", 503);
|
|
191
220
|
const selected = new Map<string, CatalogProduct>();
|
|
192
|
-
|
|
193
|
-
|
|
221
|
+
const mismatch = (check: string, line?: number) => refuse("catalog-cart-mismatch", line === undefined ? { check } : { check, line });
|
|
222
|
+
if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return mismatch("store-currency");
|
|
223
|
+
// A line's `sku` is the product code the page signed (`cartLineCode`): the SKU, or the
|
|
224
|
+
// variant id for a variant with none.
|
|
225
|
+
const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant && cartLineCode(variant) === line.sku;
|
|
226
|
+
for (const [index, line] of snapshot.lines.entries()) {
|
|
194
227
|
const matches = listing.flatMap(product => product && product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope && Array.isArray(product.variants)
|
|
195
|
-
? product.variants.filter(
|
|
196
|
-
if (matches.length !== 1) return
|
|
228
|
+
? product.variants.filter(coded(line)).map(variant => ({ product, variant })) : []);
|
|
229
|
+
if (matches.length !== 1) return mismatch(matches.length === 0 ? "sku-not-in-catalog" : "sku-ambiguous", index);
|
|
197
230
|
const match = matches[0]!;
|
|
198
231
|
const handle = match.product.handle;
|
|
199
|
-
if (typeof handle !== "string" || !handle) return
|
|
232
|
+
if (typeof handle !== "string" || !handle) return mismatch("product-handle", index);
|
|
200
233
|
if (!selected.has(handle)) {
|
|
201
234
|
let detail: CatalogProduct | null;
|
|
202
235
|
try { detail = await tot.getProductByHandle(handle); } catch { return deny("catalog-unavailable", 503); }
|
|
203
|
-
if (!fullCartProduct(detail, handle, tenant.tot_data_scope)) return
|
|
236
|
+
if (!fullCartProduct(detail, handle, tenant.tot_data_scope)) return mismatch("product-detail", index);
|
|
204
237
|
selected.set(handle, detail);
|
|
205
238
|
}
|
|
206
239
|
const detail = selected.get(handle)!;
|
|
207
|
-
if (detail.id !== match.product.id) return
|
|
208
|
-
const variants = detail.variants.filter(
|
|
209
|
-
if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return
|
|
240
|
+
if (detail.id !== match.product.id) return mismatch("product-id", index);
|
|
241
|
+
const variants = detail.variants.filter(coded(line));
|
|
242
|
+
if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return mismatch("variant-detail", index);
|
|
210
243
|
const variant = variants[0]!;
|
|
211
244
|
const digits = new Intl.NumberFormat("en", { style: "currency", currency: snapshot.currency }).resolvedOptions().maximumFractionDigits ?? 2;
|
|
212
245
|
const scaled = variant.price * 10 ** digits;
|
|
213
|
-
if (!variant.inventory.available
|
|
246
|
+
if (!variant.inventory.available) return mismatch("unavailable", index);
|
|
247
|
+
if (variant.currency !== snapshot.currency) return mismatch("variant-currency", index);
|
|
248
|
+
if (Math.abs(scaled - Math.round(scaled)) > 0.000001) return mismatch("price-precision", index);
|
|
249
|
+
if (Math.round(scaled) !== line.unitMinor) return mismatch("price", index);
|
|
214
250
|
}
|
|
215
251
|
const products = [...selected.values()];
|
|
216
|
-
|
|
252
|
+
const ambiguous = snapshot.lines.findIndex(line => products.flatMap(product => product.variants.filter(coded(line))).length !== 1);
|
|
253
|
+
if (ambiguous !== -1) return mismatch("sku-ambiguous", ambiguous);
|
|
217
254
|
// Shipping is re-derived from the store's own declaration, never from the cart: a cart that
|
|
218
255
|
// dropped, replayed or altered its signed `shipping_policy` option was charged another amount.
|
|
219
256
|
if (tenant.shipping) {
|
|
@@ -280,8 +317,10 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
280
317
|
seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
|
|
281
318
|
trustedIssuers: issuers, expectedAudience: audience, activeKids, stockTracked, authorities });
|
|
282
319
|
if (!decision.ok) {
|
|
283
|
-
if (decision.finding
|
|
284
|
-
return json({ ok: false, finding: decision.finding,
|
|
320
|
+
if (decision.finding.startsWith("verification-refused") && (seamView.flavors || seamView.lines)) {
|
|
321
|
+
return json({ ok: false, finding: decision.finding,
|
|
322
|
+
...(decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors ? { flavors: seamView.flavors } : {}),
|
|
323
|
+
...(seamView.lines ? { lines: seamView.lines } : {}) }, 409);
|
|
285
324
|
}
|
|
286
325
|
return deny(decision.finding, 409, decision.owner);
|
|
287
326
|
}
|
|
@@ -1,28 +1,50 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
2
|
import { commerceTenantId } from "@tot/public-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
bindCartSession,
|
|
5
|
+
CART_UNAVAILABLE_MESSAGE,
|
|
6
|
+
cartSessionCookiePath,
|
|
7
|
+
cartSessionCookieVisible,
|
|
8
|
+
withCartSession,
|
|
9
|
+
} from "@/lib/checkout/commerce/cartSession.js";
|
|
4
10
|
import { managedCartHref } from "@/lib/content/chrome/managedCart.js";
|
|
5
11
|
|
|
6
12
|
export const prerender = false;
|
|
7
13
|
|
|
14
|
+
const NO_STORE = "private, no-store";
|
|
15
|
+
|
|
8
16
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* Entry for bookmarked or tenant-authored `/cart` links. Chrome links straight to
|
|
18
|
+
* managed checkout, but this route keeps those links from falling through the
|
|
19
|
+
* content catch-all to a misleading storefront 404. Tenant resolution and preview
|
|
20
|
+
* authorization have already run in middleware before this redirect is selected.
|
|
21
|
+
* The redirect carries this tenant's Foxy session and never the shared cart.
|
|
22
|
+
*
|
|
23
|
+
* The session cookie lives on the tenant prefix. A link on a pinned preview page
|
|
24
|
+
* arrives from outside it, without the cookie, so it is sent to the tenant prefix
|
|
25
|
+
* first: binding there reads the shopper's own session instead of minting an empty
|
|
26
|
+
* one over it.
|
|
15
27
|
*/
|
|
16
28
|
export const GET: APIRoute = async (context) => {
|
|
17
29
|
const location = managedCartHref(context.locals.commerce);
|
|
18
30
|
if (!location || !context.locals.commerce) return new Response(null, { status: 404 });
|
|
31
|
+
// `basePath` is the prefix the browser is on: a pinned render overrides it to the
|
|
32
|
+
// pin's own prefix, while `apiBasePath` stays the tenant prefix the cookie lives on.
|
|
33
|
+
const cookiePath = cartSessionCookiePath(context.locals.apiBasePath ?? context.locals.basePath ?? "");
|
|
34
|
+
const pagePrefix = cartSessionCookiePath(context.locals.basePath ?? context.locals.apiBasePath ?? "");
|
|
35
|
+
if (!cartSessionCookieVisible(pagePrefix, cookiePath)) {
|
|
36
|
+
return new Response(null, {
|
|
37
|
+
status: 302,
|
|
38
|
+
headers: { location: `${cookiePath}cart`, "cache-control": NO_STORE },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
19
41
|
const tenantId = context.locals.tenant ? commerceTenantId(context.locals.tenant) : "";
|
|
20
42
|
const sessionId = tenantId ? await bindCartSession(context, context.locals.commerce, tenantId) : null;
|
|
21
43
|
if (!sessionId) {
|
|
22
44
|
return new Response(CART_UNAVAILABLE_MESSAGE, {
|
|
23
45
|
status: 503,
|
|
24
46
|
headers: {
|
|
25
|
-
"cache-control":
|
|
47
|
+
"cache-control": NO_STORE,
|
|
26
48
|
"content-type": "text/plain; charset=utf-8",
|
|
27
49
|
},
|
|
28
50
|
});
|
|
@@ -31,7 +53,7 @@ export const GET: APIRoute = async (context) => {
|
|
|
31
53
|
status: 302,
|
|
32
54
|
headers: {
|
|
33
55
|
location: withCartSession(location, sessionId),
|
|
34
|
-
"cache-control":
|
|
56
|
+
"cache-control": NO_STORE,
|
|
35
57
|
},
|
|
36
58
|
});
|
|
37
59
|
};
|
|
@@ -158,16 +158,26 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
158
158
|
const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
|
|
159
159
|
|
|
160
160
|
const pathname = url.pathname;
|
|
161
|
+
const publicationChannel =
|
|
162
|
+
sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live";
|
|
161
163
|
const fromPublication = await tryServePublicationRootAsset({
|
|
162
164
|
tenant: context.locals.tenant,
|
|
163
165
|
bucket: bucket as unknown as PublicationArtifactReadBucketV1,
|
|
164
166
|
kvGet: (key) => kv.get(key),
|
|
165
167
|
tenantId: urlTenant,
|
|
166
168
|
pathname,
|
|
167
|
-
channel:
|
|
169
|
+
channel: publicationChannel,
|
|
168
170
|
ifNoneMatch: context.request.headers.get("if-none-match"),
|
|
169
171
|
});
|
|
170
|
-
if (fromPublication)
|
|
172
|
+
if (fromPublication) {
|
|
173
|
+
// Preview bytes answer a tokenless URL (the credential rides in a cookie), so
|
|
174
|
+
// they must never be cached where the next visitor of that URL would get them.
|
|
175
|
+
if (publicationChannel === "preview") {
|
|
176
|
+
fromPublication.headers.set("cache-control", "no-store");
|
|
177
|
+
fromPublication.headers.set("x-robots-tag", "noindex, nofollow");
|
|
178
|
+
}
|
|
179
|
+
return fromPublication;
|
|
180
|
+
}
|
|
171
181
|
|
|
172
182
|
if (previewVersionId) {
|
|
173
183
|
const overlay = await tryServePreviewRewriteOverlay({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
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",
|
|
@@ -782,6 +782,16 @@ export const CONSPICUITY_FLOOR = Object.freeze({ contrast: 7, fontSizePx: 12, fo
|
|
|
782
782
|
/** Bounds on the size a custom style may declare, so the bar cannot swallow the header. */
|
|
783
783
|
const MAX_FONT_SIZE_PX = 24;
|
|
784
784
|
|
|
785
|
+
/** Bounds on the optional box and spacing keys; an unset key renders the preset's own frame. */
|
|
786
|
+
export const NICOTINE_WARNING_BOX_BOUNDS = Object.freeze({
|
|
787
|
+
borderWidthPx: Object.freeze({ min: 0, max: 8, integer: true }),
|
|
788
|
+
paddingYPx: Object.freeze({ min: 0, max: 24, integer: true }),
|
|
789
|
+
letterSpacingPx: Object.freeze({ min: 0, max: 4, integer: false }),
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
/** Weight 500 is allowed only on a boxed bar that is at least this large, contrasty and bordered. */
|
|
793
|
+
export const MEDIUM_WEIGHT_REQUIREMENTS = Object.freeze({ fontSizePx: 16, contrast: 7, borderWidthPx: 2 });
|
|
794
|
+
|
|
785
795
|
export const DEFAULT_NICOTINE_WARNING_PRESET = "dark";
|
|
786
796
|
|
|
787
797
|
/**
|
|
@@ -816,6 +826,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
|
|
|
816
826
|
* textTransform: "uppercase" | "none",
|
|
817
827
|
* fontWeight: number,
|
|
818
828
|
* fontSizePx: number,
|
|
829
|
+
* borderWidthPx?: number,
|
|
830
|
+
* paddingYPx?: number,
|
|
831
|
+
* letterSpacingPx?: number,
|
|
819
832
|
* }} NicotineWarningPresentation
|
|
820
833
|
*/
|
|
821
834
|
|
|
@@ -824,9 +837,10 @@ export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWid
|
|
|
824
837
|
|
|
825
838
|
export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
|
|
826
839
|
"background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
|
|
840
|
+
"borderWidthPx", "paddingYPx", "letterSpacingPx",
|
|
827
841
|
]);
|
|
828
842
|
const TEXT_TRANSFORMS = ["uppercase", "none"];
|
|
829
|
-
const FONT_WEIGHTS = [600, 700, 800, 900];
|
|
843
|
+
const FONT_WEIGHTS = [500, 600, 700, 800, 900];
|
|
830
844
|
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
831
845
|
|
|
832
846
|
/** `#abc` / `#AABBCC` → `#aabbcc`. */
|
|
@@ -865,7 +879,15 @@ export function conspicuityProblems(p) {
|
|
|
865
879
|
out.push(`font size ${p.fontSizePx}px is below the ${CONSPICUITY_FLOOR.fontSizePx}px floor`);
|
|
866
880
|
}
|
|
867
881
|
if (p.fontWeight < CONSPICUITY_FLOOR.fontWeight) {
|
|
868
|
-
|
|
882
|
+
const need = MEDIUM_WEIGHT_REQUIREMENTS;
|
|
883
|
+
const medium = p.fontWeight === 500 && p.fontSizePx >= need.fontSizePx && ratio >= need.contrast
|
|
884
|
+
&& (p.borderWidthPx ?? 0) >= need.borderWidthPx;
|
|
885
|
+
if (!medium) {
|
|
886
|
+
out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor` +
|
|
887
|
+
(p.fontWeight === 500
|
|
888
|
+
? ` (500 needs font size >= ${need.fontSizePx}px, contrast >= ${need.contrast}:1 and borderWidthPx >= ${need.borderWidthPx})`
|
|
889
|
+
: ""));
|
|
890
|
+
}
|
|
869
891
|
}
|
|
870
892
|
return out;
|
|
871
893
|
}
|
|
@@ -885,6 +907,14 @@ function styleValueProblem(key, value) {
|
|
|
885
907
|
return typeof value === "number" && Number.isFinite(value) && value >= CONSPICUITY_FLOOR.fontSizePx && value <= MAX_FONT_SIZE_PX
|
|
886
908
|
? null
|
|
887
909
|
: `\`fontSizePx\` must be a number from ${CONSPICUITY_FLOOR.fontSizePx} to ${MAX_FONT_SIZE_PX}`;
|
|
910
|
+
case "borderWidthPx":
|
|
911
|
+
case "paddingYPx":
|
|
912
|
+
case "letterSpacingPx": {
|
|
913
|
+
const { min, max, integer } = NICOTINE_WARNING_BOX_BOUNDS[key];
|
|
914
|
+
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value))
|
|
915
|
+
? null
|
|
916
|
+
: `\`${key}\` must be ${integer ? "a whole number" : "a number"} from ${min} to ${max}`;
|
|
917
|
+
}
|
|
888
918
|
default:
|
|
889
919
|
return `\`${key}\` is not a nicotine-warning style key (allowed: ${NICOTINE_WARNING_STYLE_KEYS.join(", ")})`;
|
|
890
920
|
}
|
|
@@ -993,7 +1023,10 @@ export function nicotineWarningCheckWidths(declaration) {
|
|
|
993
1023
|
*/
|
|
994
1024
|
export function describeNicotineWarningPresentation(p) {
|
|
995
1025
|
return `background ${p.background}, text ${p.text}, border ${p.border}, ` +
|
|
996
|
-
`${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px
|
|
1026
|
+
`${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px` +
|
|
1027
|
+
(p.borderWidthPx === undefined ? "" : `, border width ${p.borderWidthPx}px`) +
|
|
1028
|
+
(p.paddingYPx === undefined ? "" : `, vertical padding ${p.paddingYPx}px`) +
|
|
1029
|
+
(p.letterSpacingPx === undefined ? "" : `, letter spacing ${p.letterSpacingPx}px`);
|
|
997
1030
|
}
|
|
998
1031
|
|
|
999
1032
|
/**
|
|
@@ -1017,11 +1050,12 @@ export function describeNicotineWarningPresentation(p) {
|
|
|
1017
1050
|
*/
|
|
1018
1051
|
export function nicotineWarningHtml(declaration, variant) {
|
|
1019
1052
|
const { presentation, preset } = resolveNicotineWarning(declaration);
|
|
1053
|
+
const barPadding = presentation.paddingYPx === undefined ? "0.5rem" : `${presentation.paddingYPx}px`;
|
|
1020
1054
|
const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
|
|
1021
1055
|
`data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
|
|
1022
1056
|
const text = NICOTINE_WARNING_TEXT_LOCK;
|
|
1023
1057
|
return variant === "bar"
|
|
1024
|
-
? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block
|
|
1058
|
+
? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:${barPadding};text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
|
|
1025
1059
|
: `<div ${attrs}><p style="margin:0;${text}">${NICOTINE_WARNING_TEXT}</p></div>`;
|
|
1026
1060
|
}
|
|
1027
1061
|
|
|
@@ -1061,15 +1095,17 @@ const NICOTINE_WARNING_TEXT_LOCK = [
|
|
|
1061
1095
|
*/
|
|
1062
1096
|
export function nicotineWarningStyle(p, variant) {
|
|
1063
1097
|
const size = variant === "block" ? Math.max(p.fontSizePx, 14) : p.fontSizePx;
|
|
1098
|
+
const boxed = `${p.borderWidthPx ?? 2}px`;
|
|
1064
1099
|
const frame = variant === "bar"
|
|
1065
|
-
? [`border-bottom:2px solid ${p.border}`]
|
|
1066
|
-
: [`border
|
|
1100
|
+
? [p.borderWidthPx === undefined ? `border-bottom:2px solid ${p.border}` : `border:${boxed} solid ${p.border}`]
|
|
1101
|
+
: [`border:${boxed} solid ${p.border}`, "border-radius:var(--radius-md)",
|
|
1102
|
+
`padding:${p.paddingYPx === undefined ? "0.75rem" : `${p.paddingYPx}px`} 1rem`];
|
|
1067
1103
|
return [
|
|
1068
1104
|
...frame,
|
|
1069
1105
|
`background-color:${p.background}`,
|
|
1070
1106
|
`color:${p.text}`,
|
|
1071
1107
|
`text-transform:${p.textTransform}`,
|
|
1072
|
-
`letter-spacing:${p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
|
|
1108
|
+
`letter-spacing:${p.letterSpacingPx !== undefined ? `${p.letterSpacingPx}px` : p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
|
|
1073
1109
|
`font-weight:${p.fontWeight}`,
|
|
1074
1110
|
`font-size:${size}px`,
|
|
1075
1111
|
"line-height:1.4",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
12
|
"./blog-visibility": "./src/blog-visibility.mjs",
|
|
13
|
+
"./preview-url": "./src/preview-url.mjs",
|
|
13
14
|
"./publication-redirects": "./src/publication-redirects.mjs",
|
|
14
15
|
"./image-dependencies": "./src/image-dependencies.ts",
|
|
15
16
|
"./image-finding-dependencies": "./src/image-finding-dependencies.ts",
|
|
@@ -500,6 +500,14 @@ export function resolveManagedCheckout(
|
|
|
500
500
|
};
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
/**
|
|
504
|
+
* The checkout product code for a variant: its SKU, or its id when it has none. Every field
|
|
505
|
+
* is hashed against it, and the final-cart authority finds a cart line's variant by it.
|
|
506
|
+
*/
|
|
507
|
+
export function cartLineCode(variant: Pick<CatalogVariant, "id" | "sku">): string {
|
|
508
|
+
return variant.sku && variant.sku.length ? variant.sku : variant.id;
|
|
509
|
+
}
|
|
510
|
+
|
|
503
511
|
/**
|
|
504
512
|
* The raw (unsigned) cart fields for a variant: name, price, code, image, the
|
|
505
513
|
* variant's options, and an editable quantity. `code` is the checkout product code
|
|
@@ -511,7 +519,7 @@ export function cartFieldsForVariant(
|
|
|
511
519
|
purchaseOption?: ProductPurchaseOption,
|
|
512
520
|
opts?: { signedPrice?: number; tenantId?: string; currency?: string; contentVersionId?: string },
|
|
513
521
|
): { code: string; fields: CartField[] } {
|
|
514
|
-
const code = variant
|
|
522
|
+
const code = cartLineCode(variant);
|
|
515
523
|
const image = product.images.find((i) => i.id === variant.image_id)?.url ??
|
|
516
524
|
product.images[0]?.url;
|
|
517
525
|
// The signed price is either the caller's member-adjusted EFFECTIVE price
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const PREVIEW_URL_SHAPE: string;
|
|
2
|
+
export function previewPath(store: string, subPath?: string): string;
|
|
3
|
+
export function previewUrl(origin: string, store: string): string;
|
|
4
|
+
export function previewPrUrl(origin: string, store: string, prNumber: number | string): string | null;
|
|
5
|
+
export function previewRevUrl(origin: string, store: string, versionId: string): string;
|
|
6
|
+
export function legacyPreviewLocation(
|
|
7
|
+
pathname: string,
|
|
8
|
+
search: string | undefined,
|
|
9
|
+
isStore: (segment: string) => boolean,
|
|
10
|
+
): string | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one builder of preview URLs. A store's preview lives under the store, never under a
|
|
3
|
+
* `/preview/<store>` prefix:
|
|
4
|
+
*
|
|
5
|
+
* <origin>/<store>/preview the store's current shared preview
|
|
6
|
+
* <origin>/<store>/preview/pr/<n> candidate PR n
|
|
7
|
+
* <origin>/<store>/preview/rev/<sha>/ an immutable revision
|
|
8
|
+
*
|
|
9
|
+
* Every producer (Worker, CLI) calls these; `apps/storefront/src/pages/preview/preview-url-routes.test.ts`
|
|
10
|
+
* resolves each shape against the page route table. `packages/cli/src/preview-url.mjs` is a
|
|
11
|
+
* byte-identical copy (a CLI test pins it).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The canonical shapes, for messages that name the right URL. */
|
|
15
|
+
export const PREVIEW_URL_SHAPE = "/<store>/preview[/pr/<n>]";
|
|
16
|
+
|
|
17
|
+
function trimOrigin(origin) {
|
|
18
|
+
return String(origin).trim().replace(/\/+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `/<store>/preview` with an optional sub-path (`/pr/5`). */
|
|
22
|
+
export function previewPath(store, subPath = "") {
|
|
23
|
+
return `/${encodeURIComponent(store)}/preview${subPath}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function previewUrl(origin, store) {
|
|
27
|
+
return `${trimOrigin(origin)}${previewPath(store)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `null` when `prNumber` is not a positive integer. */
|
|
31
|
+
export function previewPrUrl(origin, store, prNumber) {
|
|
32
|
+
const n = Number(prNumber);
|
|
33
|
+
if (!Number.isInteger(n) || n < 1) return null;
|
|
34
|
+
return `${trimOrigin(origin)}${previewPath(store, `/pr/${n}`)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function previewRevUrl(origin, store, versionId) {
|
|
38
|
+
return `${trimOrigin(origin)}${previewPath(store, `/rev/${encodeURIComponent(versionId)}/`)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The canonical location for a legacy `/preview/<store>[/pr/<n>[/...]]` request, or `null` when
|
|
43
|
+
* the path is not that shape. `isStore` decides whether the first segment names a store
|
|
44
|
+
* (the caller owns what a store id looks like), so `/preview/pr/5`, `/preview/rev/...` and the
|
|
45
|
+
* other first-segment routes are never read as a store.
|
|
46
|
+
*/
|
|
47
|
+
export function legacyPreviewLocation(pathname, search, isStore) {
|
|
48
|
+
const m = /^\/preview\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const store = m[1];
|
|
51
|
+
if (!/^[A-Za-z0-9.-]+$/.test(store) || !isStore(store)) return null;
|
|
52
|
+
const rest = (m[2] ?? "").replace(/\/+$/, "");
|
|
53
|
+
return `/${store}/preview${rest}${search ?? ""}`;
|
|
54
|
+
}
|