@asstio-dev/storefront 0.31.1 → 0.40.0-market.1

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.
@@ -18,10 +18,7 @@ async function context(config) {
18
18
  const jar = await cookies();
19
19
  const security = cookieSecurity(config.origin);
20
20
  const shopperIp = shopperIpFrom(await headers(), { trustedHops: config.trustedHops });
21
- const client = (session) => createShopperClient({
22
- baseUrl: config.baseUrl, serverKey: config.serverKey, market: config.market, acceptLanguage: config.acceptLanguage,
23
- session, shopperIp, fetch: config.fetch,
24
- });
21
+ const client = (session) => createShopperClient({ ...config, session, shopperIp });
25
22
  return { jar, security, client };
26
23
  }
27
24
  const set = (jar, spec) => jar.set(spec);
@@ -50,6 +47,9 @@ function keepCarts(jar, config, security, data, current) {
50
47
  function withoutData(r, view) {
51
48
  return r.ok ? { ok: true, data: view(r.data) } : r;
52
49
  }
50
+ function transition(r) {
51
+ return r.ok ? { ok: true, data: signedInView(r.data), dropPrivateData: true } : r;
52
+ }
53
53
  /**
54
54
  * Starts a sign-in: `POST /v1/auth/sign-in`, then the binding (with the challenge id) goes into the sign-in cookie for 10
55
55
  * minutes. An unsafe `returnPath` is dropped here as the API would. The result carries the challenge id for the code form.
@@ -64,8 +64,9 @@ export async function startSignInAction(config, email, returnPath) {
64
64
  }
65
65
  /**
66
66
  * Verifies a code or link with the binding from the sign-in cookie: on success the session cookie is set (to the session's
67
- * absolute expiry) and the sign-in cookie cleared. `409 SIGN_IN_CONFIRM_REQUIRED` comes back as a problem with
68
- * `maskedEmail` and `siteName`; ask the shopper, then call again with `confirm: true`.
67
+ * absolute expiry), the sign-in cookie cleared, and the result says `dropPrivateData: true`: the guest's cart view goes, and an adopted
68
+ * cart is read again. `409 SIGN_IN_CONFIRM_REQUIRED` comes back as a problem with `maskedEmail` and `siteName`; ask the shopper, then
69
+ * call again with `confirm: true`.
69
70
  */
70
71
  export async function verifySignInAction(config, input) {
71
72
  const { jar, security, client } = await context(config);
@@ -83,12 +84,13 @@ export async function verifySignInAction(config, input) {
83
84
  set(jar, signInCookie.clear(security));
84
85
  keepCarts(jar, config, security, r.data, cartToken);
85
86
  }
86
- return withoutData(r, signedInView);
87
+ return transition(r);
87
88
  }
88
89
  /**
89
- * Switches the principal: the rotated token replaces the session cookie at once (the old one serves GETs for 30 s only).
90
- * A failure (also `SESSION_INVALID`) is returned and leaves the cookie alone: a switch that lost to a concurrent one from
91
- * another tab would otherwise delete the winner's fresh cookie. Refresh the session state; only `signOutAction` clears.
90
+ * Switches the principal: the rotated token replaces the session cookie at once (the old one serves GETs for 30 s only), the
91
+ * cart cookie moves to the new principal's own cart, and the result says `dropPrivateData: true`. A failure (also `SESSION_INVALID`)
92
+ * is returned and leaves the cookie alone: a switch that lost to a concurrent one from another tab would otherwise delete the
93
+ * winner's fresh cookie. Refresh the session state; only `signOutAction` clears.
92
94
  */
93
95
  export async function switchPrincipalAction(config, principal) {
94
96
  const { jar, security, client } = await context(config);
@@ -99,22 +101,23 @@ export async function switchPrincipalAction(config, principal) {
99
101
  // The old principal keeps its cart; the new principal's cart becomes the cart cookie, or the cookie is cleared when it has none.
100
102
  keepCarts(jar, config, security, r.data, config.cartCookie ? read(jar, config.cartCookie) : null);
101
103
  }
102
- return withoutData(r, signedInView);
104
+ return transition(r);
103
105
  }
104
106
  /**
105
107
  * Signs out (`everywhere` ends every session of the account). The session cookie, the sign-in cookie, the cart cookie and the
106
108
  * browser-cart cookie are cleared whatever the API answered, and also when it cannot be reached (`{ ok: false, status: 0, problem.code: "NETWORK" }`)
107
- * — a session already gone is still a sign-out for this browser.
109
+ * — a session already gone is still a sign-out for this browser. So every answer says `dropPrivateData: true`.
108
110
  */
109
111
  export async function signOutAction(config, opts = {}) {
110
112
  const { jar, security, client } = await context(config);
111
113
  const token = session(jar, security);
112
114
  try {
113
- return token ? await signOut(client(token), opts) : { ok: true, data: undefined };
115
+ const result = token ? await signOut(client(token), opts) : { ok: true, data: undefined };
116
+ return { ...result, dropPrivateData: true };
114
117
  }
115
118
  catch {
116
119
  // Spec §7.8: an unreachable API still signs this browser out; the session ends server-side at its idle expiry.
117
- return localFailure("NETWORK", "The API could not be reached; this browser is signed out.");
120
+ return { ...localFailure("NETWORK", "The API could not be reached; this browser is signed out."), dropPrivateData: true };
118
121
  }
119
122
  finally {
120
123
  set(jar, sessionCookie.clear(security));
@@ -5,9 +5,13 @@ export type Rational = {
5
5
  };
6
6
  /** `"125.00"`, `"25"`, `"-3.5"` → an exact rational. Anything else (exponents, commas, blanks) throws. */
7
7
  export declare function parseDecimal(s: string): Rational;
8
- /** The net of a VAT-inclusive amount: `gross / (1 + rate / 100)`, exactly. */
8
+ /** The net of a VAT-inclusive amount: `gross / (1 + rate / 100)`, exactly. For checkout and order lines only, which have no price pair
9
+ * yet (WP9b, WP10); a pair's `ex` is read, never computed. */
9
10
  export declare function netOf(gross: Rational, vatRatePercent: Rational): Rational;
10
11
  export declare function add(a: Rational, b: Rational): Rational;
11
- /** Rounded to whole cents (öre), half away from zero. */
12
+ /** The nearest number to an exact amount, as `Number("1.234")` reads it: reduced first, because a sum's denominator can outgrow a float. */
13
+ export declare function toNumber(r: Rational): number;
14
+ /** Rounded to whole cents (öre), half away from zero. For checkout and order amounts only, which are two-decimal until WP9b and WP10;
15
+ * a pair keeps the precision the API sent it in, the currency's own. */
12
16
  export declare function toCents(r: Rational): bigint;
13
17
  export declare const centsToNumber: (cents: bigint) => number;
@@ -9,14 +9,22 @@ export function parseDecimal(s) {
9
9
  const n = BigInt(whole + fraction);
10
10
  return { n: negative ? -n : n, d: 10n ** BigInt(fraction.length) };
11
11
  }
12
- /** The net of a VAT-inclusive amount: `gross / (1 + rate / 100)`, exactly. */
12
+ /** The net of a VAT-inclusive amount: `gross / (1 + rate / 100)`, exactly. For checkout and order lines only, which have no price pair
13
+ * yet (WP9b, WP10); a pair's `ex` is read, never computed. */
13
14
  export function netOf(gross, vatRatePercent) {
14
15
  return { n: gross.n * 100n * vatRatePercent.d, d: gross.d * (100n * vatRatePercent.d + vatRatePercent.n) };
15
16
  }
16
17
  export function add(a, b) {
17
18
  return { n: a.n * b.d + b.n * a.d, d: a.d * b.d };
18
19
  }
19
- /** Rounded to whole cents (öre), half away from zero. */
20
+ const gcd = (a, b) => (b === 0n ? a : gcd(b, a % b));
21
+ /** The nearest number to an exact amount, as `Number("1.234")` reads it: reduced first, because a sum's denominator can outgrow a float. */
22
+ export function toNumber(r) {
23
+ const divisor = gcd(r.n < 0n ? -r.n : r.n, r.d);
24
+ return Number(r.n / divisor) / Number(r.d / divisor);
25
+ }
26
+ /** Rounded to whole cents (öre), half away from zero. For checkout and order amounts only, which are two-decimal until WP9b and WP10;
27
+ * a pair keeps the precision the API sent it in, the currency's own. */
20
28
  export function toCents(r) {
21
29
  const num = r.n * 100n;
22
30
  let q = num / r.d; // truncates toward zero
@@ -1,8 +1,9 @@
1
+ import { type Rational } from "./decimal.js";
1
2
  import { type CartDto, type CategoryRefDto, type CheckoutCartDto, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type OrderDto } from "./types.js";
2
- /** An item and its unit price in cents, so a payload's `value` sums exact cents rather than re-reading floats. */
3
+ /** An item and its exact unit price, so a payload's `value` sums exact amounts rather than re-reading floats. */
3
4
  export type MappedItem = {
4
5
  item: Ga4Item;
5
- priceCents: bigint;
6
+ unitPrice: Rational;
6
7
  };
7
8
  /**
8
9
  * One GA4 item from a card (an impression: the family sku and from-price), a product with its selected variant, a cart
@@ -13,8 +14,9 @@ export declare function toGa4Item(source: Ga4ItemSource, opts?: Ga4ItemOptions):
13
14
  export declare function mapItem(source: Ga4ItemSource, opts?: Ga4ItemOptions): MappedItem;
14
15
  /**
15
16
  * A whole GA4 payload: `{ currency, value, items }` for a cart; plus `transaction_id`, `tax` and `shipping` for an order
16
- * (`purchase`). Each line keeps its own quantity and id; `index` is its position. Only product lines are items.
17
- * `value` is the sum of the emitted items' rounded prices × quantities, not gross minus tax. An order's `tax` is its
17
+ * (`purchase`). Each line keeps its own quantity and id; `index` is its position. Only product lines are items, and of a cart only the
18
+ * lines its context still sells: an `unavailable` cart line (market model §13: no longer covered there) has no price and is no item.
19
+ * `value` is the sum of the emitted items' prices × quantities, exactly, not gross minus tax. An order's `tax` is its
18
20
  * recorded VAT and its `shipping` comes from its shipping lines, each netted at its own rate and rounded once.
19
21
  */
20
22
  export declare function toGa4Ecommerce(source: CartDto, opts?: Ga4SharedOptions): Ga4Ecommerce;
@@ -1,4 +1,4 @@
1
- import { add, centsToNumber, netOf, parseDecimal, toCents } from "./decimal.js";
1
+ import { add, centsToNumber, netOf, parseDecimal, toCents, toNumber } from "./decimal.js";
2
2
  import { productFacts } from "./facts.js";
3
3
  import { AnalyticsError, } from "./types.js";
4
4
  /**
@@ -13,8 +13,8 @@ export function mapItem(source, opts = {}) {
13
13
  const line = lineOf(source);
14
14
  const facts = productFacts(source);
15
15
  const quantity = opts.quantity === undefined ? line.quantity : checkedQuantity(opts.quantity);
16
- const priceCents = toCents(opts.tax === "include" ? line.gross : netOf(line.gross, line.vatRate));
17
- const item = { item_id: opts.itemId ?? line.id, item_name: line.name, price: centsToNumber(priceCents), quantity };
16
+ const unitPrice = amountOf(line, opts.tax);
17
+ const item = { item_id: opts.itemId ?? line.id, item_name: line.name, price: toNumber(unitPrice), quantity };
18
18
  if (facts.brand)
19
19
  item.item_brand = facts.brand.label;
20
20
  const variant = itemVariant(line.options);
@@ -33,19 +33,21 @@ export function mapItem(source, opts = {}) {
33
33
  item.item_list_id = opts.itemListId;
34
34
  if (opts.itemListName !== undefined)
35
35
  item.item_list_name = opts.itemListName;
36
- return { item, priceCents };
36
+ return { item, unitPrice };
37
37
  }
38
38
  export function toGa4Ecommerce(source, opts = {}) {
39
39
  const isOrder = "totals" in source && "reference" in source;
40
- const lines = isOrder ? source.lines.filter(l => l.type === "product") : source.lines;
40
+ const lines = isOrder
41
+ ? source.lines.filter(l => l.type === "product")
42
+ : source.lines.filter(l => l.availability !== "unavailable");
41
43
  // Only the shared options: an options object that also carries item overrides (quantity, itemId, index) must never
42
44
  // rewrite every line's identity or quantity.
43
45
  const shared = {
44
46
  tax: opts.tax, affiliation: opts.affiliation, itemListId: opts.itemListId, itemListName: opts.itemListName, pickCategory: opts.pickCategory,
45
47
  };
46
48
  const mapped = lines.map((l, index) => mapItem(l, { ...shared, index }));
47
- const valueCents = mapped.reduce((sum, m) => sum + m.priceCents * BigInt(m.item.quantity), 0n);
48
- const payload = { currency: source.currency, value: centsToNumber(valueCents), items: mapped.map(m => m.item) };
49
+ const value = mapped.reduce((sum, m) => add(sum, { n: m.unitPrice.n * BigInt(m.item.quantity), d: m.unitPrice.d }), { n: 0n, d: 1n });
50
+ const payload = { currency: source.currency, value: toNumber(value), items: mapped.map(m => m.item) };
49
51
  if (!isOrder)
50
52
  return payload;
51
53
  const order = source;
@@ -63,29 +65,47 @@ function lineOf(source) {
63
65
  const l = source;
64
66
  if (l.type !== "product")
65
67
  throw new AnalyticsError(`an order line of type "${l.type}" is not an item`);
66
- return { id: l.sku, name: l.name, gross: parseDecimal(l.unitPriceInc), vatRate: parseDecimal(l.vatRate), quantity: sourceQuantity(l.quantity), options: l.options };
68
+ return { id: l.sku, name: l.name, ...grossMoney(l.unitPriceInc, l.vatRate), quantity: sourceQuantity(l.quantity), options: l.options };
67
69
  }
68
70
  if (typeof s.unitPriceInc === "string" && typeof s.productId === "string" && typeof s.type !== "string") {
69
- // A checkout cart line: the snapshot's own amounts (`unitPriceInc`, `vatRate`), not a PriceDto, and the options on the line.
71
+ // A checkout cart line: the snapshot's own amounts (`unitPriceInc`, `vatRate`), not a price pair, and the options on the line.
70
72
  const l = source;
71
- return { id: l.sku, name: l.name, gross: parseDecimal(l.unitPriceInc), vatRate: parseDecimal(l.vatRate), quantity: sourceQuantity(l.quantity), options: l.options };
73
+ return { id: l.sku, name: l.name, ...grossMoney(l.unitPriceInc, l.vatRate), quantity: sourceQuantity(l.quantity), options: l.options };
72
74
  }
73
75
  if (typeof s.variant === "object" && s.variant !== null && "selectedOptions" in s.variant) {
74
76
  if ("quantity" in s) {
77
+ // A cart line: its unit pair in the cart's context (market model §8.1), as a card's — never a computed amount.
75
78
  const l = source;
76
- return { id: l.sku, name: l.product.name, ...money(l.price), quantity: sourceQuantity(l.quantity), options: l.variant.selectedOptions };
79
+ return { id: l.sku, name: l.product.name, ...pairMoney(l.unitPrice, `cart line ${l.sku}`), quantity: sourceQuantity(l.quantity), options: l.variant.selectedOptions };
77
80
  }
81
+ // A selected variant: its own offer's pair in the response's context (market model §8.2), like a card's.
78
82
  const { product, variant } = source;
79
- if (!variant.price)
80
- throw new AnalyticsError(`variant ${variant.sku} has no price on this market`);
81
- return { id: variant.sku, name: product.name, ...money(variant.price), quantity: 1, options: variant.selectedOptions };
83
+ return { id: variant.sku, name: product.name, ...pairMoney(variant.offer.price, `variant ${variant.sku}`), quantity: 1, options: variant.selectedOptions };
82
84
  }
85
+ // A card: its from-price pair on the response's price basis (market model §8.2), never a computed amount.
83
86
  const c = source;
84
- if (!c.price)
85
- throw new AnalyticsError(`product ${c.sku} has no price on this market`);
86
- return { id: c.sku, name: c.name, ...money(c.price), quantity: 1, options: null };
87
+ return { id: c.sku, name: c.name, ...pairMoney(c.priceFrom?.price, `product ${c.sku}`), quantity: 1, options: null };
88
+ }
89
+ /** A pair's own sides; a partial pair (market model M35) lacks one, and an unpriced one both, which throws. */
90
+ function pairMoney(pair, what) {
91
+ if (!pair?.inc && !pair?.ex)
92
+ throw new AnalyticsError(`${what} has no price here`);
93
+ return { gross: pair.inc ? parseDecimal(pair.inc) : null, net: pair.ex ? parseDecimal(pair.ex) : null };
94
+ }
95
+ const toTwoDecimals = (r) => ({ n: toCents(r), d: 100n });
96
+ /** A checkout or order line's gross, and its net at the line's rate, each rounded to two decimals. */
97
+ function grossMoney(inc, vatRate) {
98
+ const gross = parseDecimal(inc);
99
+ return { gross: toTwoDecimals(gross), net: toTwoDecimals(netOf(gross, parseDecimal(vatRate))) };
100
+ }
101
+ /** The line's amount for the tax option; the side a partial price lacks throws. */
102
+ function amountOf(line, tax) {
103
+ const include = tax === "include";
104
+ const amount = include ? line.gross : line.net;
105
+ if (amount === null)
106
+ throw new AnalyticsError(`${line.id} has no price ${include ? "with" : "without"} tax (a partial price); map it with tax: "${include ? "exclude" : "include"}"`);
107
+ return amount;
87
108
  }
88
- const money = (p) => ({ gross: parseDecimal(p.amount), vatRate: parseDecimal(p.vatRate) });
89
109
  /** The generated quantity is `number | string`. */
90
110
  function sourceQuantity(q) {
91
111
  const n = typeof q === "number" ? q : Number(q);
@@ -12,7 +12,7 @@ export type CheckoutCartLineDto = Schemas["CheckoutCartLineDto"];
12
12
  export type BrandDto = Schemas["BrandDto"];
13
13
  export type CategoryRefDto = Schemas["CategoryRefDto"];
14
14
  export type AttributeDto = Schemas["AttributeDto"];
15
- export type PriceDto = Schemas["PriceDto"];
15
+ export type PricePairDto = Schemas["PricePairDto"];
16
16
  /** A product page with the variant the shopper selected. */
17
17
  export type SelectedVariant = {
18
18
  product: ProductDto;
@@ -6,7 +6,7 @@ export type CheckoutWidgetDto = components["schemas"]["CheckoutWidgetDto"];
6
6
  export type CheckoutShippingViewDto = components["schemas"]["CheckoutShippingViewDto"];
7
7
  export type OrderDto = components["schemas"]["OrderDto"];
8
8
  export type StartCheckoutRequest = components["schemas"]["StartCheckoutRequest"];
9
- /** The problem body; `cart` is the corrected cart on 409 CHECKOUT_CART_CHANGED (an extension, so it is typed loosely here). */
9
+ /** The problem body; `cart` is the corrected cart on 409 CHECKOUT_CART_CHANGED and the cart as stored on 409 CONTEXT_MISMATCH (an extension, so it is typed loosely here). */
10
10
  export type StorefrontProblem = components["schemas"]["StorefrontProblem"] & {
11
11
  cart?: unknown;
12
12
  };
@@ -20,6 +20,17 @@ export type CheckoutResult = {
20
20
  problem: StorefrontProblem;
21
21
  retryAfter?: number;
22
22
  };
23
+ /** `409 CONTEXT_MISMATCH` from `POST /v1/checkout`: the request's `X-Market` or `X-Country` is not the cart's stored market or country. The
24
+ * message names the cart's and `problem.cart` is the cart; only `POST /v1/cart/context` moves it. */
25
+ export type CheckoutContextMismatchError = Extract<CheckoutResult, {
26
+ ok: false;
27
+ }> & {
28
+ status: 409;
29
+ problem: StorefrontProblem & {
30
+ code: "CONTEXT_MISMATCH";
31
+ };
32
+ };
33
+ export declare function isCheckoutContextMismatch(result: CheckoutResult): result is CheckoutContextMismatchError;
23
34
  export type StatusResult = {
24
35
  ok: true;
25
36
  data: CheckoutStatusDto;
@@ -1,3 +1,6 @@
1
+ export function isCheckoutContextMismatch(result) {
2
+ return !result.ok && result.status === 409 && result.problem.code === "CONTEXT_MISMATCH";
3
+ }
1
4
  /** `Retry-After` in seconds: a delay, or an HTTP-date turned into one; garbage is 1 s, absence is undefined. */
2
5
  export function retryAfterSeconds(response, now = Date.now) {
3
6
  const raw = response.headers.get("Retry-After");
@@ -0,0 +1,23 @@
1
+ import type { components } from "./schema";
2
+ export type BindingDto = components["schemas"]["BindingDto"];
3
+ /**
4
+ * The commerce context of a client (market model §6.1, §18.5), fixed for the client's life: a page binding from `GET /v1/site`
5
+ * (`bindings[]`), or the values one by one. Each one left out is left to the API (§6.2): market → the only market or the default
6
+ * binding's, country → the market's only or default one, locale → the country's default.
7
+ */
8
+ export type CommerceContextOptions = {
9
+ /** Market code, e.g. "eur". */
10
+ market?: string;
11
+ /** ISO country, e.g. "DE". Needed to create a cart or check out on a market with several countries. */
12
+ country?: string;
13
+ /** Locale tag as the site spells it, e.g. "sv". */
14
+ locale?: string;
15
+ binding?: never;
16
+ } | {
17
+ binding: BindingDto;
18
+ market?: never;
19
+ country?: never;
20
+ locale?: never;
21
+ };
22
+ /** The context headers of a client, read once: what the caller's options object does later changes nothing. */
23
+ export declare function commerceHeaders(context: CommerceContextOptions): readonly (readonly [string, string])[];
@@ -0,0 +1,6 @@
1
+ /** The context headers of a client, read once: what the caller's options object does later changes nothing. */
2
+ export function commerceHeaders(context) {
3
+ const { market, country, locale } = context.binding ?? context;
4
+ const headers = [["X-Market", market], ["X-Country", country], ["X-Locale", locale]];
5
+ return headers.filter((h) => Boolean(h[1]));
6
+ }
package/dist/filters.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import type { components } from "./schema";
2
+ type FacetDto = components["schemas"]["FacetDto"];
3
+ type PriceBasisDto = components["schemas"]["PriceBasisDto"];
1
4
  /**
2
5
  * The list grammar of a `filter[<key>]` parameter (list, swatch and hierarchy facets): `,` separates values, `\,` is a
3
6
  * comma inside a value and `\\` a backslash. A value key is the raw value — an option such as "14,2" is the key "14,2" —
@@ -10,3 +13,10 @@ export declare function encodeFilterValues(values: readonly string[]): string;
10
13
  * before anything else (or at the end) kept as it is, values trimmed, empty ones and repeats dropped.
11
14
  */
12
15
  export declare function decodeFilterValues(param: string): string[];
16
+ /**
17
+ * The basis a price facet's bounds — and a `filter[price]` built from them — are stated on: the request context's display
18
+ * basis (`inc` or `ex`) and currency, which the API sets on the price facet only. A slider labels itself from this ("excl.
19
+ * VAT, SEK" for a dealer, "incl. VAT, EUR" for a consumer) rather than assuming a basis; null for any other facet.
20
+ */
21
+ export declare function priceFacetBasis(facet: FacetDto): PriceBasisDto | null;
22
+ export {};
package/dist/filters.js CHANGED
@@ -37,3 +37,11 @@ export function decodeFilterValues(param) {
37
37
  push();
38
38
  return values;
39
39
  }
40
+ /**
41
+ * The basis a price facet's bounds — and a `filter[price]` built from them — are stated on: the request context's display
42
+ * basis (`inc` or `ex`) and currency, which the API sets on the price facet only. A slider labels itself from this ("excl.
43
+ * VAT, SEK" for a dealer, "incl. VAT, EUR" for a consumer) rather than assuming a basis; null for any other facet.
44
+ */
45
+ export function priceFacetBasis(facet) {
46
+ return facet.range !== null && facet.priceBasis ? facet.priceBasis : null;
47
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { type Client } from "openapi-fetch";
2
2
  import type { paths } from "./schema";
3
+ import { type CommerceContextOptions } from "./context.js";
3
4
  export type { paths, components } from "./schema";
5
+ export type { BindingDto, CommerceContextOptions } from "./context.js";
4
6
  /**
5
7
  * The API requires X-Site-Key on every call, so the generated types demand `params.header` at every call site —
6
- * but this client sets all three headers itself. These map the generated paths to the same operations with the
8
+ * but this client sets the key and the commerce context itself. These map the generated paths to the same operations with the
7
9
  * header block optional: the contract stays honest, and the caller does not repeat the key on every request.
8
10
  */
9
11
  type HeadersOptional<Params> = Omit<Params, "header"> & (Params extends {
@@ -22,18 +24,15 @@ export type StorefrontPaths = {
22
24
  } : paths[Path][Method];
23
25
  };
24
26
  };
25
- export interface StorefrontClientOptions {
27
+ export type StorefrontClientOptions = {
26
28
  /** e.g. https://api.storefront.asstio.com */
27
29
  baseUrl: string;
28
30
  /** publishable (browser), preview (draft mode) or secret (server only) site key */
29
31
  siteKey: string;
30
- /** market code, e.g. "se"; omit for the site's default market */
31
- market?: string;
32
- /** BCP-47 tag; omit to use the market's language */
33
- acceptLanguage?: string;
34
32
  fetch?: typeof fetch;
35
- }
36
- /** Thin typed client: every call carries X-Site-Key / X-Market / Accept-Language. Works in browsers and on the server. */
33
+ } & CommerceContextOptions;
34
+ /** Thin typed client: every call carries X-Site-Key and the context (X-Market / X-Country / X-Locale) fixed here: one client per context.
35
+ * Works in browsers and on the server. */
37
36
  export declare function createStorefrontClient(opts: StorefrontClientOptions): Client<StorefrontPaths>;
38
37
  /** The `filter` query parameter of a listing call: one facet key to one value, as the generated types spell it. */
39
38
  export type FilterSelection = NonNullable<NonNullable<paths["/v1/products"]["get"]["parameters"]["query"]>["filter"]>;
@@ -45,16 +44,19 @@ export type FilterSelection = NonNullable<NonNullable<paths["/v1/products"]["get
45
44
  * The client sends it as `filter[brand]=dior,chanel` (deepObject), which is what the API reads.
46
45
  */
47
46
  export declare function filterQuery(selection: Record<string, string | string[] | undefined>): FilterSelection;
48
- export { decodeFilterValues, encodeFilterValues } from "./filters.js";
47
+ export { decodeFilterValues, encodeFilterValues, priceFacetBasis } from "./filters.js";
49
48
  export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, type FormatFit, type FormattedImage, type MediaConfig, type MediaDescriptor, type MediaFormat, type PictureSources, type SlotSize, type VideoSource, } from "./media.js";
50
49
  export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, liveShippingFor, startCheckout, syncCheckout, type CheckoutClient, type ShopperOption, type StartCheckoutOptions, type SyncRetryOptions, type AwaitCompletionOptions } from "./checkout/server.js";
51
- export { retryAfterSeconds, type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type CheckoutShippingViewDto, type LiveShippingResult, type CheckoutStatusDto, type CheckoutWidgetDto, type OrderDto, type OrderResult, type StatusResult, type StorefrontProblem } from "./checkout/types.js";
50
+ export { isCheckoutContextMismatch, retryAfterSeconds, type CheckoutContextMismatchError, type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type CheckoutShippingViewDto, type LiveShippingResult, type CheckoutStatusDto, type CheckoutWidgetDto, type OrderDto, type OrderResult, type StatusResult, type StorefrontProblem } from "./checkout/types.js";
52
51
  export { mountCheckout, type CheckoutHandle, type MountOptions, type MinimalDocument, type MinimalElement, type MinimalScript } from "./checkout/browser.js";
53
52
  export { controllers, type CheckoutController, type ProviderWindow } from "./checkout/controllers.js";
53
+ export { displayPrice, isBuyable, isPriceVisible, type DisplayPrice, type OfferDto } from "./offer.js";
54
54
  export { breadcrumbJsonLd } from "./seo/breadcrumb.js";
55
55
  export { brandHead, collectionHead, productHead, toNextMetadata } from "./seo/head.js";
56
56
  export { gtinJsonLd } from "./gtin.js";
57
+ export { hreflangFor } from "./seo/locales.js";
57
58
  export { productJsonLd } from "./seo/product.js";
59
+ export { requireBinding } from "./seo/shared.js";
58
60
  export { sitemapEntries, toNextSitemap, type NextSitemapEntry, type SitemapOptions, type SitemapResult, type SitemapType, type SitemapUrl } from "./seo/sitemap.js";
59
61
  export { jsonLdScript } from "./seo/serialize.js";
60
62
  export { productFacts } from "./analytics/facts.js";
package/dist/index.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import createClient from "openapi-fetch";
2
+ import { commerceHeaders } from "./context.js";
2
3
  import { encodeFilterValues } from "./filters.js";
3
- /** Thin typed client: every call carries X-Site-Key / X-Market / Accept-Language. Works in browsers and on the server. */
4
+ /** Thin typed client: every call carries X-Site-Key and the context (X-Market / X-Country / X-Locale) fixed here: one client per context.
5
+ * Works in browsers and on the server. */
4
6
  export function createStorefrontClient(opts) {
5
7
  const client = createClient({ baseUrl: opts.baseUrl.replace(/\/$/, ""), fetch: opts.fetch });
8
+ const context = commerceHeaders(opts);
6
9
  const headers = {
7
10
  onRequest({ request }) {
8
11
  request.headers.set("X-Site-Key", opts.siteKey);
9
- if (opts.market)
10
- request.headers.set("X-Market", opts.market);
11
- if (opts.acceptLanguage)
12
- request.headers.set("Accept-Language", opts.acceptLanguage);
12
+ for (const [name, value] of context)
13
+ request.headers.set(name, value);
13
14
  return request;
14
15
  },
15
16
  };
@@ -34,16 +35,19 @@ export function filterQuery(selection) {
34
35
  }
35
36
  return out;
36
37
  }
37
- export { decodeFilterValues, encodeFilterValues } from "./filters.js";
38
+ export { decodeFilterValues, encodeFilterValues, priceFacetBasis } from "./filters.js";
38
39
  export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, } from "./media.js";
39
40
  export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, liveShippingFor, startCheckout, syncCheckout } from "./checkout/server.js"; // SyncRetryOptions *(plan review 3 #5)*
40
- export { retryAfterSeconds } from "./checkout/types.js";
41
+ export { isCheckoutContextMismatch, retryAfterSeconds } from "./checkout/types.js";
41
42
  export { mountCheckout } from "./checkout/browser.js";
42
43
  export { controllers } from "./checkout/controllers.js";
44
+ export { displayPrice, isBuyable, isPriceVisible } from "./offer.js";
43
45
  export { breadcrumbJsonLd } from "./seo/breadcrumb.js";
44
46
  export { brandHead, collectionHead, productHead, toNextMetadata } from "./seo/head.js";
45
47
  export { gtinJsonLd } from "./gtin.js";
48
+ export { hreflangFor } from "./seo/locales.js";
46
49
  export { productJsonLd } from "./seo/product.js";
50
+ export { requireBinding } from "./seo/shared.js";
47
51
  export { sitemapEntries, toNextSitemap } from "./seo/sitemap.js";
48
52
  export { jsonLdScript } from "./seo/serialize.js";
49
53
  export { productFacts } from "./analytics/facts.js";
@@ -0,0 +1,16 @@
1
+ import type { components } from "./schema";
2
+ export type OfferDto = components["schemas"]["OfferDto"];
3
+ /** One amount to show, on the basis the API chose for this context. */
4
+ export type DisplayPrice = {
5
+ amount: string;
6
+ basis: "inc" | "ex";
7
+ currency: string;
8
+ };
9
+ /** The amount to show (market model §8.2): the offer's `displayBasis` side of its pair, as the API sent it. Null when the offer is not
10
+ * `available`, or when the pair lacks that side: never the other side, and never a computed amount. */
11
+ export declare function displayPrice(offer: OfferDto): DisplayPrice | null;
12
+ /** Whether a price can be shown: a partial price can be, and still not be buyable. */
13
+ export declare function isPriceVisible(offer: OfferDto): boolean;
14
+ /** Whether the buy button works: the offer is `available` and so is its `purchaseStatus`, which already folds in permission, freshness,
15
+ * sellability and a complete price. Never read stock or the price for this. */
16
+ export declare function isBuyable(offer: OfferDto): boolean;
package/dist/offer.js ADDED
@@ -0,0 +1,17 @@
1
+ /** The amount to show (market model §8.2): the offer's `displayBasis` side of its pair, as the API sent it. Null when the offer is not
2
+ * `available`, or when the pair lacks that side: never the other side, and never a computed amount. */
3
+ export function displayPrice(offer) {
4
+ if (offer.offerStatus !== "available")
5
+ return null;
6
+ const amount = offer.price?.[offer.displayBasis];
7
+ return amount ? { amount, basis: offer.displayBasis, currency: offer.price.currency } : null;
8
+ }
9
+ /** Whether a price can be shown: a partial price can be, and still not be buyable. */
10
+ export function isPriceVisible(offer) {
11
+ return displayPrice(offer) !== null;
12
+ }
13
+ /** Whether the buy button works: the offer is `available` and so is its `purchaseStatus`, which already folds in permission, freshness,
14
+ * sellability and a complete price. Never read stock or the price for this. */
15
+ export function isBuyable(offer) {
16
+ return offer.offerStatus === "available" && offer.purchaseStatus === "available";
17
+ }