@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 +134 -43
- package/dist/account/cart.d.ts +32 -1
- package/dist/account/cart.js +20 -2
- package/dist/account/index.d.ts +5 -8
- package/dist/account/index.js +6 -5
- package/dist/account/next.d.ts +32 -14
- package/dist/account/next.js +17 -14
- package/dist/analytics/decimal.d.ts +6 -2
- package/dist/analytics/decimal.js +10 -2
- package/dist/analytics/ga4.d.ts +6 -4
- package/dist/analytics/ga4.js +38 -18
- package/dist/analytics/types.d.ts +1 -1
- package/dist/checkout/types.d.ts +12 -1
- package/dist/checkout/types.js +3 -0
- package/dist/context.d.ts +23 -0
- package/dist/context.js +6 -0
- package/dist/filters.d.ts +10 -0
- package/dist/filters.js +8 -0
- package/dist/index.d.ts +12 -10
- package/dist/index.js +11 -7
- package/dist/offer.d.ts +16 -0
- package/dist/offer.js +17 -0
- package/dist/schema.d.ts +510 -279
- package/dist/seo/head.js +16 -12
- package/dist/seo/locales.d.ts +6 -1
- package/dist/seo/locales.js +9 -1
- package/dist/seo/product.d.ts +4 -0
- package/dist/seo/product.js +25 -12
- package/dist/seo/shared.d.ts +8 -6
- package/dist/seo/shared.js +33 -34
- package/dist/seo/sitemap.d.ts +27 -9
- package/dist/seo/sitemap.js +52 -21
- package/dist/seo/types.d.ts +10 -4
- package/package.json +2 -2
- package/src/schema.d.ts +510 -279
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
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,
|
|
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
|
|
264
|
-
(`product`, `collection`, `brand`; default all three) a cursor walk of `GET /v1/sitemap
|
|
265
|
-
`nextCursor`, with no 10 000-row ceiling. `
|
|
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
|
|
274
|
-
const api = createStorefrontClient({ baseUrl: API_URL, siteKey: SITE_KEY
|
|
275
|
-
const result = await sitemapEntries(api, {
|
|
276
|
-
if (!result.complete) console.warn("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
|
-
- `
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
-
|
|
288
|
-
|
|
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
|
-
|
|
295
|
-
two
|
|
296
|
-
|
|
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 `
|
|
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'
|
|
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
|
-
`
|
|
513
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
721
|
-
| `
|
|
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
|
-
|
|
724
|
-
`
|
|
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`, `
|
|
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), `
|
|
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
|
-
`
|
|
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 `
|
|
761
|
-
`CHECKOUT_IN_PROGRESS`, `
|
|
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,
|
|
799
|
-
all
|
|
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
|
package/dist/account/cart.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/account/cart.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/account/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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";
|
package/dist/account/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
26
|
-
|
|
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";
|
package/dist/account/next.d.ts
CHANGED
|
@@ -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
|
|
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)
|
|
55
|
-
* `maskedEmail` and `siteName`; ask the shopper, then
|
|
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<
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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<
|
|
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<
|
|
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
|