@asstio-dev/storefront 0.7.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.
@@ -0,0 +1,55 @@
1
+ /** What the provider's JavaScript offers (spec §4.4 `ClientSuspend`); each is optional on the page until the widget's script has loaded. */
2
+ export interface ProviderWindow {
3
+ q1?: {
4
+ lock(): void;
5
+ unlock(): void;
6
+ /** Qliro's sync after a `PUT Orders/{id}`: the iframe re-reads the order and calls back until `unlock()` is called
7
+ * (developers.qliro.com → Update order). */
8
+ onOrderUpdated?(cb: (order: unknown) => void): void;
9
+ /** Fires when our validation callback declined the purchase, with the `DeclineReason` it answered
10
+ * (developers.qliro.com → Listeners). */
11
+ onPaymentDeclined?(cb: (declineReason?: string, declineReasonMessage?: string) => void): void;
12
+ };
13
+ _klarnaCheckout?: (cb: (api: {
14
+ suspend(): void;
15
+ resume(): void;
16
+ }) => void) => void;
17
+ walley?: {
18
+ checkout?: {
19
+ api?: {
20
+ suspend(): void;
21
+ resume(): void;
22
+ };
23
+ };
24
+ };
25
+ }
26
+ /** What a resume must see in the provider's own view of the order before it enables payment: the confirmed answer
27
+ * (`open` for the mounted attempt) *(plan review 1 #3)*. */
28
+ export interface ResumeTarget {
29
+ cart: {
30
+ grandTotalInc: string;
31
+ };
32
+ /** The provider never showed `cart` before its deadline: the handle re-renders the widget from the confirmed answer. */
33
+ onUnsynced(): void;
34
+ }
35
+ export interface CheckoutController {
36
+ suspend(): void;
37
+ /** `target` lets a controller that can see the provider's order wait for it (qliro-q1); the others ignore it. */
38
+ resume(target?: ResumeTarget): void;
39
+ /** Subscribe to the provider's payment-declined signal where its API offers one. Returns the unsubscribe once the
40
+ * subscription is registered, and `undefined` while the provider's script has not exposed the signal yet — the
41
+ * handle retries only then. A controller whose provider has no such signal leaves this member out, and the handle
42
+ * never retries for it *(plan review 2 #13)*. */
43
+ onDeclined?(handler: () => void): (() => void) | undefined;
44
+ }
45
+ /** A controller's own timer, scheduled on the handle so that a remount or `destroy()` cancels it. */
46
+ export type ControllerTimer = (fn: () => void, ms: number) => void;
47
+ export type ControllerFactory = (win: ProviderWindow, timer: ControllerTimer) => CheckoutController;
48
+ /** How long `qliro-q1`'s resume waits for `q1.onOrderUpdated` to show the confirmed cart. Past it the widget is
49
+ * re-rendered, never unlocked blindly *(plan review 1 #3)*. */
50
+ export declare const QLIRO_SYNC_DEADLINE_MS = 10000;
51
+ /** Qliro's check before `unlock()` (*Update order*: "Verify that the total amount of the order = total amount in the
52
+ * shopping cart"), in öre. The total alone, as Qliro documents it *(plan review 1 #3, final review S1)*. */
53
+ export declare function qliroOrderMatches(order: unknown, cart: ResumeTarget["cart"]): boolean;
54
+ export declare const controllers: Record<"qliro-q1" | "klarna-kco" | "walley", ControllerFactory>;
55
+ export declare function controllerFor(name: string, win: ProviderWindow, timer?: ControllerTimer): CheckoutController;
@@ -0,0 +1,75 @@
1
+ /** How long `qliro-q1`'s resume waits for `q1.onOrderUpdated` to show the confirmed cart. Past it the widget is
2
+ * re-rendered, never unlocked blindly *(plan review 1 #3)*. */
3
+ export const QLIRO_SYNC_DEADLINE_MS = 10_000;
4
+ const ore = (v) => Math.round(Number(v) * 100);
5
+ /** Qliro's check before `unlock()` (*Update order*: "Verify that the total amount of the order = total amount in the
6
+ * shopping cart"), in öre. The total alone, as Qliro documents it *(plan review 1 #3, final review S1)*. */
7
+ export function qliroOrderMatches(order, cart) {
8
+ const total = order?.totalPrice;
9
+ return (typeof total === "number" || typeof total === "string") && ore(total) === ore(cart.grandTotalInc);
10
+ }
11
+ export const controllers = {
12
+ "qliro-q1": (win, timer) => {
13
+ // Every suspend, every unlock and the deadline move the generation, so neither a resume's `onOrderUpdated` callback
14
+ // (Qliro calls it until unlock) nor its deadline acts after a newer suspend (Review Focus 3).
15
+ let generation = 0;
16
+ // Whether the last suspend reached `q1.lock()`: before the snippet's script has run there is no `q1` to lock, and the
17
+ // iframe may load the order before the PUT *(final review #4)*.
18
+ let locked = false;
19
+ return {
20
+ suspend: () => { generation++; locked = false; const q1 = win.q1; if (q1) {
21
+ q1.lock();
22
+ locked = true;
23
+ } },
24
+ resume: (target) => {
25
+ const q1 = win.q1;
26
+ const missedLock = !locked;
27
+ locked = false;
28
+ if (!q1) {
29
+ if (missedLock && target)
30
+ target.onUnsynced();
31
+ return;
32
+ }
33
+ const mine = ++generation;
34
+ const unlock = () => { if (generation !== mine)
35
+ return; generation++; q1.unlock(); };
36
+ // Nothing to compare — no sync API, or the app resumed without an answer: unlock at once, as B did.
37
+ if (!q1.onOrderUpdated || !target) {
38
+ unlock();
39
+ return;
40
+ }
41
+ if (missedLock)
42
+ q1.lock(); // onOrderUpdated "requires q1.lock() to have been called first"
43
+ q1.onOrderUpdated((order) => { if (qliroOrderMatches(order, target.cart))
44
+ unlock(); });
45
+ // Never unlocked blindly: past the deadline the handle re-renders the widget from the confirmed answer
46
+ // *(plan review 1 #3)*.
47
+ timer(() => { if (generation !== mine)
48
+ return; generation++; target.onUnsynced(); }, QLIRO_SYNC_DEADLINE_MS);
49
+ },
50
+ onDeclined: (handler) => {
51
+ // Confirmed (plan C): `q1.onPaymentDeclined` fires on our validation decline. `undefined` until `q1` and the signal
52
+ // exist *(plan review 2 #13)*.
53
+ const q1 = win.q1;
54
+ if (!q1?.onPaymentDeclined)
55
+ return undefined;
56
+ q1.onPaymentDeclined(() => handler());
57
+ return () => { };
58
+ },
59
+ };
60
+ },
61
+ "klarna-kco": (win) => ({
62
+ suspend: () => win._klarnaCheckout?.((api) => api.suspend()),
63
+ resume: () => win._klarnaCheckout?.((api) => api.resume()),
64
+ }),
65
+ walley: (win) => ({
66
+ suspend: () => win.walley?.checkout?.api?.suspend(),
67
+ resume: () => win.walley?.checkout?.api?.resume(),
68
+ }),
69
+ };
70
+ export function controllerFor(name, win, timer = (fn, ms) => { setTimeout(fn, ms); }) {
71
+ const factory = controllers[name];
72
+ if (!factory)
73
+ throw new Error(`unknown checkout controller '${name}'`);
74
+ return factory(win, timer);
75
+ }
@@ -0,0 +1,37 @@
1
+ import type { Client } from "openapi-fetch";
2
+ import type { StorefrontPaths } from "../index.js";
3
+ import { type CheckoutResult, type OrderResult, type StatusResult } from "./types.js";
4
+ export type CheckoutClient = Client<StorefrontPaths>;
5
+ export interface StartCheckoutOptions {
6
+ /** One of `GET /v1/site` → `markets[].checkout.options[].key`; omit for the market's default. */
7
+ option?: string;
8
+ /** Replaces the market's origin in the URLs handed to the provider; must be one of the site's allowed origins. */
9
+ returnOrigin?: string;
10
+ }
11
+ export interface SyncRetryOptions {
12
+ sleep?: (ms: number) => Promise<void>;
13
+ /** How many `CHECKOUT_IN_PROGRESS` retries this call makes itself (default 2). Pass `0` for the POST behind
14
+ * `mountCheckout`'s `sync` and `update`: one call is one HTTP POST, and the browser handle retries inside its own
15
+ * 30 s burst instead of multiplying it *(plan review 3 #5, final review S1)*. */
16
+ retries?: number;
17
+ }
18
+ /** `POST /v1/checkout`: creates the cart's attempt or converges on the live one (spec §5.3). Call it from a Server Action or route handler, never during render. */
19
+ export declare function startCheckout(client: CheckoutClient, cartToken: string, opts?: StartCheckoutOptions): Promise<CheckoutResult>;
20
+ /** The same `POST /v1/checkout`, retrying `CHECKOUT_IN_PROGRESS` per `Retry-After`, twice (spec §10.3) — or
21
+ * `retries` times; `{ retries: 0 }` is the single-attempt call to put behind `mountCheckout` *(plan review 3 #5)*.
22
+ * Never repeats a cart mutation: it only syncs. */
23
+ export declare function syncCheckout(client: CheckoutClient, cartToken: string, opts?: StartCheckoutOptions, retry?: SyncRetryOptions): Promise<CheckoutResult>;
24
+ /** `POST /v1/checkout/{attemptId}/complete`: the confirmation page's call; with the cart token the answer carries the order's access token (spec §8.3). */
25
+ export declare function completeCheckout(client: CheckoutClient, attemptId: string, cartToken?: string): Promise<StatusResult>;
26
+ /** `GET /v1/checkout/{attemptId}`: the non-mutating status read the browser module polls with. */
27
+ export declare function getCheckoutStatus(client: CheckoutClient, attemptId: string, cartToken: string): Promise<StatusResult>;
28
+ /** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3). */
29
+ export declare function getOrder(client: CheckoutClient, orderId: string, token: string): Promise<OrderResult>;
30
+ export interface AwaitCompletionOptions {
31
+ firstDelayMs?: number;
32
+ maxDelayMs?: number;
33
+ timeoutMs?: number;
34
+ sleep?: (ms: number) => Promise<void>;
35
+ }
36
+ /** Polls `complete` with doubling backoff until the attempt has an order or is closed, or the timeout passes (the last answer is returned either way). */
37
+ export declare function awaitCompletion(complete: () => Promise<StatusResult>, opts?: AwaitCompletionOptions): Promise<StatusResult>;
@@ -0,0 +1,68 @@
1
+ import { localProblem, retryAfterSeconds } from "./types.js";
2
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3
+ /** `POST /v1/checkout`: creates the cart's attempt or converges on the live one (spec §5.3). Call it from a Server Action or route handler, never during render. */
4
+ export async function startCheckout(client, cartToken, opts = {}) {
5
+ const { data, error, response } = await client.POST("/v1/checkout", {
6
+ body: { option: opts.option ?? null, returnOrigin: opts.returnOrigin ?? null },
7
+ headers: { "X-Cart-Token": cartToken },
8
+ });
9
+ const retryAfter = retryAfterSeconds(response);
10
+ if (error || !data)
11
+ return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter };
12
+ return { ok: true, data, retryAfter };
13
+ }
14
+ /** The same `POST /v1/checkout`, retrying `CHECKOUT_IN_PROGRESS` per `Retry-After`, twice (spec §10.3) — or
15
+ * `retries` times; `{ retries: 0 }` is the single-attempt call to put behind `mountCheckout` *(plan review 3 #5)*.
16
+ * Never repeats a cart mutation: it only syncs. */
17
+ export async function syncCheckout(client, cartToken, opts = {}, retry = {}) {
18
+ const sleep = retry.sleep ?? defaultSleep;
19
+ const retries = retry.retries ?? 2; // *(plan review 3 #5)*
20
+ let result = await startCheckout(client, cartToken, opts);
21
+ for (let attempt = 0; attempt < retries && !result.ok && result.problem.code === "CHECKOUT_IN_PROGRESS"; attempt++) {
22
+ await sleep((result.retryAfter ?? 1) * 1000);
23
+ result = await startCheckout(client, cartToken, opts);
24
+ }
25
+ return result;
26
+ }
27
+ /** `POST /v1/checkout/{attemptId}/complete`: the confirmation page's call; with the cart token the answer carries the order's access token (spec §8.3). */
28
+ export async function completeCheckout(client, attemptId, cartToken) {
29
+ const { data, error, response } = await client.POST("/v1/checkout/{attemptId}/complete", {
30
+ params: { path: { attemptId } },
31
+ headers: cartToken ? { "X-Cart-Token": cartToken } : {},
32
+ });
33
+ if (error || !data)
34
+ return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter: retryAfterSeconds(response) };
35
+ return { ok: true, data };
36
+ }
37
+ /** `GET /v1/checkout/{attemptId}`: the non-mutating status read the browser module polls with. */
38
+ export async function getCheckoutStatus(client, attemptId, cartToken) {
39
+ const { data, error, response } = await client.GET("/v1/checkout/{attemptId}", { params: { path: { attemptId } }, headers: { "X-Cart-Token": cartToken } });
40
+ if (error || !data)
41
+ return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter: retryAfterSeconds(response) };
42
+ return { ok: true, data };
43
+ }
44
+ /** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3). */
45
+ export async function getOrder(client, orderId, token) {
46
+ const { data, error, response } = await client.GET("/v1/orders/{orderId}", { params: { path: { orderId } }, headers: { "X-Order-Token": token } });
47
+ if (error || !data)
48
+ return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")) };
49
+ return { ok: true, data };
50
+ }
51
+ const FINAL = new Set(["completed", "pending", "declined", "expired"]);
52
+ /** Polls `complete` with doubling backoff until the attempt has an order or is closed, or the timeout passes (the last answer is returned either way). */
53
+ export async function awaitCompletion(complete, opts = {}) {
54
+ const first = opts.firstDelayMs ?? 1000;
55
+ const max = opts.maxDelayMs ?? 10_000;
56
+ const timeout = opts.timeoutMs ?? 120_000;
57
+ const sleep = opts.sleep ?? defaultSleep;
58
+ let delay = first;
59
+ let waited = 0;
60
+ let last = await complete();
61
+ while (last.ok && !FINAL.has(last.data.status) && waited + delay <= timeout) {
62
+ await sleep(delay);
63
+ waited += delay;
64
+ delay = Math.min(delay * 2, max);
65
+ last = await complete();
66
+ }
67
+ return last;
68
+ }
@@ -0,0 +1,42 @@
1
+ import type { components } from "../schema";
2
+ export type CheckoutDto = components["schemas"]["CheckoutDto"];
3
+ export type CheckoutStatusDto = components["schemas"]["CheckoutStatusDto"];
4
+ export type CheckoutOrderRefDto = components["schemas"]["CheckoutOrderRefDto"];
5
+ export type CheckoutWidgetDto = components["schemas"]["CheckoutWidgetDto"];
6
+ export type OrderDto = components["schemas"]["OrderDto"];
7
+ export type StartCheckoutRequest = components["schemas"]["StartCheckoutRequest"];
8
+ /** The problem body; `cart` is the corrected cart on 409 CHECKOUT_CART_CHANGED (an extension, so it is typed loosely here). */
9
+ export type StorefrontProblem = components["schemas"]["StorefrontProblem"] & {
10
+ cart?: unknown;
11
+ };
12
+ export type CheckoutResult = {
13
+ ok: true;
14
+ data: CheckoutDto;
15
+ retryAfter?: number;
16
+ } | {
17
+ ok: false;
18
+ status: number;
19
+ problem: StorefrontProblem;
20
+ retryAfter?: number;
21
+ };
22
+ export type StatusResult = {
23
+ ok: true;
24
+ data: CheckoutStatusDto;
25
+ } | {
26
+ ok: false;
27
+ status: number;
28
+ problem: StorefrontProblem;
29
+ retryAfter?: number;
30
+ };
31
+ export type OrderResult = {
32
+ ok: true;
33
+ data: OrderDto;
34
+ } | {
35
+ ok: false;
36
+ status: number;
37
+ problem: StorefrontProblem;
38
+ };
39
+ /** `Retry-After` in seconds: a delay, or an HTTP-date turned into one; garbage is 1 s, absence is undefined. */
40
+ export declare function retryAfterSeconds(response: Response, now?: () => number): number | undefined;
41
+ /** A problem shaped like the API's, for failures that never reached it (a thrown fetch, a timeout). */
42
+ export declare function localProblem(code: string, detail: string): StorefrontProblem;
@@ -0,0 +1,17 @@
1
+ /** `Retry-After` in seconds: a delay, or an HTTP-date turned into one; garbage is 1 s, absence is undefined. */
2
+ export function retryAfterSeconds(response, now = Date.now) {
3
+ const raw = response.headers.get("Retry-After");
4
+ if (raw === null || raw === "")
5
+ return undefined;
6
+ const seconds = Number(raw);
7
+ if (Number.isFinite(seconds))
8
+ return Math.max(0, Math.ceil(seconds));
9
+ const date = Date.parse(raw);
10
+ if (Number.isFinite(date))
11
+ return Math.max(0, Math.ceil((date - now()) / 1000));
12
+ return 1;
13
+ }
14
+ /** A problem shaped like the API's, for failures that never reached it (a thrown fetch, a timeout). */
15
+ export function localProblem(code, detail) {
16
+ return { type: "https://docs.asstio.com/storefront/errors#" + code, title: code, status: 0, detail, code };
17
+ }
package/dist/index.d.ts CHANGED
@@ -47,3 +47,7 @@ export type FilterSelection = NonNullable<NonNullable<paths["/v1/products"]["get
47
47
  export declare function filterQuery(selection: Record<string, string | string[] | undefined>): FilterSelection;
48
48
  export { decodeFilterValues, encodeFilterValues } from "./filters.js";
49
49
  export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, type FormatFit, type FormattedImage, type MediaConfig, type MediaDescriptor, type MediaFormat, type PictureSources, type SlotSize, type VideoSource, } from "./media.js";
50
+ export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, startCheckout, syncCheckout, type CheckoutClient, type StartCheckoutOptions, type SyncRetryOptions, type AwaitCompletionOptions } from "./checkout/server.js";
51
+ export { retryAfterSeconds, type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type CheckoutStatusDto, type CheckoutWidgetDto, type OrderDto, type OrderResult, type StatusResult, type StorefrontProblem } from "./checkout/types.js";
52
+ export { mountCheckout, type CheckoutHandle, type MountOptions, type MinimalDocument, type MinimalElement, type MinimalScript } from "./checkout/browser.js";
53
+ export { controllers, type CheckoutController, type ProviderWindow } from "./checkout/controllers.js";
package/dist/index.js CHANGED
@@ -36,3 +36,7 @@ export function filterQuery(selection) {
36
36
  }
37
37
  export { decodeFilterValues, encodeFilterValues } from "./filters.js";
38
38
  export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, } from "./media.js";
39
+ export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, startCheckout, syncCheckout } from "./checkout/server.js"; // SyncRetryOptions *(plan review 3 #5)*
40
+ export { retryAfterSeconds } from "./checkout/types.js";
41
+ export { mountCheckout } from "./checkout/browser.js";
42
+ export { controllers } from "./checkout/controllers.js";