@asstio-dev/storefront 0.32.0 → 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.
package/README.md CHANGED
@@ -15,7 +15,7 @@ served publicly at <https://api.storefront.asstio.com/docs/onboarding>. This fil
15
15
  package.
16
16
 
17
17
  ```ts
18
- import { createStorefrontClient, filterQuery } from "@asstio-dev/storefront";
18
+ import { createStorefrontClient, filterQuery, requireBinding } from "@asstio-dev/storefront";
19
19
 
20
20
  // Your key: process.env.STOREFRONT_SITE_KEY on a server, import.meta.env.VITE_… in a bundler.
21
21
  declare const STOREFRONT_SITE_KEY: string;
@@ -24,7 +24,8 @@ const storefront = createStorefrontClient({
24
24
  baseUrl: "https://api.storefront.asstio.com",
25
25
  siteKey: STOREFRONT_SITE_KEY, // publishable in the browser, secret only on the server
26
26
  market: "se",
27
- acceptLanguage: "sv-SE",
27
+ country: "SE",
28
+ locale: "sv",
28
29
  });
29
30
 
30
31
  const { data, error } = await storefront.GET("/v1/products", {
@@ -43,6 +44,18 @@ if (error) throw new Error(`${error.code}: ${error.detail ?? ""}`);
43
44
  console.log(data.total, data.items, data.warnings);
44
45
  ```
45
46
 
47
+ A client's context is fixed when it is built, so a site with several contexts has one client per context. Leave out what the
48
+ API may resolve (see [Headers](#headers)), or pass a page binding from `GET /v1/site` instead of the three values:
49
+
50
+ ```ts
51
+ const { data: siteInfo } = await storefront.GET("/v1/site", {});
52
+ const germanInSwedish = createStorefrontClient({
53
+ baseUrl: "https://api.storefront.asstio.com",
54
+ siteKey: STOREFRONT_SITE_KEY,
55
+ binding: requireBinding(siteInfo!, "eur/DE/sv"), // sends X-Market: eur, X-Country: DE, X-Locale: sv
56
+ });
57
+ ```
58
+
46
59
  ```ts
47
60
  // A product with many variants comes back with variants: null and a variantCount; page them:
48
61
  const { data: page } = await storefront.GET("/v1/products/{slugOrId}/variants", {
@@ -191,6 +204,30 @@ const { data: listing } = await storefront.GET("/v1/products", {
191
204
  });
192
205
  ```
193
206
 
207
+ ### Prices and buying
208
+
209
+ Every card, product and variant carries an `offer`. Render it with three helpers instead of reading its fields:
210
+
211
+ ```ts
212
+ import { displayPrice, isBuyable, type OfferDto } from "@asstio-dev/storefront";
213
+
214
+ export function buyBox(offer: OfferDto) {
215
+ // The offer's displayBasis side of its pair: ex for a dealer, inc for a consumer. Null means "show no price", never 0.
216
+ const price = displayPrice(offer); // { amount: "100.00", basis: "ex", currency: "SEK" }
217
+ return {
218
+ label: price ? `${price.amount} ${price.currency} ${price.basis === "ex" ? "excl. VAT" : "incl. VAT"}` : null,
219
+ canBuy: isBuyable(offer),
220
+ };
221
+ }
222
+ ```
223
+
224
+ - `displayPrice` is null when `offerStatus` is not `available` (render `loginRequired` as "Sign in for prices"), and when
225
+ the pair lacks the side to show. It never falls back to the other side or computes an amount. `isPriceVisible(offer)` is
226
+ whether it has an amount.
227
+ - `isBuyable` is `offerStatus` and `purchaseStatus` both `available`. `purchaseStatus` already folds in permission,
228
+ freshness, sellability and a complete price, so a partial price can be visible and still not buyable. Never decide it
229
+ from `stock`: the API sends no public stock quantity, and zero stock is buyable on a backorder site.
230
+
194
231
  ### Variant links
195
232
 
196
233
  A link to one variant is the product URL plus `?variant={variantId}` (the id from `variants[].id`); `variant.url` is
@@ -207,7 +244,7 @@ if (product?.redirectTo) permanentRedirect(redirectTarget(product.url!, raw));
207
244
  // product.selectedVariantStatus: null (absent: your default), "matched" (preselect product.selectedVariant),
208
245
  // "notSoldHere" (show it as unavailable here, preselect nothing), "unknown" (note that it is gone, preselect nothing).
209
246
  if (!product) notFound();
210
- const ld = productJsonLd(product, { site, market: "se" });
247
+ const ld = productJsonLd(product, { site, binding: "se/SE/sv" });
211
248
  ```
212
249
 
213
250
  Pass the **raw search string** to `readVariantParam` and `redirectTarget` when you can (for example from a
@@ -221,7 +258,7 @@ Pure helpers that compute schema.org JSON-LD and `<head>` data from the DTOs. Th
221
258
  writes the markup. No backend call, no DOM, no dependency.
222
259
 
223
260
  **Product JSON-LD.** `productJsonLd` returns a `Product` (one variant) or a `ProductGroup` with one `Product` and
224
- `Offer` per variant sold on the market. Each variant's link is `v.url` from the API (see Variant links above), so no
261
+ `Offer` per variant sold in the page's context. Each variant's link is `v.url` from the API (see Variant links above), so no
225
262
  `variantUrl` hook is needed; pass one only to override it. Google needs a distinct URL per variant, and opening that
226
263
  URL **must preselect the variant** on your page. `productJsonLd` reads `product.selectedVariant` itself.
227
264
  `variesBy` maps your option names to `size`, `color`, `material` or `pattern`; unmapped options still name the
@@ -230,7 +267,7 @@ variants. A product with more than 100 variants arrives with `variants: null`: p
230
267
 
231
268
  ```tsx
232
269
  const ld = productJsonLd(product, {
233
- site, market: "se",
270
+ site, binding: "se/SE/sv", // the site.bindings id the page was requested on
234
271
  variesBy: { Storlek: "size", Färg: "color" },
235
272
  });
236
273
  <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(ld) }} />
@@ -248,21 +285,25 @@ const crumbs = breadcrumbJsonLd(r.breadcrumb, { home: { name: "Hem", url: "https
248
285
 
249
286
  **Head.** `productHead`, `collectionHead` (takes your own `description`; the API has none for collections) and
250
287
  `brandHead` return a framework-neutral `SeoHead`: title, description (cut at 160 characters on a word boundary),
251
- canonical, hreflang alternates (keyed `language-COUNTRY`, `x-default` from the site's default market; override with
252
- `xDefault: "<market>"` or `false`), and Open Graph / Twitter meta. `titleTemplate: "%s | Moory"` applies to `<title>`
253
- only, never to the social titles. In Next.js:
288
+ canonical (the page's own URL on its binding, base path and prefix included), `robots: "noindex,follow"` on a secondary
289
+ (non-indexable) binding, hreflang alternates (the API's `alternates`: the indexable bindings where the page exists
290
+ publicly, `x-default` from the site's default binding; override with `xDefault: "<binding id>"` or `false`; none on a
291
+ secondary binding), and Open Graph / Twitter meta. `binding` is the `site.bindings` id the page was requested on
292
+ (its `X-Market`, `X-Country` and `X-Locale`). `titleTemplate: "%s | Moory"` applies to `<title>` only, never to the
293
+ social titles. In Next.js:
254
294
 
255
295
  ```ts
256
296
  export async function generateMetadata(): Promise<Metadata> {
257
- return toNextMetadata(productHead(product, { site, market: "se", titleTemplate: "%s | Moory" }));
297
+ return toNextMetadata(productHead(product, { site, binding: "se/SE/sv", titleTemplate: "%s | Moory" }));
258
298
  }
259
299
  ```
260
300
 
261
301
  Titles come out as `{ absolute }` on purpose, so a layout's `title.template` does not apply a second time.
262
302
 
263
- **Sitemap.** `sitemapEntries` reads every sitemap URL of one market: `GET /v1/site` once, then for each type
264
- (`product`, `collection`, `brand`; default all three) a cursor walk of `GET /v1/sitemap` from `cursor=start` along
265
- `nextCursor`, with no 10 000-row ceiling. `toNextSitemap` turns the result into Next.js's `app/sitemap.ts` shape:
303
+ **Sitemap.** `sitemapEntries` reads every sitemap URL of one binding: `GET /v1/site`, then for each type
304
+ (`product`, `collection`, `brand`; default all three) a cursor walk of `GET /v1/sitemap?binding=` from `cursor=start` along
305
+ `nextCursor`, with no 10 000-row ceiling. `since` (optional) limits it to entries changed at or after a timestamp. A secondary
306
+ (non-indexable) binding has an empty sitemap. `toNextSitemap` turns the result into Next.js's `app/sitemap.ts` shape:
266
307
 
267
308
  ```ts
268
309
  import type { MetadataRoute } from "next";
@@ -270,32 +311,42 @@ import type { MetadataRoute } from "next";
270
311
  export const dynamic = "force-dynamic";
271
312
 
272
313
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
273
- const market = process.env.STOREFRONT_MARKET!; // the same market the client sends as X-Market
274
- const api = createStorefrontClient({ baseUrl: API_URL, siteKey: SITE_KEY, market });
275
- const result = await sitemapEntries(api, { market });
276
- if (!result.complete) console.warn("Sitemap: Storefront API unavailable; serving a partial sitemap.");
314
+ const binding = process.env.STOREFRONT_BINDING!; // a site.bindings id, e.g. "se/SE/sv": one sitemap per binding
315
+ const api = createStorefrontClient({ baseUrl: API_URL, siteKey: SITE_KEY });
316
+ const result = await sitemapEntries(api, { binding });
317
+ if (!result.complete) console.warn("Sitemap: incomplete walk");
277
318
  return toNextSitemap(result);
278
319
  }
279
320
  ```
280
321
 
281
- - `market` is required and must be the client's market. It is matched case-insensitively against the site's markets;
282
- an unknown one throws `SeoError` before any page is read. Never pass the site's default instead of the request's.
283
- - hreflang tags are `language-COUNTRY` (`sv-SE`, never the market code `se`), each entry lists itself, and `x-default`
284
- appears only when the site's default market has the page. Two markets with the same tag and different URLs drop that
285
- entry's alternates; the URL stays listed.
322
+ - `binding` is required and must be one of `site.bindings`; an unknown one throws `SeoError` before any page is read.
323
+ - hreflang tags are the bindings' compiled tags (`sv-SE`, `en-DE` for `en-GB` shipped to Germany; never the market code
324
+ `se`), each entry lists itself when its binding is indexable, and `x-default` appears only when the site's default
325
+ binding has the page. Two bindings with the same tag and different URLs drop that entry's alternates; the URL stays
326
+ listed.
286
327
  - `lastModified` is left out when the API reports the Unix epoch (collections carry no timestamp of their own).
287
- - `complete: false`: a transport failure, a 5xx, `INDEX_UNAVAILABLE` or `SITE_REGISTRY_WARMING` stopped the walk; the
288
- entries read so far are returned. Any other problem (a 400 such as `INVALID_CURSOR`) throws.
328
+ - A walk restarts once, whole: on `409 CURSOR_EXPIRED` (a publish retired the generation, or a cursor is over an hour old),
329
+ and when its responses name more than one `X-Config-Version` (a publish between two types). The new walk's entries
330
+ replace the old ones. If a publish removed the binding, the restart throws `SeoError`.
331
+ - `complete: true`: every type's walk finished on its own catalog generation, and the config version did not change
332
+ between types. It does not promise one generation for the whole result: the API names no generation in a response, so a
333
+ rebuild at the same config version between two types is not detected. That is acceptable for a sitemap, because two
334
+ generations at one config version carry the same URL model and the next render reads the newer one.
335
+ - `complete: false`: a transport failure, a 5xx, `INDEX_UNAVAILABLE` or `SITE_REGISTRY_WARMING` stopped the walk, or the
336
+ restart could not meet that promise either. `entries` is what the last walk read, so a restart that stopped early can
337
+ hold less than the first walk did. Only a first walk that finished replaces such a restart, which gives every entry when
338
+ a config version change was the only trouble. Any other problem throws, a 400 such as `INVALID_CURSOR` included. A preview
339
+ key can meet that one when its draft is created or published mid-walk: run the walk again.
289
340
  - An API older than cursor paging throws "the Storefront API does not support sitemap cursors; deploy the API first"
290
341
  rather than ending the walk after one page.
291
342
  - A walk is an eventually consistent crawl, not a snapshot: a product added mid-walk may wait for the next render.
292
343
 
293
344
  **Errors.** `SeoError` means a caller or configuration error: `redirectTo` is set (answer 301 instead), an unknown
294
- market, a bad `variesBy` value, a variant URL (`v.url` or the optional `variantUrl` hook) that is missing, not an absolute http(s) URL or repeated for two variants, or
295
- two markets with the same `language-COUNTRY` and different URLs. Catch it at your SEO boundary and fix the market
296
- config. Holes in the data never throw: a missing image, description or price only leaves that part out.
345
+ binding, a bad `variesBy` value, a variant URL (`v.url` or the optional `variantUrl` hook) that is missing, not an absolute http(s) URL or repeated for two variants, or
346
+ two bindings with the same hreflang and different URLs. Catch it at your SEO boundary and fix the site's
347
+ bindings. Holes in the data never throw: a missing image, description or price only leaves that part out.
297
348
 
298
- hreflang sets are only as reciprocal as the API's `alternateUrls`; the SDK renders what the API gives.
349
+ hreflang sets are only as reciprocal as the API's `alternates`; the SDK renders what the API gives.
299
350
 
300
351
  ### Search events
301
352
 
@@ -360,8 +411,11 @@ know nothing about consent or a data layer.
360
411
  | order line (product) | the line `sku` | the variant's brand | the unit price | the line's | options |
361
412
 
362
413
  - A card is a family impression; later events carry the purchased sku. Pass `itemId` to report at family level.
414
+ - Cards, variants and cart lines read the API's price pair: `ex` by default, `inc` with `tax: "include"`, never computed
415
+ and in the currency's own precision (three decimals in KWD, none in JPY). A partial price without that side throws
416
+ `AnalyticsError`. Checkout and order lines have no pair yet: they net `unitPriceInc` at `vatRate` and round to two decimals.
363
417
  - `price` and `value` are **net of VAT** by default, as GA4 defines them: 2 × 125.00 at 25 % gives `price: 100`,
364
- `value: 200`. `value` is the sum of the items' rounded prices, so it need not match accounting to the öre (3 × 9.99 at
418
+ `value: 200`. `value` is the sum of the items' prices, so it need not match accounting to the öre (3 × 9.99 at
365
419
  25 % gives `price: 7.99`, `value: 23.97`). Pass `tax: "include"` to report gross on purpose.
366
420
  - For `add_to_cart` and `remove_from_cart`, pass `quantity`: the positive number of units added or removed, never the
367
421
  line's new total.
@@ -509,8 +563,9 @@ below), Walley `*.walleypay.com`, plus their `frame-src`/`script-src`/`connect-s
509
563
  integration guide lists the exact hosts.
510
564
 
511
565
  Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `cart`), `CHECKOUT_PREVIEW_KEY`,
512
- `CHECKOUT_MARKET_MISMATCH` (switch the cart's market first, then sync), `OPTION_NOT_AVAILABLE`,
513
- `ORIGIN_NOT_ALLOWED`, `CHECKOUT_UNAVAILABLE` and `CHECKOUT_PROVIDER_UNAVAILABLE` (503, `Retry-After`),
566
+ `CONTEXT_MISMATCH` (409: the request's market or country is not the cart's, the cart is under `cart`; send the cart's, or
567
+ move it with `POST /v1/cart/context`, then sync), `OPTION_NOT_AVAILABLE`,
568
+ `ORIGIN_NOT_ALLOWED`, `CHECKOUT_UNAVAILABLE` (503, with `Retry-After` when it is worth retrying) and `CHECKOUT_PROVIDER_UNAVAILABLE` (503, `Retry-After`),
514
569
  `CHECKOUT_IN_PROGRESS` (409, `Retry-After`), `CHECKOUT_PROVIDER_REJECTED` (502), `CHECKOUT_NOT_FOUND`,
515
570
  `ORDER_NOT_FOUND`, `SITE_KEYS_SYNCING` (503 for a secret key on a freshly created site, `Retry-After`).
516
571
 
@@ -649,13 +704,21 @@ A link opened in another browser has no binding: the answer is `409 SIGN_IN_CONF
649
704
  `SIGN_IN_INVALID`: start a new sign-in. Redirect to the view's `returnPath` (or your default) yourself.
650
705
 
651
706
  **Sessions.** Build a client per request, never a module singleton: `shopperClient(config)` reads the session cookie
652
- (`createShopperClient({ baseUrl, serverKey, market, session, shopperIp })` outside Next.js). `getSession`, `getMe` and
707
+ (`createShopperClient({ baseUrl, serverKey, session, shopperIp, binding })` outside Next.js). `AccountActionConfig` and
708
+ `createShopperClient` take the same context as `createStorefrontClient`: `market`, `country` and `locale`, each optional, or
709
+ a `binding`. `getSession`, `getMe` and
653
710
  `getMyCompany` read who is signed in; `switchPrincipalAction(config, principal)` acts as another principal (`"consumer"` or a
654
711
  membership id), rotates the session and points your cart cookie at the new principal's saved cart, or clears it when that
655
712
  principal has none; `signOutAction(config, { everywhere? })` clears the session, sign-in, cart and
656
713
  browser-cart cookies whatever the API answers. A session that no longer validates is `401 SESSION_INVALID` on **every**
657
714
  route, never a silent signed-out answer: render signed-out, and let the next action clear the cookie.
658
715
 
716
+ **Private data at a session change.** A verify or switch that took effect, and every sign-out, returns
717
+ `dropPrivateData: true`: the shopper is now someone else, so throw away what the browser still holds for the old one (a cart
718
+ view, prices, account data) and read again. After a sign-in that includes the adopted cart: the guest's view of it goes. A
719
+ switch keeps the rotated session and the cart cookie now names the new principal's own cart. A failed verify or switch
720
+ changed nothing and has no such flag. On the server, never put priced private data in a shared cache or ISR.
721
+
659
722
  **Saved carts.** With a session, `POST /v1/cart` returns the principal's saved cart (or creates one) and `GET /v1/me/cart`
660
723
  reads it; the cart routes work on it with its token **and** the session. A saved cart's token without its owner's session —
661
724
  another device signed out, another principal, another account — is `404 NOT_FOUND`, exactly an unknown token; a principal
@@ -712,16 +775,39 @@ export async function beginSignedInCheckout(savedCart: string) {
712
775
 
713
776
  ## Headers
714
777
 
715
- Every call the client makes carries these; you never set them by hand.
778
+ The client sets `X-Site-Key` and the context it was built with: `X-Market`, `X-Country` and `X-Locale`, from `market`,
779
+ `country` and `locale` or from a `binding`. It leaves out what you leave out: the API then resolves it as below, and
780
+ `POST /v1/cart` or `POST /v1/checkout` on a market with several countries answers `COUNTRY_REQUIRED`. The context cannot
781
+ change after the client is built, and it wins over a header passed on a call: another context is another client.
716
782
 
717
783
  | Header | Required | Meaning |
718
784
  | --- | --- | --- |
719
785
  | `X-Site-Key` | yes | Which shop, and with which visibility. `sfk_pub_…` publishable (safe in a browser bundle), `sfk_prv_…` preview (draft content), `sfk_sec_…` secret — **server-side only**: a secret key sent from a page is rejected with `SECRET_KEY_FROM_BROWSER`. |
720
- | `X-Market` | no | Market code, e.g. `se`. Omit for the site's default market. An unknown one is `MARKET_UNKNOWN`. |
721
- | `Accept-Language` | no | BCP-47 tag. Omit to use the market's language. |
786
+ | `X-Market` | no | Market code, e.g. `se`. Omit on a site with one market, or for the default binding's market. Unknown or inactive is `MARKET_NOT_FOUND`, none to fall back on `MARKET_REQUIRED`. |
787
+ | `X-Country` | no | ISO country, e.g. `DE`. Omit when the market has one country; a catalog read falls back to the market's default country, a new cart and checkout answer `COUNTRY_REQUIRED`. Outside the market it is `COUNTRY_NOT_ALLOWED`. |
788
+ | `X-Locale` | no | Locale tag as the site spells it, e.g. `sv`. Omit for the country's default locale. Not allowed there is `LOCALE_NOT_ALLOWED`. |
789
+
790
+ Take all three from the page's binding: `createStorefrontClient({ baseUrl, siteKey, binding })`. `Accept-Language` selects
791
+ nothing and the client does not send it. Only `GET /v1/site` reads it, for `suggestedLocale`; a server can forward the
792
+ shopper's own on that one call with `headers`. A response states its context in `context`, and a cart keeps its own: only
793
+ `POST /v1/cart/context` moves it.
794
+
795
+ The client sets these, so `params.header` is optional at every call site even though the contract marks `X-Site-Key`
796
+ required.
797
+
798
+ ### Moving from markets to bindings
799
+
800
+ The page's identity is now its binding (`{market}/{COUNTRY}/{locale}`), not its market:
722
801
 
723
- The client sets all three, so `params.header` is optional at every call site even though the contract marks
724
- `X-Site-Key` required.
802
+ - `createStorefrontClient`, `createShopperClient` and `AccountActionConfig` lost `acceptLanguage`: pass `locale` or a
803
+ `binding`. `market` is optional on all three, and the three option types are unions, so an `interface` can no longer
804
+ extend them.
805
+ - Every SEO helper and `sitemapEntries` take `binding` (a `site.bindings` id) instead of `market`, and `xDefault` is a binding
806
+ id. **`X-Market` matches in any case; a binding id does not**, in these helpers or in the API's `?binding=`: `SE/SE/sv`
807
+ throws `SeoError`, so copy the id from `site.bindings` instead of building it.
808
+ - `alternateUrls` is gone: read `alternates`, or let the helpers do it.
809
+ - `verifySignInAction`, `switchPrincipalAction` and `signOutAction` return `dropPrivateData` beside their result; see
810
+ [Accounts](#accounts-sign-in-sessions-and-saved-carts).
725
811
 
726
812
  ## Errors
727
813
 
@@ -748,17 +834,22 @@ Every operation documents `400`, `401`, `403`, `404`, `429` and `503`; the entit
748
834
  `COLLECTION_NOT_FOUND`) are called out on the operations that raise them.
749
835
 
750
836
  Codes you will meet: `SITE_KEY_MISSING`, `SITE_KEY_INVALID`, `SITE_INACTIVE`, `SECRET_KEY_FROM_BROWSER`,
751
- `SITE_REGISTRY_WARMING`, `MARKET_UNKNOWN`, `RATE_LIMITED` (with `retryAfterSeconds`, plus a `Retry-After`
837
+ `SITE_REGISTRY_WARMING`, `MARKET_REQUIRED`, `MARKET_NOT_FOUND`, `COUNTRY_REQUIRED`, `COUNTRY_NOT_ALLOWED`, `LOCALE_NOT_ALLOWED`,
838
+ `PRICE_QUERY_UNSUPPORTED`, `STOCK_QUERY_UNSUPPORTED`, `CURSOR_EXPIRED`, `SESSION_INVALID`, `PERMISSION_DENIED` (cart writes only:
839
+ `permission` says `login` or `cart.edit`), `RATE_LIMITED` (with `retryAfterSeconds`, plus a `Retry-After`
752
840
  header), `PRODUCT_NOT_FOUND`, `COLLECTION_NOT_FOUND`, `INVALID_PAGE`, `INVALID_SINCE`, `INVALID_TYPE`, `INVALID_CURSOR`,
753
- `INDEX_UNAVAILABLE` (retry — a preview index is being rebuilt), `NOT_FOUND`, `INVALID_QUERY`,
841
+ `INDEX_UNAVAILABLE` (retry — a preview index is being rebuilt), `PRICE_CONTEXT_NOT_READY` (503, `Retry-After`),
842
+ `PRICE_CONTEXT_UNAVAILABLE` (503), `TAX_UNAVAILABLE` (503, cart add and update), `NOT_FOUND`, `INVALID_QUERY`,
754
843
  `FACET_NOT_FOUND`, `FACET_NOT_SEARCHABLE`,
755
844
  and, from the checkout ([above](#checkout)): `CART_EMPTY`, `CHECKOUT_CART_CHANGED`, `CHECKOUT_PREVIEW_KEY`,
756
- `CHECKOUT_MARKET_MISMATCH`, `OPTION_NOT_AVAILABLE`, `ORIGIN_NOT_ALLOWED`, `CHECKOUT_UNAVAILABLE`,
845
+ `CONTEXT_MISMATCH`, `OPTION_NOT_AVAILABLE`, `ORIGIN_NOT_ALLOWED`, `CHECKOUT_UNAVAILABLE`,
757
846
  `CHECKOUT_PROVIDER_UNAVAILABLE`, `CHECKOUT_IN_PROGRESS`, `CHECKOUT_PROVIDER_REJECTED`, `CHECKOUT_NOT_FOUND`,
758
847
  `ORDER_NOT_FOUND`, `SITE_KEYS_SYNCING`, `CART_EXPIRED`.
759
848
 
760
- `SITE_REGISTRY_WARMING` and `INDEX_UNAVAILABLE` are retries, not bugs in your call, as are the checkout's
761
- `CHECKOUT_IN_PROGRESS`, `CHECKOUT_UNAVAILABLE`, `CHECKOUT_PROVIDER_UNAVAILABLE` and `SITE_KEYS_SYNCING` (each with `Retry-After`).
849
+ `SITE_REGISTRY_WARMING`, `INDEX_UNAVAILABLE` and `PRICE_CONTEXT_NOT_READY` are retries, not bugs in your call, as are the checkout's
850
+ `CHECKOUT_IN_PROGRESS`, `CHECKOUT_PROVIDER_UNAVAILABLE` and `SITE_KEYS_SYNCING` (each with `Retry-After`). `CHECKOUT_UNAVAILABLE` is a
851
+ retry only when it carries `Retry-After`; without it the cart cannot be charged yet (a currency that is not two-decimal, or a
852
+ quote with a rounding component), and retrying does not help.
762
853
 
763
854
  ## Warnings
764
855
 
@@ -795,8 +886,8 @@ listing — both are described under [Search-as-you-type and search-in-facet](#s
795
886
 
796
887
  ## Caching
797
888
 
798
- Read responses carry an `ETag` and `Vary: X-Site-Key, X-Market, Accept-Language` — the answer depends on
799
- all three, so a shared cache must key on all three. Send the ETag back as `If-None-Match` and a matching
889
+ Read responses carry an `ETag` and `Vary: X-Site-Key, X-Market, X-Country, X-Locale` (`GET /v1/site` adds
890
+ `Accept-Language`) — the answer depends on all four, so a shared cache must key on all four. Send the ETag back as `If-None-Match` and a matching
800
891
  request answers `304 Not Modified` with no body:
801
892
 
802
893
  ```ts
@@ -1,6 +1,16 @@
1
+ /**
2
+ * "Lägg till i sparad varukorg" (accounts spec §9.2): sign-in never merges carts, so when the shopper signs in with a browser
3
+ * cart A and already has a saved cart B, the frontend may offer to copy A's lines into B. The copy is ordinary
4
+ * `POST /v1/cart/lines` calls on B under the session, one per line of A, each with `Idempotency-Key: copy:{A}:{lineId}`:
5
+ * a retry (after a failure, or a second click) never doubles a line — B remembers every key it has applied.
6
+ */
7
+ import type { components } from "../schema";
1
8
  import type { AccountProblem, ShopperClient } from "./index.js";
9
+ type CartDto = components["schemas"]["CartDto"];
10
+ type PricePairDto = components["schemas"]["PricePairDto"];
2
11
  /** Line ids of A: `copied` (added to B, or already added by an earlier run), `skipped` (the product is gone or cannot be sold:
3
- * `409`), `failed` (anything else — retry; the keys make copied lines no-ops). */
12
+ * `409`; B's context does not see it: `404 PRODUCT_NOT_FOUND`; its price is incomplete there: `503 TAX_UNAVAILABLE`), `failed`
13
+ * (anything else — retry; the keys make copied lines no-ops). */
4
14
  export interface CopyBrowserCartResult {
5
15
  copied: string[];
6
16
  skipped: string[];
@@ -22,3 +32,24 @@ export declare const copyKey: (browserCartToken: string, lineId: string) => stri
22
32
  * throws `CopyBrowserCartError`.
23
33
  */
24
34
  export declare function copyBrowserCart(client: ShopperClient, browserCartToken: string, savedCartToken: string): Promise<CopyBrowserCartResult>;
35
+ /** A cart's totals as the API states them (market model §8.3, §9.1). */
36
+ export interface CartTotals {
37
+ /** Before the rounding component (`grandTotal.inc − rounding`; ex and tax as the grand total's). */
38
+ subtotal: PricePairDto;
39
+ /** The rounding component as a decimal string: `"0.00"` on a gross-unit cart, a few minor units (e.g. `"-0.01"`) on a net-unit
40
+ * company cart; `null` when the quote is incomplete. */
41
+ rounding: string | null;
42
+ /** What the shopper pays — the receivable, rounding included. On an incomplete quote: the lines' known gross, or `inc: null`. */
43
+ grandTotal: PricePairDto;
44
+ /** `complete`, `partial` or `unavailable`: the worst line. */
45
+ quoteStatus: CartDto["quoteStatus"];
46
+ /** True until every line is completely priced and covered: checkout answers `409 CHECKOUT_CART_CHANGED` meanwhile. */
47
+ checkoutBlocked: boolean;
48
+ }
49
+ /**
50
+ * The cart's totals exactly as the API sends them. Never recompute them from the lines: line pairs are allocations of tax buckets, and
51
+ * on a net-unit cart the receivable is rounded over the whole order, so the sum of the line grosses can differ from `grandTotal.inc` by
52
+ * the `rounding` component (three 0.99 lines at 25 % on three booking codes: lines 3.72, rounding −0.01, grand total 3.71).
53
+ */
54
+ export declare function cartTotals(cart: CartDto): CartTotals;
55
+ export {};
@@ -32,10 +32,14 @@ export async function copyBrowserCart(client, browserCartToken, savedCartToken)
32
32
  headers: { "X-Cart-Token": savedCartToken, "Idempotency-Key": copyKey(browserCartToken, line.id) },
33
33
  body: { sku: line.sku, quantity: Number(line.quantity) },
34
34
  });
35
+ const code = problemOf(error)?.code;
35
36
  if (response.ok)
36
37
  result.copied.push(line.id);
37
- // 409 is the line's own product (PRODUCT_GONE, STOCK_INSUFFICIENT); a lost race (CART_CONFLICT) is worth a retry.
38
- else if (response.status === 409 && problemOf(error)?.code !== "CART_CONFLICT")
38
+ // 409 is the line's own product (PRODUCT_GONE, STOCK_INSUFFICIENT); a lost race (CART_CONFLICT) is worth a retry. B's context
39
+ // (market model §11.1) may not see the product (404 PRODUCT_NOT_FOUND) or price it completely (503 TAX_UNAVAILABLE): no retry helps.
40
+ else if (response.status === 409 && code !== "CART_CONFLICT")
41
+ result.skipped.push(line.id);
42
+ else if ((response.status === 404 && code === "PRODUCT_NOT_FOUND") || (response.status === 503 && code === "TAX_UNAVAILABLE"))
39
43
  result.skipped.push(line.id);
40
44
  else
41
45
  result.failed.push(line.id);
@@ -46,6 +50,20 @@ export async function copyBrowserCart(client, browserCartToken, savedCartToken)
46
50
  }
47
51
  return result;
48
52
  }
53
+ /**
54
+ * The cart's totals exactly as the API sends them. Never recompute them from the lines: line pairs are allocations of tax buckets, and
55
+ * on a net-unit cart the receivable is rounded over the whole order, so the sum of the line grosses can differ from `grandTotal.inc` by
56
+ * the `rounding` component (three 0.99 lines at 25 % on three booking codes: lines 3.72, rounding −0.01, grand total 3.71).
57
+ */
58
+ export function cartTotals(cart) {
59
+ return {
60
+ subtotal: cart.totals.subtotal,
61
+ rounding: cart.totals.rounding,
62
+ grandTotal: cart.totals.grandTotal,
63
+ quoteStatus: cart.quoteStatus,
64
+ checkoutBlocked: cart.checkoutBlocked,
65
+ };
66
+ }
49
67
  function problemOf(error) {
50
68
  return typeof error === "object" && error !== null && typeof error.code === "string" ? error : null;
51
69
  }
@@ -6,6 +6,7 @@
6
6
  * a client is never a module-level singleton, so one shopper's token can never serve another shopper's request.
7
7
  */
8
8
  import { type Client } from "openapi-fetch";
9
+ import { type CommerceContextOptions } from "../context.js";
9
10
  import type { components } from "../schema";
10
11
  import type { StorefrontPaths } from "../index.js";
11
12
  export type SignInStartedDto = components["schemas"]["SignInStartedDto"];
@@ -30,25 +31,21 @@ export type AccountResult<T> = {
30
31
  problem: AccountProblem;
31
32
  retryAfter?: number;
32
33
  };
33
- export interface ShopperClientOptions {
34
+ export type ShopperClientOptions = {
34
35
  /** e.g. https://api.storefront.asstio.com */
35
36
  baseUrl: string;
36
37
  /** The site's server key (`sfk_srv_…`). Server-side only. */
37
38
  serverKey: string;
38
- /** Market code, e.g. "se". */
39
- market: string;
40
39
  /** The request's session token (`sfs_…`) from the session cookie; omit when the shopper is signed out. */
41
40
  session?: string | null;
42
41
  /** The shopper's IP (`shopperIpFrom`); the sign-in rate limits are per shopper IP. `null` when it cannot be known. */
43
42
  shopperIp: string | null;
44
- /** BCP-47 tag; omit to use the market's language. */
45
- acceptLanguage?: string;
46
43
  fetch?: typeof fetch;
47
- }
44
+ } & CommerceContextOptions;
48
45
  /** A client for one request. Construct it per request; never cache it beyond that request. */
49
46
  export type ShopperClient = Client<StorefrontPaths>;
50
47
  /**
51
- * A request-scoped client: every call carries the server key, the market, the shopper IP and (when given) the session, and
48
+ * A request-scoped client: every call carries the server key, the commerce context, the shopper IP and (when given) the session, and
52
49
  * every fetch is `cache: "no-store"` — an account answer is never shared or cached (spec §10.1).
53
50
  */
54
51
  export declare function createShopperClient(opts: ShopperClientOptions): ShopperClient;
@@ -95,6 +92,6 @@ export declare function getSession(client: ShopperClient): Promise<AccountResult
95
92
  export declare function getMe(client: ShopperClient): Promise<AccountResult<AccountDto>>;
96
93
  /** `GET /v1/me/company`: `403 PRINCIPAL_NOT_AVAILABLE` while the principal is the consumer. */
97
94
  export declare function getMyCompany(client: ShopperClient): Promise<AccountResult<MyCompanyDto>>;
98
- export { copyBrowserCart, copyKey, CopyBrowserCartError, type CopyBrowserCartResult } from "./cart.js";
95
+ export { cartTotals, copyBrowserCart, copyKey, CopyBrowserCartError, type CartTotals, type CopyBrowserCartResult } from "./cart.js";
99
96
  export { browserCartCookie, cartCookie, cookieSecurity, readSignInCookie, serializeCookie, sessionCookie, signInCookie, type CookieSecurity, type CookieSpec, type SignInCookieValue, } from "./cookies.js";
100
97
  export { assertSameOrigin, CrossOriginError, safeReturnPath, shopperIpFrom, type HeadersLike } from "./security.js";
@@ -6,8 +6,9 @@
6
6
  * a client is never a module-level singleton, so one shopper's token can never serve another shopper's request.
7
7
  */
8
8
  import createClient from "openapi-fetch";
9
+ import { commerceHeaders } from "../context.js";
9
10
  /**
10
- * A request-scoped client: every call carries the server key, the market, the shopper IP and (when given) the session, and
11
+ * A request-scoped client: every call carries the server key, the commerce context, the shopper IP and (when given) the session, and
11
12
  * every fetch is `cache: "no-store"` — an account answer is never shared or cached (spec §10.1).
12
13
  */
13
14
  export function createShopperClient(opts) {
@@ -19,12 +20,12 @@ export function createShopperClient(opts) {
19
20
  const noStore = ((input, init) => baseFetch(input, { ...init, cache: "no-store" }));
20
21
  const client = createClient({ baseUrl: opts.baseUrl.replace(/\/$/, ""), fetch: noStore, cache: "no-store" });
21
22
  const session = opts.session || null;
23
+ const context = commerceHeaders(opts);
22
24
  const headers = {
23
25
  onRequest({ request }) {
24
26
  request.headers.set("X-Site-Key", opts.serverKey);
25
- request.headers.set("X-Market", opts.market);
26
- if (opts.acceptLanguage)
27
- request.headers.set("Accept-Language", opts.acceptLanguage);
27
+ for (const [name, value] of context)
28
+ request.headers.set(name, value);
28
29
  if (opts.shopperIp)
29
30
  request.headers.set("X-Shopper-Ip", opts.shopperIp);
30
31
  if (session)
@@ -109,6 +110,6 @@ function failure(response, error) {
109
110
  function isProblem(value) {
110
111
  return typeof value === "object" && value !== null && typeof value.code === "string";
111
112
  }
112
- export { copyBrowserCart, copyKey, CopyBrowserCartError } from "./cart.js";
113
+ export { cartTotals, copyBrowserCart, copyKey, CopyBrowserCartError } from "./cart.js";
113
114
  export { browserCartCookie, cartCookie, cookieSecurity, readSignInCookie, serializeCookie, sessionCookie, signInCookie, } from "./cookies.js";
114
115
  export { assertSameOrigin, CrossOriginError, safeReturnPath, shopperIpFrom } from "./security.js";
@@ -1,13 +1,11 @@
1
1
  import { type AccountResult, type PrincipalDto, type ShopperClient } from "./index.js";
2
+ import type { CommerceContextOptions } from "../context.js";
2
3
  import { type CopyBrowserCartResult } from "./cart.js";
3
- export interface AccountActionConfig {
4
+ export type AccountActionConfig = {
4
5
  /** e.g. https://api.storefront.asstio.com */
5
6
  baseUrl: string;
6
7
  /** The site's server key (`sfk_srv_…`), from the server's environment. */
7
8
  serverKey: string;
8
- /** Market code, e.g. "se". */
9
- market: string;
10
- acceptLanguage?: string;
11
9
  /** The frontend's own origin, e.g. https://shop.example: decides `Secure` and the `__Host-` prefix (dropped on http://localhost). */
12
10
  origin: string;
13
11
  /** How many proxies append to `X-Forwarded-For` in front of this server (`shopperIpFrom`). */
@@ -19,7 +17,7 @@ export interface AccountActionConfig {
19
17
  */
20
18
  cartCookie?: string;
21
19
  fetch?: typeof fetch;
22
- }
20
+ } & CommerceContextOptions;
23
21
  /** What a sign-in start shows the page: no binding (it is in the cookie). */
24
22
  export interface SignInStartedView {
25
23
  challengeId: string;
@@ -37,6 +35,24 @@ export interface SignedInView {
37
35
  */
38
36
  returnPath: string | null;
39
37
  }
38
+ /**
39
+ * A verify or switch that took effect says `dropPrivateData: true` (market model §18.4): the shopper is now someone else, so throw away
40
+ * what the browser holds from before it: cart views, prices, anything rendered for the old principal. Then read again (a cart the sign-in
41
+ * adopted is re-read too). A failure changed nothing, so it has no such flag.
42
+ */
43
+ export type SessionTransition<T> = {
44
+ ok: true;
45
+ data: T;
46
+ dropPrivateData: true;
47
+ } | (Extract<AccountResult<T>, {
48
+ ok: false;
49
+ }> & {
50
+ dropPrivateData?: never;
51
+ });
52
+ /** A sign-out always says `dropPrivateData: true`: the cookies are gone whatever the API answered. */
53
+ export type SignOutResult = AccountResult<void> & {
54
+ dropPrivateData: true;
55
+ };
40
56
  /**
41
57
  * Starts a sign-in: `POST /v1/auth/sign-in`, then the binding (with the challenge id) goes into the sign-in cookie for 10
42
58
  * minutes. An unsafe `returnPath` is dropped here as the API would. The result carries the challenge id for the code form.
@@ -51,24 +67,26 @@ export interface VerifySignInActionInput {
51
67
  }
52
68
  /**
53
69
  * Verifies a code or link with the binding from the sign-in cookie: on success the session cookie is set (to the session's
54
- * absolute expiry) and the sign-in cookie cleared. `409 SIGN_IN_CONFIRM_REQUIRED` comes back as a problem with
55
- * `maskedEmail` and `siteName`; ask the shopper, then call again with `confirm: true`.
70
+ * absolute expiry), the sign-in cookie cleared, and the result says `dropPrivateData: true`: the guest's cart view goes, and an adopted
71
+ * cart is read again. `409 SIGN_IN_CONFIRM_REQUIRED` comes back as a problem with `maskedEmail` and `siteName`; ask the shopper, then
72
+ * call again with `confirm: true`.
56
73
  */
57
- export declare function verifySignInAction(config: AccountActionConfig, input: VerifySignInActionInput): Promise<AccountResult<SignedInView>>;
74
+ export declare function verifySignInAction(config: AccountActionConfig, input: VerifySignInActionInput): Promise<SessionTransition<SignedInView>>;
58
75
  /**
59
- * Switches the principal: the rotated token replaces the session cookie at once (the old one serves GETs for 30 s only).
60
- * A failure (also `SESSION_INVALID`) is returned and leaves the cookie alone: a switch that lost to a concurrent one from
61
- * another tab would otherwise delete the winner's fresh cookie. Refresh the session state; only `signOutAction` clears.
76
+ * Switches the principal: the rotated token replaces the session cookie at once (the old one serves GETs for 30 s only), the
77
+ * cart cookie moves to the new principal's own cart, and the result says `dropPrivateData: true`. A failure (also `SESSION_INVALID`)
78
+ * is returned and leaves the cookie alone: a switch that lost to a concurrent one from another tab would otherwise delete the
79
+ * winner's fresh cookie. Refresh the session state; only `signOutAction` clears.
62
80
  */
63
- export declare function switchPrincipalAction(config: AccountActionConfig, principal: string): Promise<AccountResult<SignedInView>>;
81
+ export declare function switchPrincipalAction(config: AccountActionConfig, principal: string): Promise<SessionTransition<SignedInView>>;
64
82
  /**
65
83
  * Signs out (`everywhere` ends every session of the account). The session cookie, the sign-in cookie, the cart cookie and the
66
84
  * browser-cart cookie are cleared whatever the API answered, and also when it cannot be reached (`{ ok: false, status: 0, problem.code: "NETWORK" }`)
67
- * — a session already gone is still a sign-out for this browser.
85
+ * — a session already gone is still a sign-out for this browser. So every answer says `dropPrivateData: true`.
68
86
  */
69
87
  export declare function signOutAction(config: AccountActionConfig, opts?: {
70
88
  everywhere?: boolean;
71
- }): Promise<AccountResult<void>>;
89
+ }): Promise<SignOutResult>;
72
90
  /**
73
91
  * "Lägg till i sparad varukorg" (spec §9.2): copies the browser cart (the browser-cart cookie) into the saved cart (the cart
74
92
  * cookie, `config.cartCookie`) with `copyBrowserCart`, under the session. The browser-cart cookie is cleared only when no line