@asstio-dev/storefront 0.6.0 → 0.9.2

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
@@ -53,7 +53,7 @@ const { data: page } = await storefront.GET("/v1/products/{slugOrId}/variants",
53
53
  ```
54
54
 
55
55
  Every example in this file is compiled as part of `npm run check` — the source of them is
56
- [`examples/readme.ts`](examples/readme.ts), which also covers product lookup, ETag revalidation and crawling.
56
+ [`examples/readme.ts`](examples/readme.ts), which also covers product lookup, ETag revalidation, crawling, and image formats.
57
57
 
58
58
  ### Images
59
59
 
@@ -78,6 +78,70 @@ for (const file of product?.files ?? []) console.log(file.filename, fileUrl(site
78
78
  `videoSources` returns `[]` for anything that is not a video, while `mediaUrl`/`pictureSources`/`fileUrl`/`posterUrl`
79
79
  throw `MediaUrlError` when the descriptor cannot produce that URL — branch on `kind` before calling.
80
80
 
81
+ ### Image formats
82
+
83
+ A site's **formats** (`site.media.formats`: `card`, `gallery`, `thumb`, `cart`, `swatch`, `default`, plus custom
84
+ keys) say how an image is framed: aspect, fit (`contain` · `cover` · `natural`), padding and a background per
85
+ `bg` class. They are published with the site, so the look changes without a frontend deploy. `formatImage` turns
86
+ a descriptor and a format key into everything a `<picture>` needs:
87
+
88
+ ```ts
89
+ import { formatImage } from "@asstio-dev/storefront";
90
+
91
+ const f = formatImage(site.media, product.image, "card", [
92
+ { media: "(min-width: 1080px)", size: "25vw" }, // your slot's width per breakpoint
93
+ { size: "100vw" }, // the last one without a media condition
94
+ ]);
95
+ // React:
96
+ // <div className={styles.cardImage}> ← your slot: your class, may be a flex/grid item
97
+ // <div style={f.box}> ← the aspect frame, the slot's only child
98
+ // <canvas ref={blurhashCanvas} style={f.placeholder} /> ← optional placeholder, same rectangle as the image
99
+ // <picture>
100
+ // {f.sources.map((s) => <source key={s.type} {...s} />)}
101
+ // <img {...f.fallback} style={f.img} alt={alt} loading="lazy" />
102
+ // </picture>
103
+ // </div>
104
+ // </div>
105
+ ```
106
+
107
+ Plain HTML (templating):
108
+
109
+ ```ts
110
+ const f = formatImage(site.media, product.image, "card", [
111
+ { media: "(min-width: 1080px)", size: "25vw" },
112
+ { media: "(min-width: 640px)", size: "50vw" },
113
+ { size: "100vw" },
114
+ ]);
115
+ // alt is merchant text, not SDK output: escape it for the attribute.
116
+ const escapeAttr = (s: string) => s.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
117
+ const alt = escapeAttr(product.image.alt["sv"] ?? product.name);
118
+ // Your slot owns the width; box is its only child in every fit:
119
+ const picture =
120
+ `<picture>${f.sources.map((s) => `<source type="${s.type}" srcset="${s.srcSet}" sizes="${s.sizes}">`).join("")}` +
121
+ `<img src="${f.fallback.src}" srcset="${f.fallback.srcSet}" sizes="${f.fallback.sizes}" width="${f.fallback.width}" height="${f.fallback.height}" alt="${alt}" loading="lazy" style="${styleToCss(f.img)}"></picture>`;
122
+ const placeholder = `<canvas width="32" height="32" style="${styleToCss(f.placeholder)}"></canvas>`;
123
+ const html = `<div class="card-image"><div style="${styleToCss(f.box)}">${placeholder}${picture}</div></div>`;
124
+ ```
125
+
126
+ - **Element contract.** Your slot element owns the width (grid, flex, `className`); it may be stretched. Put the
127
+ `box` inside it as its only child and give the box **no** padding, border or height of its own — its inline
128
+ styles pin its geometry, so a stretched slot never distorts the format's aspect.
129
+ - **One rectangle.** `img` and `placeholder` are the same absolutely positioned rectangle with `object-fit: fill`,
130
+ computed from the descriptor: a blurhash canvas of any decode size is stretched to exactly where the image will
131
+ paint. The box's `backgroundColor` shows around a `contain` image and through transparent AVIF/WebP.
132
+ - **Natural** has a box too, at the image's own aspect and without a colour; `img` and `placeholder` fill it. So
133
+ the layout is reserved before load in every fit, and the placeholder sits under the image the same way.
134
+ - **`sizes`** is your slots scaled by the image's drawn fraction with `calc()`; `srcset` keeps the real widths, so
135
+ the browser downloads a sharp enough rendition for a cover crop and not a wasted one for a padded packshot.
136
+ - `formats: null` (a site without formats) or an unknown key falls back to `default`, then to `natural`; your own
137
+ CSS keeps working. `focal` (set per placement in Asstio) positions a `cover` crop; `showWhole` renders a cover
138
+ format as contain for that image. `formatImage` throws `MediaUrlError` for video descriptors, file descriptors,
139
+ a descriptor without `w`/`h`, a descriptor without renditions, or an empty `slots` array.
140
+ - Plain HTML: see the templating example above, or the compiled function `productCard` in [`examples/readme.ts`](examples/readme.ts).
141
+ `styleToCss(f.box)` gives the `style` attribute value for any element. Escape anything that is not SDK output
142
+ (`alt`, product names) before it goes into markup; the React example needs no escaping because React escapes
143
+ attribute values.
144
+
81
145
  ### Product content
82
146
 
83
147
  `description` is **already sanitized** on the server: render `description.html` as-is — do not sanitize, escape or
@@ -129,6 +193,120 @@ case-insensitively, or key prefix — returning at most 50. A facet with more di
129
193
  over its top 1000; no regular expression ever reaches the search index. `key` has to be a `list` or `swatch` facet:
130
194
  an unknown key is `FACET_NOT_FOUND` (404), and a range or toggle facet is `FACET_NOT_SEARCHABLE` (400).
131
195
 
196
+ ### Checkout
197
+
198
+ Two small modules, because the suspend → update → resume rule spans two runtimes. The server helpers need the
199
+ client and the cart token (an `httpOnly` cookie, so they run in Server Actions and route handlers);
200
+ `mountCheckout` needs neither key nor fetch — it mounts the provider's widget and drives it from the answers
201
+ your server hands it.
202
+
203
+ ```ts
204
+ import { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, mountCheckout, startCheckout, syncCheckout } from "@asstio-dev/storefront";
205
+ ```
206
+
207
+ **Server.** `startCheckout(client, cartToken, { option?, returnOrigin? })` creates the cart's checkout attempt
208
+ or converges on the live one; call it from a Server Action or route handler **on the shopper's navigation to the
209
+ checkout page, never during a Server Component's render** (a re-render would create a provider order per
210
+ mutation). `syncCheckout` is the same `POST /v1/checkout`, retrying `CHECKOUT_IN_PROGRESS` per `Retry-After`
211
+ twice by default — **call it after every cart mutation on the checkout page**, through the browser handle's
212
+ `update`; there, and for the handle's `sync`, pass `{ retries: 0 }` so each call is one POST and the handle's own
213
+ burst does the retrying. `getCheckoutStatus(client, attemptId, cartToken)` is the non-mutating
214
+ `GET /v1/checkout/{attemptId}` behind the handle's `status` callback.
215
+ `completeCheckout(client, attemptId, cartToken?)` is the confirmation page's call; with the cart token
216
+ the answer carries `order.accessToken`, on every call, so a lost first answer or a reload still ends with the token.
217
+ `awaitCompletion(() => completeCheckout(…))` polls with backoff until the order exists. `getOrder(client, orderId,
218
+ token)` sends the token as `X-Order-Token` — a header, never a query string; send `Referrer-Policy: no-referrer`
219
+ on the order page and keep its URL out of your logs.
220
+
221
+ ```ts
222
+ // A Server Action or route handler, on the shopper's navigation to the checkout page.
223
+ const result = await startCheckout(storefront, cartToken, { returnOrigin: "https://shop.example" });
224
+ if (!result.ok) return { problem: result.problem, retryAfter: result.retryAfter };
225
+ return { checkout: result.data }; // "open" with a widget, or "completed"/"pending" with an order
226
+
227
+ // The Server Action behind mountCheckout's `sync`, and the POST inside every cart update: one HTTP POST per call.
228
+ export async function syncForBrowser() {
229
+ return syncCheckout(storefront, cartToken, undefined, { retries: 0 });
230
+ }
231
+
232
+ // The confirmation page: complete with the cart token, keep the order's access token, then poll for the number.
233
+ const first = await completeCheckout(storefront, attemptId, cartToken);
234
+ const done = await awaitCompletion(() => completeCheckout(storefront, attemptId, cartToken));
235
+
236
+ // The order page: the token is a header.
237
+ const order = await getOrder(storefront, orderId, token); // 404 ORDER_NOT_FOUND for an unknown id and a wrong token alike
238
+ ```
239
+
240
+ **Browser.** `mountCheckout(container, checkout, { sync, status, onProblem, onOrder })` takes the `open` answer
241
+ your page was rendered with and returns a handle `{ attemptId, suspended, suspend, resume, remount, destroy,
242
+ sync(update) }`. The container is a client component **keyed by `attemptId`**. `handle.sync(update)` is the whole
243
+ rule: it suspends the widget, runs your `update` **once** (mutate the cart, `POST /checkout`, return its answer),
244
+ then dispatches **status first, identity on `open`**: `completed`/`pending` → `onOrder(order, attemptId)` (you
245
+ navigate); `locked` → stays suspended and retries `sync()` per `Retry-After` within the API's budget (30 s, ten
246
+ calls — a 429 counts the same; a `Retry-After` past the 30 s is not waited out), then polls `status(attemptId)`;
247
+ `processing` → polls `status()`; `declined`/`expired` → one `sync()`; `open` → resumes when the answer's
248
+ `attemptId` is the mounted one, else remounts from the answer's widget (`changed` only says *this* call created a
249
+ new attempt — never resume on it). A `status()` answer is never dispatched as payable: its `open` leads to one
250
+ non-mutating `sync()`. An `update` that throws leaves the widget suspended. The widget is suspended the moment
251
+ `handle.sync` is called, and stays suspended while another update is queued behind it; a newly queued update ends
252
+ the running sync's waits (a retry or a status poll), so it runs at once. `CHECKOUT_IN_PROGRESS` is retried like
253
+ `locked`, within the same burst. If the provider's script loads late, the handle keeps trying to bind its decline
254
+ signal every half second for as long as the widget is mounted. A 503 `CHECKOUT_UNAVAILABLE` or
255
+ `CHECKOUT_PROVIDER_UNAVAILABLE` is retried per its `Retry-After` by non-mutating syncs within the same budget, then
256
+ reported; any other 503 (`SITE_KEYS_SYNCING`, `SITE_REGISTRY_WARMING`) and every other non-2xx of the SDK's own calls
257
+ reaches `onProblem(problem, { status })` at once, `status` being the HTTP status — a `409 CHECKOUT_CART_CHANGED`
258
+ carries the corrected cart in `problem.cart`: show it and its warnings, then `handle.sync(() => POST /checkout)`.
259
+ `handle.sync` never rejects. `onProblem` also receives the SDK's own codes: `SDK_SYNC_FAILED` or `SDK_STATUS_FAILED`
260
+ when one of its calls throws (a network failure), `SDK_UPDATE_FAILED` when your `update` throws, and
261
+ `SDK_DISPATCH_FAILED` for any other throw while dispatching (an `onOrder` that throws, an unknown widget controller) —
262
+ each with `{ status: 0 }` and the widget left suspended; `SDK_CHECKOUT_CLOSED` when the one `sync()` after a
263
+ `declined`/`expired` answers closed again, and `SDK_UNKNOWN_STATUS` for an answer status the SDK does not know (both
264
+ `{ status: 200 }`); and `SDK_EMPTY_ANSWER`, which the server helpers return when an answer carried no body (with that
265
+ answer's HTTP status). The SDK also syncs **by itself**: once on the provider's
266
+ payment-declined signal where its JavaScript offers one, when the page becomes visible again while suspended, and
267
+ **three minutes before expiry, timed from the DTO's `expiresInSeconds`** (server-relative, so a browser clock ahead
268
+ of the server's still lands inside the server's five-minute window). An autonomous sync is skipped while another
269
+ sync is running or queued.
270
+
271
+ ```ts
272
+ // A client component, keyed by attemptId so a re-render never resets it.
273
+ const handle = mountCheckout(container, checkout, {
274
+ sync: callSync, // a Server Action calling syncForBrowser()
275
+ status: callStatus, // a Server Action calling getCheckoutStatus(storefront, attemptId, cartToken)
276
+ onProblem: (problem) => { /* problem.cart when code === "CHECKOUT_CART_CHANGED"; otherwise the message for problem.code */ },
277
+ onOrder: (order, attemptId) => { location.assign(`/kassa/tack?attempt=${attemptId}&order=${order.id}`); },
278
+ });
279
+ // Every cart mutation on the page goes through the handle: suspend → your update once → status first, identity on open.
280
+ await handle.sync(() => callAddLine(sku)); // callAddLine mutates the cart, then syncs, and returns that answer
281
+ ```
282
+
283
+ **After completion.** Every cart endpoint answers `410 CART_EXPIRED` for the converted cart, while the cart token
284
+ is still what re-delivers the order's access token — so **a `410` from a cart mutation on the checkout or
285
+ confirmation path does not end the cart cookie**: call `GET /v1/checkout/{attemptId}` (or `POST /checkout`, which
286
+ answers the converted cart's completed attempt) with the same token, navigate to the confirmation from its
287
+ `order`, and clear the cookie only once the confirmation page holds the access token.
288
+
289
+ **Copy and CSP.** The `pending` state ("awaiting payment approval") is your copy. Each provider's widget needs its
290
+ domains in your CSP: Qliro One `*.qliro.com` (`pago.qit.nu` in the sandbox), Klarna/Kustom `*.klarna.com`
291
+ (`*.kustom.co`), Walley `*.walleypay.com`, plus their `frame-src`/`script-src`/`connect-src` — the provider's
292
+ integration guide lists the exact hosts.
293
+
294
+ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `cart`), `CHECKOUT_PREVIEW_KEY`,
295
+ `CHECKOUT_MARKET_MISMATCH` (switch the cart's market first, then sync), `OPTION_NOT_AVAILABLE`,
296
+ `ORIGIN_NOT_ALLOWED`, `CHECKOUT_UNAVAILABLE` and `CHECKOUT_PROVIDER_UNAVAILABLE` (503, `Retry-After`),
297
+ `CHECKOUT_IN_PROGRESS` (409, `Retry-After`), `CHECKOUT_PROVIDER_REJECTED` (502), `CHECKOUT_NOT_FOUND`,
298
+ `ORDER_NOT_FOUND`, `SITE_KEYS_SYNCING` (503 for a secret key on a freshly created site, `Retry-After`).
299
+
300
+ #### Qliro (`qliro-q1`)
301
+
302
+ - The widget is Qliro's HTML snippet; `mountCheckout` re-creates its scripts so they run.
303
+ - After every cart change, `handle.sync(update)` locks the widget (`q1.lock()`), and resumes it through
304
+ `q1.onOrderUpdated`, which makes the iframe re-read the order. The widget unlocks only when Qliro's order shows the
305
+ confirmed total. If it does not within 10 seconds, or the change came before Qliro's script had loaded, the widget is
306
+ re-rendered from the confirmed checkout instead; it is never unlocked before Qliro shows the confirmed total.
307
+ - When our validation declines a purchase (the cart moved on, the attempt expired, a line is out of stock), Qliro shows
308
+ its out-of-stock message and fires `q1.onPaymentDeclined`; the SDK then syncs by itself and remounts or resumes.
309
+
132
310
  ## Headers
133
311
 
134
312
  Every call the client makes carries these; you never set them by hand.
@@ -170,9 +348,14 @@ Codes you will meet: `SITE_KEY_MISSING`, `SITE_KEY_INVALID`, `SITE_INACTIVE`, `S
170
348
  `SITE_REGISTRY_WARMING`, `MARKET_UNKNOWN`, `RATE_LIMITED` (with `retryAfterSeconds`, plus a `Retry-After`
171
349
  header), `PRODUCT_NOT_FOUND`, `COLLECTION_NOT_FOUND`, `INVALID_PAGE`, `INVALID_SINCE`, `INVALID_TYPE`,
172
350
  `INDEX_UNAVAILABLE` (retry — a preview index is being rebuilt), `NOT_FOUND`, `INVALID_QUERY`,
173
- `FACET_NOT_FOUND`, `FACET_NOT_SEARCHABLE`.
174
-
175
- `SITE_REGISTRY_WARMING` and `INDEX_UNAVAILABLE` are retries, not bugs in your call.
351
+ `FACET_NOT_FOUND`, `FACET_NOT_SEARCHABLE`,
352
+ and, from the checkout ([above](#checkout)): `CART_EMPTY`, `CHECKOUT_CART_CHANGED`, `CHECKOUT_PREVIEW_KEY`,
353
+ `CHECKOUT_MARKET_MISMATCH`, `OPTION_NOT_AVAILABLE`, `ORIGIN_NOT_ALLOWED`, `CHECKOUT_UNAVAILABLE`,
354
+ `CHECKOUT_PROVIDER_UNAVAILABLE`, `CHECKOUT_IN_PROGRESS`, `CHECKOUT_PROVIDER_REJECTED`, `CHECKOUT_NOT_FOUND`,
355
+ `ORDER_NOT_FOUND`, `SITE_KEYS_SYNCING`, `CART_EXPIRED`.
356
+
357
+ `SITE_REGISTRY_WARMING` and `INDEX_UNAVAILABLE` are retries, not bugs in your call, as are the checkout's
358
+ `CHECKOUT_IN_PROGRESS`, `CHECKOUT_UNAVAILABLE`, `CHECKOUT_PROVIDER_UNAVAILABLE` and `SITE_KEYS_SYNCING` (each with `Retry-After`).
176
359
 
177
360
  ## Warnings
178
361
 
@@ -0,0 +1,76 @@
1
+ import { type ProviderWindow } from "./controllers.js";
2
+ import { type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type StatusResult, type StorefrontProblem } from "./types.js";
3
+ export interface MinimalScript {
4
+ src: string;
5
+ textContent: string | null;
6
+ async: boolean;
7
+ defer: boolean;
8
+ type: string;
9
+ getAttributeNames(): string[];
10
+ getAttribute(name: string): string | null;
11
+ setAttribute(name: string, value: string): void;
12
+ parentNode?: {
13
+ replaceChild(newNode: unknown, oldNode: unknown): void;
14
+ } | null;
15
+ }
16
+ export interface MinimalElement {
17
+ innerHTML: string;
18
+ querySelectorAll(selector: "script"): ArrayLike<MinimalScript>;
19
+ appendChild(node: unknown): unknown;
20
+ ownerDocument?: MinimalDocument | null;
21
+ }
22
+ export interface MinimalDocument {
23
+ createElement(tag: "script"): MinimalScript;
24
+ visibilityState: string;
25
+ addEventListener(type: "visibilitychange", fn: () => void): void;
26
+ removeEventListener(type: "visibilitychange", fn: () => void): void;
27
+ }
28
+ export interface MinimalWindow {
29
+ document?: MinimalDocument;
30
+ }
31
+ export interface MountOptions {
32
+ /** A plain, non-mutating `POST /checkout` (server-side, with the cart token). Recommended: one HTTP POST per call,
33
+ * `syncCheckout(client, cartToken, opts, { retries: 0 })` — the handle retries `CHECKOUT_IN_PROGRESS` itself inside
34
+ * its 30 s burst *(plan review 3 #5, final review S1)*. */
35
+ sync: () => Promise<CheckoutResult>;
36
+ /** A `GET /checkout/{attemptId}` on the attempt named. */
37
+ status: (attemptId: string) => Promise<StatusResult>;
38
+ /** Every non-2xx the SDK's own calls meet, a call of its own that threw, an `update` that threw, and any other
39
+ * throw while dispatching (`SDK_DISPATCH_FAILED`: an `onOrder` that threw, an unknown controller). A 409
40
+ * CHECKOUT_CART_CHANGED carries the corrected cart in `problem.cart`. */
41
+ onProblem: (problem: StorefrontProblem, context: {
42
+ status: number;
43
+ }) => void;
44
+ /** An order exists: navigate to the confirmation from it. The SDK never navigates itself. */
45
+ onOrder: (order: CheckoutOrderRefDto, attemptId: string) => void;
46
+ document?: MinimalDocument;
47
+ window?: ProviderWindow & MinimalWindow;
48
+ now?: () => number;
49
+ setTimeout?: (fn: () => void, ms: number) => unknown;
50
+ clearTimeout?: (handle: unknown) => void;
51
+ /** The `locked` burst, sized to the API's checkout:{cartToken} policy (20/min): 30 s and at most ten calls (spec
52
+ * §10.3). The spec's burst is the whole client-side budget: the rolling-minute POST ledger rounds 2–3 added is cut —
53
+ * the server enforces the policy and a 429 is handled like `locked` *(final review S1)*. */
54
+ lockedBudgetMs?: number;
55
+ lockedMaxCalls?: number;
56
+ pollFirstMs?: number;
57
+ pollMaxMs?: number;
58
+ /** How many consecutive `locked` status answers lead to one sync() rather than more polling *(plan review 1 #12)*. */
59
+ lockedPollsBeforeSync?: number;
60
+ /** Binding the provider's decline signal until its script has run: every `bindRetryMs` (500 ms) for as long as the
61
+ * widget is mounted — one interval, no fast and slow phases *(plan review 2 #13, plan review 3 #4, final review S8)*. */
62
+ bindRetryMs?: number;
63
+ /** How long before `expiresInSeconds` the SDK syncs by itself (inside the server's five-minute window). */
64
+ preExpiryMs?: number;
65
+ }
66
+ export interface CheckoutHandle {
67
+ readonly attemptId: string;
68
+ readonly suspended: boolean;
69
+ suspend(): void;
70
+ resume(): void;
71
+ remount(checkout: CheckoutDto): void;
72
+ destroy(): void;
73
+ /** The whole rule: suspend → `await update()` once → status first, identity on `open`. Never rejects. */
74
+ sync(update: () => Promise<CheckoutResult>): Promise<void>;
75
+ }
76
+ export declare function mountCheckout(container: MinimalElement, checkout: CheckoutDto, options: MountOptions): CheckoutHandle;