@asstio-dev/storefront 0.18.0 → 0.19.0
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 +38 -0
- package/dist/checkout/browser.d.ts +13 -1
- package/dist/checkout/browser.js +146 -3
- package/dist/checkout/controllers.d.ts +18 -2
- package/dist/checkout/controllers.js +133 -16
- package/dist/checkout/live-shipping.d.ts +30 -0
- package/dist/checkout/live-shipping.js +193 -0
- package/dist/checkout/server.d.ts +4 -1
- package/dist/checkout/server.js +9 -0
- package/dist/checkout/types.d.ts +12 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/schema.d.ts +127 -0
- package/package.json +2 -2
- package/src/schema.d.ts +127 -0
package/README.md
CHANGED
|
@@ -436,6 +436,8 @@ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `c
|
|
|
436
436
|
Qliro shows the confirmed total or version.
|
|
437
437
|
- When our validation declines a purchase (the cart moved on, the attempt expired, a line is out of stock), Qliro shows
|
|
438
438
|
its out-of-stock message and fires `q1.onPaymentDeclined`; the SDK then syncs by itself and remounts or resumes.
|
|
439
|
+
- `q1` callbacks replace per event. The SDK owns `onOrderUpdated` and `onPaymentDeclined`, and with `onShipping` also
|
|
440
|
+
`onShippingMethodChanged` and `onShippingPriceChanged`: a listener of your own on these silently stops the SDK's.
|
|
439
441
|
|
|
440
442
|
#### Kustom (`klarna-kco`)
|
|
441
443
|
|
|
@@ -457,6 +459,8 @@ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `c
|
|
|
457
459
|
- Never bind `can_not_complete_order` yourself through `window._klarnaCheckout(api => api.on(...))`. Kustom's `api.on`
|
|
458
460
|
replaces the listener per event instead of adding one, so yours and the SDK's would replace each other. Use
|
|
459
461
|
`onProblem`. Listeners for other events are not affected.
|
|
462
|
+
With `onShipping` set (see "Live shipping view" below) the SDK also owns `shipping_option_change`,
|
|
463
|
+
`shipping_address_change` and `order_total_change` (one separate `api.on`); use `onShipping` for those.
|
|
460
464
|
- **CSP hosts** (K-C10, `docs/ops/kustom-playground-checks.md`, 2026-10-09: a card with 3-D Secure and the default
|
|
461
465
|
method, no blocked request). The checkout page itself loads only Kustom's script host. Everything else
|
|
462
466
|
(`kco.assets.kustomcdn.co`, Klarna, Stripe, hCaptcha, Google Pay, Amazon Pay) loads inside Kustom's iframe under
|
|
@@ -505,6 +509,40 @@ if (estimate?.available) {
|
|
|
505
509
|
normally held in Asstio with its reason (rarely the export waits for an operator instead); the page has nothing to
|
|
506
510
|
do for it.
|
|
507
511
|
|
|
512
|
+
#### Live shipping view
|
|
513
|
+
|
|
514
|
+
Optionally show what the shopper has chosen inside the widget, from `GET /v1/checkout/{attemptId}/shipping`. The view is
|
|
515
|
+
not a promise: the order's `lines` and `totals` stay the truth.
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
// Server route: forwards the view and Retry-After. The cart token stays on the server.
|
|
519
|
+
const r = await liveShippingFor(client, cartToken, attemptId);
|
|
520
|
+
// r.ok ? r.data : r.problem, with r.retryAfter as the Retry-After header
|
|
521
|
+
|
|
522
|
+
// Page:
|
|
523
|
+
mountCheckout(el, widget, {
|
|
524
|
+
/* … */
|
|
525
|
+
liveShipping: (attemptId) => callMyProxy(attemptId), // returns LiveShippingResult
|
|
526
|
+
onShipping: (view) => {
|
|
527
|
+
if (view === null) return showEstimate(); // invalidated: a change is in flight
|
|
528
|
+
if (view.state === "selected") showLine(view.name, view.shippingInc, view.totalInc);
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
- `liveShipping` and `onShipping` are required together (one without the other throws at mount). Without them nothing
|
|
534
|
+
shipping-related is bound, scheduled or called.
|
|
535
|
+
- `view.state`: `selected`, `unselected`, `unmatched`, `unavailable` (with `reason`: `syncing`, `closed`, `provider`,
|
|
536
|
+
`throttled`, `mismatch`, `erased`, `not_supported`, and `retryAfterSeconds`).
|
|
537
|
+
- Members: `goodsInc`, `shippingInc`, `totalInc`, `otherInc`, `totalSource`, `option`, `name`, `deliveryType`, `point`,
|
|
538
|
+
`readAt`, `cartVersion`. `totalInc` is the provider-reported total, before payment-method charges the provider does
|
|
539
|
+
not report before purchase. `name` can differ from Kustom's display copy.
|
|
540
|
+
- Freshness: a method change on both providers, and an address change on Kustom, typically follows the provider's event
|
|
541
|
+
within a couple of seconds. A Kustom pickup-point change and a Qliro address change fire no client event we know of and
|
|
542
|
+
are seen only by the safety net (every 45 to 60 s, best-effort, up to about 60 s), which runs only while the tab is
|
|
543
|
+
visible.
|
|
544
|
+
- The SDK owns the provider's shipping events while `onShipping` is set; do not bind them yourself.
|
|
545
|
+
|
|
508
546
|
## Headers
|
|
509
547
|
|
|
510
548
|
Every call the client makes carries these; you never set them by hand.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ProviderWindow } from "./controllers.js";
|
|
2
|
-
import { type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type StatusResult, type StorefrontProblem } from "./types.js";
|
|
2
|
+
import { type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type CheckoutShippingViewDto, type LiveShippingResult, type StatusResult, type StorefrontProblem } from "./types.js";
|
|
3
3
|
export interface MinimalScript {
|
|
4
4
|
src: string;
|
|
5
5
|
textContent: string | null;
|
|
@@ -62,6 +62,12 @@ export interface MountOptions {
|
|
|
62
62
|
bindRetryMs?: number;
|
|
63
63
|
/** How long before `expiresInSeconds` the SDK syncs by itself (inside the server's five-minute window). */
|
|
64
64
|
preExpiryMs?: number;
|
|
65
|
+
/** A `GET /checkout/{attemptId}/shipping` on the attempt named (server-side, with the cart token, e.g.
|
|
66
|
+
* `liveShippingFor`). Must be provided together with `onShipping`; one without the other throws at mount. */
|
|
67
|
+
liveShipping?: (attemptId: string) => Promise<LiveShippingResult>;
|
|
68
|
+
/** Receives the live shipping view; `null` means invalidated: show the estimate or a placeholder. Must be provided
|
|
69
|
+
* together with `liveShipping`. Without both, nothing shipping-related is bound, scheduled or called. */
|
|
70
|
+
onShipping?: (view: CheckoutShippingViewDto | null) => void;
|
|
65
71
|
}
|
|
66
72
|
export interface CheckoutHandle {
|
|
67
73
|
readonly attemptId: string;
|
|
@@ -73,4 +79,10 @@ export interface CheckoutHandle {
|
|
|
73
79
|
/** The whole rule: suspend → `await update()` once → status first, identity on `open`. Never rejects. */
|
|
74
80
|
sync(update: () => Promise<CheckoutResult>): Promise<void>;
|
|
75
81
|
}
|
|
82
|
+
/** Kustom replays its events once, about 1 s after the first `api.on` registration (K-L15). A shipping event within this
|
|
83
|
+
* window of a mount's first successful shipping subscription, before any later event, is that replay: it carries no
|
|
84
|
+
* change, so it is not an invalidation. It joins the explicit first read if that read has not started, and is dropped
|
|
85
|
+
* if a read already started at or after the subscription (that read already covers it). Applied to `klarna-kco` only:
|
|
86
|
+
* no other provider replays, so their events always invalidate. */
|
|
87
|
+
export declare const REPLAY_WINDOW_MS = 1500;
|
|
76
88
|
export declare function mountCheckout(container: MinimalElement, checkout: CheckoutDto, options: MountOptions): CheckoutHandle;
|
package/dist/checkout/browser.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { controllerFor } from "./controllers.js";
|
|
2
|
+
import { createLiveShippingObserver } from "./live-shipping.js";
|
|
2
3
|
import { localProblem } from "./types.js";
|
|
3
4
|
const FINAL_ORDER = new Set(["completed", "pending"]);
|
|
5
|
+
/** Kustom replays its events once, about 1 s after the first `api.on` registration (K-L15). A shipping event within this
|
|
6
|
+
* window of a mount's first successful shipping subscription, before any later event, is that replay: it carries no
|
|
7
|
+
* change, so it is not an invalidation. It joins the explicit first read if that read has not started, and is dropped
|
|
8
|
+
* if a read already started at or after the subscription (that read already covers it). Applied to `klarna-kco` only:
|
|
9
|
+
* no other provider replays, so their events always invalidate. */
|
|
10
|
+
export const REPLAY_WINDOW_MS = 1500;
|
|
4
11
|
export function mountCheckout(container, checkout, options) {
|
|
12
|
+
if ((options.liveShipping === undefined) !== (options.onShipping === undefined))
|
|
13
|
+
throw new Error("liveShipping and onShipping must be provided together");
|
|
5
14
|
if (checkout.status !== "open" || !checkout.widget)
|
|
6
15
|
throw new Error("mountCheckout needs an `open` answer with a widget");
|
|
7
16
|
const doc = options.document ?? globalThis.document;
|
|
@@ -36,6 +45,7 @@ export function mountCheckout(container, checkout, options) {
|
|
|
36
45
|
let queue = Promise.resolve(); // *(plan review 1 #43)*
|
|
37
46
|
let unsubscribeDeclined;
|
|
38
47
|
let unsubscribeCannotComplete;
|
|
48
|
+
let unsubscribeShipping;
|
|
39
49
|
let expiryTimer;
|
|
40
50
|
let firedPreExpiryFor; // *(plan review 1 #42, plan review 2 #12)*
|
|
41
51
|
// Every pending sleep's resolver, so a remount or destroy can settle it instead of clearing its timer and leaving
|
|
@@ -79,6 +89,87 @@ export function mountCheckout(container, checkout, options) {
|
|
|
79
89
|
if (alive(gen))
|
|
80
90
|
options.onProblem(localProblem(code, message(e)), { status: 0 });
|
|
81
91
|
}
|
|
92
|
+
// ---- Live shipping (spec 2026-10-12 §4.6) ---------------------------------------------------------------------
|
|
93
|
+
// A separate observation flow, never driven through `dispatch` or `poll`. One observer per mount: a remount or destroy
|
|
94
|
+
// destroys it, so an answer or a provider callback of an older mount is dropped. Without `onShipping` it never exists.
|
|
95
|
+
let observer;
|
|
96
|
+
let observerSuspended = false; // the observer was told `suspended()` and not yet `resumed()`
|
|
97
|
+
let observerResumeReady = false; // the provider reported it is resumed (or this mount is fresh): resume once drained
|
|
98
|
+
let shippingBoundAt; // this mount's first successful shipping subscription
|
|
99
|
+
let lastReadAt; // when this mount's observer last started a read
|
|
100
|
+
let replayOver = false; // a non-replay shipping event was handled on this mount
|
|
101
|
+
function createObserver(gen) {
|
|
102
|
+
if (!options.onShipping || !options.liveShipping)
|
|
103
|
+
return undefined;
|
|
104
|
+
const onShipping = options.onShipping;
|
|
105
|
+
const liveShipping = options.liveShipping;
|
|
106
|
+
let reported = false; // SDK_LIVE_SHIPPING_FAILED once per mount, for liveShipping and onShipping alike
|
|
107
|
+
const report = (e) => {
|
|
108
|
+
if (reported || !alive(gen))
|
|
109
|
+
return;
|
|
110
|
+
reported = true;
|
|
111
|
+
try {
|
|
112
|
+
reportLocal(gen, "SDK_LIVE_SHIPPING_FAILED", e);
|
|
113
|
+
}
|
|
114
|
+
catch { /* an app callback: never reaches checkout */ }
|
|
115
|
+
};
|
|
116
|
+
shippingBoundAt = undefined;
|
|
117
|
+
lastReadAt = undefined;
|
|
118
|
+
replayOver = false;
|
|
119
|
+
return createLiveShippingObserver({
|
|
120
|
+
// A synchronous throw and a rejection are both reported, then rethrown: the observer still sees a failed read.
|
|
121
|
+
// The attempt is read at call time.
|
|
122
|
+
read: async () => {
|
|
123
|
+
lastReadAt = now();
|
|
124
|
+
try {
|
|
125
|
+
return await liveShipping(attemptId);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
report(e);
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
deliver: (view) => {
|
|
133
|
+
if (!alive(gen))
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
onShipping(view);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
report(e);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
now,
|
|
143
|
+
// The handle's timers, so a remount or destroy cancels them with every other.
|
|
144
|
+
setTimeout: (fn, ms) => { const h = setT(() => { timers.delete(h); fn(); }, ms); timers.add(h); return h; },
|
|
145
|
+
clearTimeout: (h) => { clearT(h); timers.delete(h); },
|
|
146
|
+
visible: () => doc.visibilityState === "visible",
|
|
147
|
+
random: Math.random,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function observerSuspend() {
|
|
151
|
+
if (!observer)
|
|
152
|
+
return;
|
|
153
|
+
observerResumeReady = false;
|
|
154
|
+
if (observerSuspended)
|
|
155
|
+
return;
|
|
156
|
+
observerSuspended = true;
|
|
157
|
+
observer.suspended();
|
|
158
|
+
}
|
|
159
|
+
/** The refresh after a sync waits for both: the provider is actually resumed and the sync queue has drained. */
|
|
160
|
+
function observerTryResume() {
|
|
161
|
+
if (!observer || !observerSuspended || !observerResumeReady || active > 0)
|
|
162
|
+
return;
|
|
163
|
+
observerSuspended = false;
|
|
164
|
+
observerResumeReady = false;
|
|
165
|
+
observer.resumed();
|
|
166
|
+
}
|
|
167
|
+
function observerProviderResumed(target) {
|
|
168
|
+
if (!observer || observer !== target)
|
|
169
|
+
return;
|
|
170
|
+
observerResumeReady = true;
|
|
171
|
+
observerTryResume();
|
|
172
|
+
}
|
|
82
173
|
function render(dto) {
|
|
83
174
|
const w = dto.widget;
|
|
84
175
|
container.innerHTML = "";
|
|
@@ -108,6 +199,7 @@ export function mountCheckout(container, checkout, options) {
|
|
|
108
199
|
}
|
|
109
200
|
function suspend() {
|
|
110
201
|
suspended = true;
|
|
202
|
+
observerSuspend();
|
|
111
203
|
if (suspendedOn === controller)
|
|
112
204
|
return;
|
|
113
205
|
// Recorded before the call, as on main: a controller whose suspend throws (a broken provider `lock()`) stays recorded, so a
|
|
@@ -124,6 +216,13 @@ export function mountCheckout(container, checkout, options) {
|
|
|
124
216
|
suspendedOn = undefined;
|
|
125
217
|
controller.resume(target);
|
|
126
218
|
} }
|
|
219
|
+
/** The app's own `handle.resume()`: no confirmed answer to wait for, so the observer resumes once the queue drains. */
|
|
220
|
+
function appResume() {
|
|
221
|
+
const was = suspended;
|
|
222
|
+
resume();
|
|
223
|
+
if (was)
|
|
224
|
+
observerProviderResumed(observer);
|
|
225
|
+
}
|
|
127
226
|
// ---- The locked burst (spec §10.3) ---------------------------------------------------------------------------
|
|
128
227
|
/** One SDK-owned `POST /checkout`; a transport rejection is reported and swallowed — the caller stops, the widget
|
|
129
228
|
* stays suspended and the queue is released *(plan review 2 #17)*. Posted directly: the rolling-minute ledger, its
|
|
@@ -191,6 +290,8 @@ export function mountCheckout(container, checkout, options) {
|
|
|
191
290
|
unsubscribeDeclined = undefined;
|
|
192
291
|
unsubscribeCannotComplete?.();
|
|
193
292
|
unsubscribeCannotComplete = undefined;
|
|
293
|
+
unsubscribeShipping?.();
|
|
294
|
+
unsubscribeShipping = undefined;
|
|
194
295
|
controller = bound;
|
|
195
296
|
const gen = generation;
|
|
196
297
|
// `render` appends the provider's script; it has not executed when this runs, so the global the controller reads
|
|
@@ -219,6 +320,25 @@ export function mountCheckout(container, checkout, options) {
|
|
|
219
320
|
// Spec F §7.1: Kustom's can_not_complete_order is the app's problem to show, not a validation decline (no sync).
|
|
220
321
|
bind(bound.onCannotComplete, () => { if (alive(gen))
|
|
221
322
|
options.onProblem(localProblem("SDK_PROVIDER_CANNOT_COMPLETE", "can_not_complete_order"), { status: 0 }); }, (off) => { unsubscribeCannotComplete = off; });
|
|
323
|
+
// Live shipping: only with `onShipping`. The provider's shipping signals are a trigger only (spec §4.6).
|
|
324
|
+
const obs = observer;
|
|
325
|
+
// Only Kustom replays its shipping events after registration (K-L15); every other provider's event is real, also
|
|
326
|
+
// inside the window, and always invalidates *(final review B)*.
|
|
327
|
+
const replays = dto.widget.controller === "klarna-kco";
|
|
328
|
+
const onShippingEvent = () => {
|
|
329
|
+
if (!alive(gen))
|
|
330
|
+
return;
|
|
331
|
+
if (replays && !replayOver && shippingBoundAt !== undefined && now() - shippingBoundAt <= REPLAY_WINDOW_MS) {
|
|
332
|
+
// Kustom's replay: no invalidation. Covered by a read started since the subscription, else it joins the first read.
|
|
333
|
+
if (lastReadAt === undefined || lastReadAt < shippingBoundAt)
|
|
334
|
+
obs.trigger("first");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
replayOver = true;
|
|
338
|
+
obs.trigger("event");
|
|
339
|
+
};
|
|
340
|
+
if (obs)
|
|
341
|
+
bind(bound.onShippingEvent, onShippingEvent, (off) => { unsubscribeShipping = off; shippingBoundAt ??= now(); });
|
|
222
342
|
}
|
|
223
343
|
function remount(dto) {
|
|
224
344
|
if (dto.status !== "open" || !dto.widget)
|
|
@@ -227,11 +347,20 @@ export function mountCheckout(container, checkout, options) {
|
|
|
227
347
|
// dispatch's catch reports it instead of dropping it for a generation that moved on *(final review B3 T2)*.
|
|
228
348
|
const next = controllerFor(dto.widget.controller, win, controllerTimer);
|
|
229
349
|
controller.dispose?.(); // spec F §7.1: the old mount's deferred callbacks and timers do nothing now
|
|
350
|
+
observer?.destroy(); // the old observation's sequence is gone with its mount
|
|
230
351
|
generation++;
|
|
231
352
|
cancelTimers(); // *(plan review 1 #37)*
|
|
232
353
|
expiryTimer = undefined;
|
|
233
354
|
firedPreExpiryFor = undefined;
|
|
234
355
|
attemptId = dto.attemptId;
|
|
356
|
+
// The new mount owns the recovery read: invalidate (`null`) now, read once the sync queue has drained — a remount
|
|
357
|
+
// inside a dispatch runs while `active > 0`, so the refresh is queued, never skipped (spec §4.6 Resume). The new
|
|
358
|
+
// widget needs no provider resume signal (no controller call after a remount); a sync still queued suspends it again.
|
|
359
|
+
observer = createObserver(generation);
|
|
360
|
+
observerSuspended = false;
|
|
361
|
+
observerResumeReady = false;
|
|
362
|
+
observerSuspend();
|
|
363
|
+
observerResumeReady = !!observer;
|
|
235
364
|
// The ruling of round 1 #39: no controller call after a remount (the new widget's script has not loaded), and the
|
|
236
365
|
// flag is cleared. Unless a sync is queued behind the one running: then payment stays suspended, and that sync
|
|
237
366
|
// suspends the new controller when it starts (`suspendedOn` is cleared) *(plan review 2 #11)*.
|
|
@@ -240,6 +369,7 @@ export function mountCheckout(container, checkout, options) {
|
|
|
240
369
|
render(dto);
|
|
241
370
|
bindProvider(dto, next);
|
|
242
371
|
armExpiry(dto);
|
|
372
|
+
observerTryResume();
|
|
243
373
|
}
|
|
244
374
|
const onVisible = () => { if (!destroyed && suspended && doc.visibilityState === "visible")
|
|
245
375
|
autonomousSync("visible", generation); };
|
|
@@ -302,6 +432,7 @@ export function mountCheckout(container, checkout, options) {
|
|
|
302
432
|
finally {
|
|
303
433
|
active--;
|
|
304
434
|
release();
|
|
435
|
+
observerTryResume(); // the refresh after a sync waits for the queue to drain (spec §4.6 Resume)
|
|
305
436
|
}
|
|
306
437
|
})();
|
|
307
438
|
}
|
|
@@ -356,9 +487,13 @@ export function mountCheckout(container, checkout, options) {
|
|
|
356
487
|
// Another update is queued behind this one: its mutation is still to come, so payment stays suspended
|
|
357
488
|
// until its own answer *(plan review 2 #11)*. The provider unlocks once its view shows `dto.cart`; if it
|
|
358
489
|
// never does, the widget is re-rendered from this answer *(plan review 1 #3)*.
|
|
359
|
-
if (active <= 1)
|
|
490
|
+
if (active <= 1) {
|
|
491
|
+
const obs = observer;
|
|
360
492
|
resume({ cart: dto.cart, cartVersion: Number(dto.cartVersion), onUnsynced: () => { if (alive(gen))
|
|
361
|
-
remount(dto); }
|
|
493
|
+
remount(dto); },
|
|
494
|
+
onResumed: () => { if (alive(gen))
|
|
495
|
+
observerProviderResumed(obs); } });
|
|
496
|
+
}
|
|
362
497
|
}
|
|
363
498
|
else {
|
|
364
499
|
remount(dto);
|
|
@@ -428,11 +563,15 @@ export function mountCheckout(container, checkout, options) {
|
|
|
428
563
|
const handle = {
|
|
429
564
|
get attemptId() { return attemptId; },
|
|
430
565
|
get suspended() { return suspended; },
|
|
431
|
-
suspend, resume, remount,
|
|
566
|
+
suspend, resume: appResume, remount,
|
|
432
567
|
destroy() {
|
|
433
568
|
destroyed = true;
|
|
434
569
|
generation++;
|
|
570
|
+
observer?.destroy();
|
|
571
|
+
observer = undefined;
|
|
435
572
|
cancelTimers(); // *(plan review 1 #37)*
|
|
573
|
+
unsubscribeShipping?.();
|
|
574
|
+
unsubscribeShipping = undefined;
|
|
436
575
|
unsubscribeDeclined?.();
|
|
437
576
|
unsubscribeDeclined = undefined;
|
|
438
577
|
unsubscribeCannotComplete?.();
|
|
@@ -456,8 +595,12 @@ export function mountCheckout(container, checkout, options) {
|
|
|
456
595
|
});
|
|
457
596
|
},
|
|
458
597
|
};
|
|
598
|
+
observer = createObserver(generation);
|
|
459
599
|
render(checkout);
|
|
460
600
|
bindProvider(checkout);
|
|
461
601
|
armExpiry(checkout);
|
|
602
|
+
// The explicit first read: Kustom replays its events once after the first registration and Qliro never replays, so the
|
|
603
|
+
// first view never depends on a provider event; a replay inside the debounce coalesces with it (spec §4.6 Binding).
|
|
604
|
+
observer?.trigger("first");
|
|
462
605
|
return handle;
|
|
463
606
|
}
|
|
@@ -20,6 +20,10 @@ export interface ProviderWindow {
|
|
|
20
20
|
/** Fires when our validation callback declined the purchase, with the `DeclineReason` it answered
|
|
21
21
|
* (developers.qliro.com → Listeners). */
|
|
22
22
|
onPaymentDeclined?(cb: (declineReason?: string, declineReasonMessage?: string) => void): void;
|
|
23
|
+
/** Shipping listeners (developers.qliro.com → Listeners). Registration replaces per event and nothing replays; the
|
|
24
|
+
* payload is never read, a call is only a refresh trigger. */
|
|
25
|
+
onShippingMethodChanged?(cb: (payload?: unknown) => void): void;
|
|
26
|
+
onShippingPriceChanged?(cb: (price?: unknown) => void): void;
|
|
23
27
|
};
|
|
24
28
|
_klarnaCheckout?: (cb: (api: KlarnaCheckoutApi) => void) => void;
|
|
25
29
|
walley?: {
|
|
@@ -42,6 +46,10 @@ export interface ResumeTarget {
|
|
|
42
46
|
cartVersion?: number;
|
|
43
47
|
/** The provider never showed `cart` before its deadline: the handle re-renders the widget from the confirmed answer. */
|
|
44
48
|
onUnsynced(): void;
|
|
49
|
+
/** The provider is actually resumed: Qliro on the matching `onOrderUpdated` (with the unlock), Kustom after `api.resume()`
|
|
50
|
+
* ran, Walley after its `resume()`; at once when nothing was held (the provider global is missing, or Kustom's throws).
|
|
51
|
+
* Never called on a Qliro deadline, nor when Qliro's lock was missed (that is `onUnsynced`). */
|
|
52
|
+
onResumed?(): void;
|
|
45
53
|
}
|
|
46
54
|
export interface CheckoutController {
|
|
47
55
|
/** Returns `false` when the suspension could not be handed to the provider yet (its script has not defined its API): the
|
|
@@ -57,6 +65,10 @@ export interface CheckoutController {
|
|
|
57
65
|
/** The provider says no offered payment method can complete the order (Kustom's `can_not_complete_order`). Same binding
|
|
58
66
|
* contract as `onDeclined`: `undefined` until the provider's script exposes it. */
|
|
59
67
|
onCannotComplete?(handler: () => void): (() => void) | undefined;
|
|
68
|
+
/** The provider's shipping-change signals (Kustom `shipping_option_change`, `shipping_address_change`,
|
|
69
|
+
* `order_total_change`; Qliro `onShippingMethodChanged`, `onShippingPriceChanged`). The handler is only a trigger. Same
|
|
70
|
+
* binding contract as `onDeclined`: `undefined` until the provider's script exposes the signals; returns the unsubscribe. */
|
|
71
|
+
onShippingEvent?(handler: () => void): (() => void) | undefined;
|
|
60
72
|
/** This mount is over (remount, destroy): every deferred provider callback and timer of this controller does nothing after it. */
|
|
61
73
|
dispose?(): void;
|
|
62
74
|
}
|
|
@@ -68,9 +80,13 @@ export type ControllerFactory = (win: ProviderWindow, timer: ControllerTimer) =>
|
|
|
68
80
|
/** How long `qliro-q1`'s resume waits for `q1.onOrderUpdated` to show the confirmed cart. Past it the widget is
|
|
69
81
|
* re-rendered, never unlocked blindly *(plan review 1 #3)*. */
|
|
70
82
|
export declare const QLIRO_SYNC_DEADLINE_MS = 10000;
|
|
83
|
+
/** How often `qliro-q1`'s shipping dispatcher checks whether `window.q1` is a new object (a re-render replaces it, and a
|
|
84
|
+
* registration on a stale instance is silent). */
|
|
85
|
+
export declare const QLIRO_REBIND_MS = 500;
|
|
71
86
|
/** Qliro's check before `unlock()` (*Update order*: "Verify that the total amount of the order = total amount in the
|
|
72
|
-
* shopping cart"), in öre, **or** the `MerchantUpdateVersion` our PUT sent (carriers 1b ruling R-S3-K1:
|
|
73
|
-
*
|
|
87
|
+
* shopping cart"), in öre, **or** the `MerchantUpdateVersion` our PUT sent (carriers 1b ruling R-S3-K1: Qliro's client
|
|
88
|
+
* `onOrderUpdated.totalPrice` is goods only while the GET total includes shipping (Q-L2), so on a shipping market the two
|
|
89
|
+
* totals can differ and the total alone is not a reliable match). The echo is a string in Qliro's schema; its
|
|
74
90
|
* name is read case-insensitively (VQ-8 records the real one). */
|
|
75
91
|
export declare function qliroOrderMatches(order: unknown, target: Pick<ResumeTarget, "cart" | "cartVersion">): boolean;
|
|
76
92
|
export declare const controllers: Record<"qliro-q1" | "klarna-kco" | "walley", ControllerFactory>;
|
|
@@ -3,10 +3,14 @@ export const KLARNA_READY_POLL_MS = 100;
|
|
|
3
3
|
/** How long `qliro-q1`'s resume waits for `q1.onOrderUpdated` to show the confirmed cart. Past it the widget is
|
|
4
4
|
* re-rendered, never unlocked blindly *(plan review 1 #3)*. */
|
|
5
5
|
export const QLIRO_SYNC_DEADLINE_MS = 10_000;
|
|
6
|
+
/** How often `qliro-q1`'s shipping dispatcher checks whether `window.q1` is a new object (a re-render replaces it, and a
|
|
7
|
+
* registration on a stale instance is silent). */
|
|
8
|
+
export const QLIRO_REBIND_MS = 500;
|
|
6
9
|
const ore = (v) => Math.round(Number(v) * 100);
|
|
7
10
|
/** Qliro's check before `unlock()` (*Update order*: "Verify that the total amount of the order = total amount in the
|
|
8
|
-
* shopping cart"), in öre, **or** the `MerchantUpdateVersion` our PUT sent (carriers 1b ruling R-S3-K1:
|
|
9
|
-
*
|
|
11
|
+
* shopping cart"), in öre, **or** the `MerchantUpdateVersion` our PUT sent (carriers 1b ruling R-S3-K1: Qliro's client
|
|
12
|
+
* `onOrderUpdated.totalPrice` is goods only while the GET total includes shipping (Q-L2), so on a shipping market the two
|
|
13
|
+
* totals can differ and the total alone is not a reliable match). The echo is a string in Qliro's schema; its
|
|
10
14
|
* name is read case-insensitively (VQ-8 records the real one). */
|
|
11
15
|
export function qliroOrderMatches(order, target) {
|
|
12
16
|
const o = order;
|
|
@@ -31,6 +35,69 @@ export const controllers = {
|
|
|
31
35
|
// Whether the last suspend reached `q1.lock()`: before the snippet's script has run there is no `q1` to lock, and the
|
|
32
36
|
// iframe may load the order before the PUT *(final review #4)*.
|
|
33
37
|
let locked = false;
|
|
38
|
+
// The passive-listener dispatcher (spec §4.6 Binding): each passive q1 listener (the shipping pair, onPaymentDeclined) is
|
|
39
|
+
// registered once per q1 object and fans out to every handler of its kind. The identity watch runs only while a
|
|
40
|
+
// shipping handler exists (no new work without `onShipping`); while it runs it rebinds the decline signal too, because a
|
|
41
|
+
// re-render replaces `window.q1` and a registration on the stale instance is silent.
|
|
42
|
+
let disposed = false;
|
|
43
|
+
const handlers = { shipping: new Set(), declined: new Set() };
|
|
44
|
+
const registered = new WeakMap();
|
|
45
|
+
let current; // the q1 whose callbacks may fire
|
|
46
|
+
let watchEpoch = 0;
|
|
47
|
+
let watching = false;
|
|
48
|
+
const fire = (q1, kind) => () => { if (disposed || q1 !== current)
|
|
49
|
+
return; for (const h of [...handlers[kind]])
|
|
50
|
+
h(); };
|
|
51
|
+
const register = (kind) => {
|
|
52
|
+
const q1 = win.q1;
|
|
53
|
+
if (!q1)
|
|
54
|
+
return false;
|
|
55
|
+
const ready = kind === "shipping" ? !!(q1.onShippingMethodChanged && q1.onShippingPriceChanged) : !!q1.onPaymentDeclined;
|
|
56
|
+
if (!ready)
|
|
57
|
+
return false;
|
|
58
|
+
current = q1;
|
|
59
|
+
let done = registered.get(q1);
|
|
60
|
+
if (!done)
|
|
61
|
+
registered.set(q1, (done = new Set()));
|
|
62
|
+
if (done.has(kind))
|
|
63
|
+
return true;
|
|
64
|
+
const cb = fire(q1, kind);
|
|
65
|
+
if (kind === "shipping") {
|
|
66
|
+
q1.onShippingMethodChanged(cb);
|
|
67
|
+
q1.onShippingPriceChanged(cb);
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
q1.onPaymentDeclined(() => cb());
|
|
71
|
+
done.add(kind);
|
|
72
|
+
return true;
|
|
73
|
+
};
|
|
74
|
+
const tryRegister = (kind) => { try {
|
|
75
|
+
return register(kind);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
} };
|
|
80
|
+
const stopWatch = () => { watchEpoch++; watching = false; };
|
|
81
|
+
const startWatch = () => {
|
|
82
|
+
if (watching)
|
|
83
|
+
return;
|
|
84
|
+
watching = true;
|
|
85
|
+
const mine = ++watchEpoch;
|
|
86
|
+
const tick = () => {
|
|
87
|
+
if (disposed || mine !== watchEpoch || handlers.shipping.size === 0)
|
|
88
|
+
return;
|
|
89
|
+
for (const kind of ["shipping", "declined"]) {
|
|
90
|
+
if (handlers[kind].size === 0)
|
|
91
|
+
continue;
|
|
92
|
+
try {
|
|
93
|
+
register(kind);
|
|
94
|
+
}
|
|
95
|
+
catch { /* not ready: the next tick retries */ }
|
|
96
|
+
}
|
|
97
|
+
timer(tick, QLIRO_REBIND_MS);
|
|
98
|
+
};
|
|
99
|
+
timer(tick, QLIRO_REBIND_MS);
|
|
100
|
+
};
|
|
34
101
|
return {
|
|
35
102
|
suspend: () => { generation++; locked = false; const q1 = win.q1; if (q1) {
|
|
36
103
|
q1.lock();
|
|
@@ -40,14 +107,22 @@ export const controllers = {
|
|
|
40
107
|
const q1 = win.q1;
|
|
41
108
|
const missedLock = !locked;
|
|
42
109
|
locked = false;
|
|
110
|
+
// Nothing held and nothing to resume: still a resumed signal, or the live shipping observer stays suspended.
|
|
43
111
|
if (!q1) {
|
|
44
112
|
if (missedLock && target)
|
|
45
113
|
target.onUnsynced();
|
|
114
|
+
else
|
|
115
|
+
target?.onResumed?.();
|
|
46
116
|
return;
|
|
47
117
|
}
|
|
48
118
|
const mine = ++generation;
|
|
49
|
-
const unlock = () => {
|
|
50
|
-
|
|
119
|
+
const unlock = () => {
|
|
120
|
+
if (generation !== mine || disposed)
|
|
121
|
+
return;
|
|
122
|
+
generation++;
|
|
123
|
+
q1.unlock();
|
|
124
|
+
target?.onResumed?.();
|
|
125
|
+
};
|
|
51
126
|
// Nothing to compare — no sync API, or the app resumed without an answer: unlock at once, as B did.
|
|
52
127
|
if (!q1.onOrderUpdated || !target) {
|
|
53
128
|
unlock();
|
|
@@ -55,22 +130,33 @@ export const controllers = {
|
|
|
55
130
|
}
|
|
56
131
|
if (missedLock)
|
|
57
132
|
q1.lock(); // onOrderUpdated "requires q1.lock() to have been called first"
|
|
58
|
-
q1.onOrderUpdated((order) => { if (qliroOrderMatches(order, target))
|
|
133
|
+
q1.onOrderUpdated((order) => { if (!disposed && qliroOrderMatches(order, target))
|
|
59
134
|
unlock(); });
|
|
60
135
|
// Never unlocked blindly: past the deadline the handle re-renders the widget from the confirmed answer
|
|
61
136
|
// *(plan review 1 #3)*.
|
|
62
|
-
timer(() => { if (generation !== mine)
|
|
137
|
+
timer(() => { if (generation !== mine || disposed)
|
|
63
138
|
return; generation++; target.onUnsynced(); }, QLIRO_SYNC_DEADLINE_MS);
|
|
64
139
|
},
|
|
65
140
|
onDeclined: (handler) => {
|
|
66
141
|
// Confirmed (plan C): `q1.onPaymentDeclined` fires on our validation decline. `undefined` until `q1` and the signal
|
|
67
|
-
// exist *(plan review 2 #13)*.
|
|
68
|
-
|
|
69
|
-
|
|
142
|
+
// exist *(plan review 2 #13)*. Registered through the dispatcher, so the shipping watch rebinds it on a new q1.
|
|
143
|
+
// A throwing q1 (a stale instance during a remount) is not ready, as the Kustom path: it must not abort the remount
|
|
144
|
+
// before its expiry is armed; the bind retry tries again *(final review E)*.
|
|
145
|
+
if (disposed || !tryRegister("declined"))
|
|
70
146
|
return undefined;
|
|
71
|
-
|
|
72
|
-
|
|
147
|
+
handlers.declined.add(handler);
|
|
148
|
+
// q1 offers no unsubscribe: the handler leaves the fan-out, and the handle's generation guard drops late calls.
|
|
149
|
+
return () => { handlers.declined.delete(handler); };
|
|
73
150
|
},
|
|
151
|
+
onShippingEvent: (handler) => {
|
|
152
|
+
if (disposed || !tryRegister("shipping"))
|
|
153
|
+
return undefined;
|
|
154
|
+
handlers.shipping.add(handler);
|
|
155
|
+
startWatch();
|
|
156
|
+
return () => { handlers.shipping.delete(handler); if (handlers.shipping.size === 0)
|
|
157
|
+
stopWatch(); };
|
|
158
|
+
},
|
|
159
|
+
dispose: () => { disposed = true; stopWatch(); handlers.shipping.clear(); handlers.declined.clear(); },
|
|
74
160
|
};
|
|
75
161
|
},
|
|
76
162
|
"klarna-kco": (win, timer) => {
|
|
@@ -84,7 +170,7 @@ export const controllers = {
|
|
|
84
170
|
// The desired-state generation already handed to `_klarnaCheckout`: the readiness timer hands over only a wish that was
|
|
85
171
|
// not, so an immediate handover by a later suspend() is never repeated by the timer *(review F2-#22)*.
|
|
86
172
|
let handed = 0;
|
|
87
|
-
const hand = (want, mine) => {
|
|
173
|
+
const hand = (want, mine, after) => {
|
|
88
174
|
const kco = win._klarnaCheckout;
|
|
89
175
|
if (!kco)
|
|
90
176
|
return false;
|
|
@@ -96,8 +182,10 @@ export const controllers = {
|
|
|
96
182
|
return;
|
|
97
183
|
if (want === "suspended")
|
|
98
184
|
api.suspend({ autoResume: { enabled: false } });
|
|
99
|
-
else
|
|
185
|
+
else {
|
|
100
186
|
api.resume();
|
|
187
|
+
after?.();
|
|
188
|
+
}
|
|
101
189
|
});
|
|
102
190
|
}
|
|
103
191
|
catch (e) {
|
|
@@ -140,15 +228,19 @@ export const controllers = {
|
|
|
140
228
|
awaitScript();
|
|
141
229
|
return false;
|
|
142
230
|
},
|
|
143
|
-
resume: () => {
|
|
231
|
+
resume: (target) => {
|
|
144
232
|
const mine = ++seq;
|
|
145
233
|
desired = "resumed";
|
|
146
234
|
// Before the script ran there is nothing to resume; a pending readiness check sees `resumed` and stays quiet. A global
|
|
147
235
|
// that throws is the same "not ready": `hand` restores `handed`, and nothing escapes into the handle (final review, Task 19).
|
|
236
|
+
// Nothing handed over means nothing held: the resumed signal comes at once (only the live shipping observer reads it).
|
|
237
|
+
let handedOver = false;
|
|
148
238
|
try {
|
|
149
|
-
hand("resumed", mine);
|
|
239
|
+
handedOver = hand("resumed", mine, () => target?.onResumed?.());
|
|
150
240
|
}
|
|
151
241
|
catch { /* nothing to resume yet */ }
|
|
242
|
+
if (!handedOver)
|
|
243
|
+
target?.onResumed?.();
|
|
152
244
|
},
|
|
153
245
|
onCannotComplete: (handler) => {
|
|
154
246
|
const kco = win._klarnaCheckout;
|
|
@@ -167,12 +259,37 @@ export const controllers = {
|
|
|
167
259
|
}
|
|
168
260
|
return () => { live = false; };
|
|
169
261
|
},
|
|
262
|
+
onShippingEvent: (handler) => {
|
|
263
|
+
const kco = win._klarnaCheckout;
|
|
264
|
+
if (!kco)
|
|
265
|
+
return undefined;
|
|
266
|
+
let live = true;
|
|
267
|
+
const fire = () => { if (live && !disposed)
|
|
268
|
+
handler(); };
|
|
269
|
+
// One separate api.on: replacement is per event, so can_not_complete_order stays bound.
|
|
270
|
+
try {
|
|
271
|
+
kco((api) => api.on?.({ shipping_option_change: fire, shipping_address_change: fire, order_total_change: fire }));
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
live = false;
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
return () => { live = false; };
|
|
278
|
+
},
|
|
170
279
|
dispose: () => { disposed = true; },
|
|
171
280
|
};
|
|
172
281
|
},
|
|
173
282
|
walley: (win) => ({
|
|
174
283
|
suspend: () => win.walley?.checkout?.api?.suspend(),
|
|
175
|
-
resume: () =>
|
|
284
|
+
resume: (target) => {
|
|
285
|
+
const api = win.walley?.checkout?.api;
|
|
286
|
+
if (!api) {
|
|
287
|
+
target?.onResumed?.();
|
|
288
|
+
return;
|
|
289
|
+
} // nothing held: still resumed for the observer
|
|
290
|
+
api.resume();
|
|
291
|
+
target?.onResumed?.();
|
|
292
|
+
},
|
|
176
293
|
}),
|
|
177
294
|
};
|
|
178
295
|
export function controllerFor(name, win, timer = (fn, ms) => { setTimeout(fn, ms); }) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CheckoutShippingViewDto, LiveShippingResult } from "./types";
|
|
2
|
+
/** Everything the observation flow needs from the outside. No DOM, no provider knowledge, no real clock. */
|
|
3
|
+
export interface LiveShippingDeps {
|
|
4
|
+
read: () => Promise<LiveShippingResult>;
|
|
5
|
+
deliver: (view: CheckoutShippingViewDto | null) => void;
|
|
6
|
+
now: () => number;
|
|
7
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
8
|
+
clearTimeout: (h: unknown) => void;
|
|
9
|
+
random: () => number;
|
|
10
|
+
visible: () => boolean;
|
|
11
|
+
debounceMs?: number;
|
|
12
|
+
followUpMs?: number;
|
|
13
|
+
netMinMs?: number;
|
|
14
|
+
netMaxMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface LiveShippingObserver {
|
|
17
|
+
/** A provider event, or an explicit first read. An event clears the view (`deliver(null)`) before the read. */
|
|
18
|
+
trigger(kind: "event" | "first" | "resume" | "net"): void;
|
|
19
|
+
/** Sync started: invalidate and hold reads until `resumed()`. */
|
|
20
|
+
suspended(): void;
|
|
21
|
+
/** The provider is actually resumed and the sync queue drained: refresh. A shipping event seen while suspended is replayed here. */
|
|
22
|
+
resumed(): void;
|
|
23
|
+
destroy(): void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The live shipping observation flow (spec 2026-10-12-checkout-live-shipping-design.md §4.6): one request in flight,
|
|
27
|
+
* one coalesced planned read, one not-before deadline for every timer, a sequence number that events, suspend and
|
|
28
|
+
* destroy bump so that an older answer is dropped.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createLiveShippingObserver(deps: LiveShippingDeps): LiveShippingObserver;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live shipping observation flow (spec 2026-10-12-checkout-live-shipping-design.md §4.6): one request in flight,
|
|
3
|
+
* one coalesced planned read, one not-before deadline for every timer, a sequence number that events, suspend and
|
|
4
|
+
* destroy bump so that an older answer is dropped.
|
|
5
|
+
*/
|
|
6
|
+
export function createLiveShippingObserver(deps) {
|
|
7
|
+
const debounceMs = deps.debounceMs ?? 300;
|
|
8
|
+
const followUpMs = deps.followUpMs ?? 500;
|
|
9
|
+
const netMinMs = deps.netMinMs ?? 45_000;
|
|
10
|
+
const netMaxMs = Math.max(netMinMs, deps.netMaxMs ?? 60_000);
|
|
11
|
+
let destroyed = false;
|
|
12
|
+
let isSuspended = false;
|
|
13
|
+
let seq = 0;
|
|
14
|
+
let notBefore = 0;
|
|
15
|
+
let inFlight = false;
|
|
16
|
+
let view = null;
|
|
17
|
+
let netStopped = false;
|
|
18
|
+
let planned = null;
|
|
19
|
+
let readTimer = null;
|
|
20
|
+
let netTimer = null;
|
|
21
|
+
const deliver = (v) => {
|
|
22
|
+
try {
|
|
23
|
+
deps.deliver(v);
|
|
24
|
+
}
|
|
25
|
+
catch { /* the host's display must not break the observation */ }
|
|
26
|
+
};
|
|
27
|
+
const clearRead = () => { if (readTimer !== null) {
|
|
28
|
+
deps.clearTimeout(readTimer);
|
|
29
|
+
readTimer = null;
|
|
30
|
+
} };
|
|
31
|
+
const clearNet = () => { if (netTimer !== null) {
|
|
32
|
+
deps.clearTimeout(netTimer);
|
|
33
|
+
netTimer = null;
|
|
34
|
+
} };
|
|
35
|
+
/** (Re)arm the single read timer for `planned`, never earlier than the not-before deadline. */
|
|
36
|
+
function arm() {
|
|
37
|
+
clearRead();
|
|
38
|
+
if (planned === null || destroyed || isSuspended || inFlight)
|
|
39
|
+
return; // a settle re-arms
|
|
40
|
+
const at = Math.max(planned.due, notBefore);
|
|
41
|
+
readTimer = deps.setTimeout(onReadTimer, Math.max(0, at - deps.now()));
|
|
42
|
+
}
|
|
43
|
+
function onReadTimer() {
|
|
44
|
+
readTimer = null;
|
|
45
|
+
if (destroyed || isSuspended || planned === null || inFlight)
|
|
46
|
+
return;
|
|
47
|
+
if (deps.now() < notBefore) {
|
|
48
|
+
arm();
|
|
49
|
+
return;
|
|
50
|
+
} // the deadline moved after this was armed
|
|
51
|
+
const { kind, followUp } = planned;
|
|
52
|
+
planned = null;
|
|
53
|
+
void start(kind, followUp);
|
|
54
|
+
}
|
|
55
|
+
function plan(kind, delayMs, followUp, debounce = false) {
|
|
56
|
+
if (destroyed || isSuspended)
|
|
57
|
+
return;
|
|
58
|
+
const due = deps.now() + delayMs;
|
|
59
|
+
if (planned === null)
|
|
60
|
+
planned = { due, kind, followUp };
|
|
61
|
+
else {
|
|
62
|
+
if (debounce)
|
|
63
|
+
planned.due = due; // an event restarts the debounce
|
|
64
|
+
if (kind !== "net" && kind !== "followUp")
|
|
65
|
+
planned.kind = planned.kind === "event" ? "event" : kind;
|
|
66
|
+
else if (planned.kind === "net")
|
|
67
|
+
planned.kind = kind;
|
|
68
|
+
planned.followUp = planned.followUp || followUp;
|
|
69
|
+
}
|
|
70
|
+
arm();
|
|
71
|
+
}
|
|
72
|
+
const retryDeadline = (seconds) => {
|
|
73
|
+
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0)
|
|
74
|
+
notBefore = Math.max(notBefore, deps.now() + seconds * 1000);
|
|
75
|
+
};
|
|
76
|
+
async function start(kind, followUp) {
|
|
77
|
+
inFlight = true;
|
|
78
|
+
const mine = seq;
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = await deps.read();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
result = { ok: false, status: 0, problem: { code: "NETWORK", status: 0 } };
|
|
85
|
+
}
|
|
86
|
+
inFlight = false;
|
|
87
|
+
if (destroyed)
|
|
88
|
+
return;
|
|
89
|
+
// Whatever the answer's age, a server-demanded delay holds every later read.
|
|
90
|
+
// Only a network failure (0), 408, 429 and 5xx are transient; any other 4xx (404, 403, 400) is a real answer that
|
|
91
|
+
// clears the view on every read kind, and the net keeps its own rules *(final review C)*.
|
|
92
|
+
let transient = !result.ok && isTransientStatus(result.status);
|
|
93
|
+
retryDeadline(result.retryAfter);
|
|
94
|
+
if (!result.ok && result.status === 429 && result.retryAfter === undefined)
|
|
95
|
+
notBefore = Math.max(notBefore, deps.now() + 1000);
|
|
96
|
+
if (result.ok) {
|
|
97
|
+
const d = result.data;
|
|
98
|
+
if (d.state === "unavailable" && (d.reason === "throttled" || d.reason === "provider")) {
|
|
99
|
+
transient = true;
|
|
100
|
+
retryDeadline(typeof d.retryAfterSeconds === "number" ? d.retryAfterSeconds : undefined);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const current = mine === seq && !isSuspended;
|
|
104
|
+
if (current) {
|
|
105
|
+
if (!transient && result.ok) {
|
|
106
|
+
view = result.data;
|
|
107
|
+
netStopped = view.state === "unavailable" && (view.reason === "closed" || view.reason === "not_supported");
|
|
108
|
+
deliver(view);
|
|
109
|
+
}
|
|
110
|
+
else if (transient && (kind === "net" || kind === "followUp") && view !== null) {
|
|
111
|
+
// a transient failure after a good view with no event in between: keep what is shown
|
|
112
|
+
}
|
|
113
|
+
else if (view !== null) {
|
|
114
|
+
view = null;
|
|
115
|
+
deliver(null);
|
|
116
|
+
}
|
|
117
|
+
if (followUp)
|
|
118
|
+
plan("followUp", followUpMs, false);
|
|
119
|
+
}
|
|
120
|
+
if (planned !== null)
|
|
121
|
+
arm();
|
|
122
|
+
armNet();
|
|
123
|
+
}
|
|
124
|
+
function armNet() {
|
|
125
|
+
clearNet();
|
|
126
|
+
if (destroyed || isSuspended || netStopped)
|
|
127
|
+
return;
|
|
128
|
+
const delay = netMinMs + deps.random() * (netMaxMs - netMinMs);
|
|
129
|
+
netTimer = deps.setTimeout(onNet, delay);
|
|
130
|
+
}
|
|
131
|
+
function onNet() {
|
|
132
|
+
netTimer = null;
|
|
133
|
+
if (destroyed || isSuspended || netStopped)
|
|
134
|
+
return;
|
|
135
|
+
if (!deps.visible() || inFlight || planned !== null) {
|
|
136
|
+
armNet();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
plan("net", 0, false);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
trigger(kind) {
|
|
143
|
+
if (destroyed)
|
|
144
|
+
return;
|
|
145
|
+
if (kind === "event") {
|
|
146
|
+
seq++;
|
|
147
|
+
view = null;
|
|
148
|
+
deliver(null);
|
|
149
|
+
// a shipping event seen while suspended is the pending trigger resumed() replays
|
|
150
|
+
if (isSuspended)
|
|
151
|
+
return;
|
|
152
|
+
plan("event", debounceMs, true, true);
|
|
153
|
+
}
|
|
154
|
+
else if (kind === "net") {
|
|
155
|
+
if (isSuspended || netStopped)
|
|
156
|
+
return;
|
|
157
|
+
plan("net", 0, false);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
if (isSuspended)
|
|
161
|
+
return; // replayed by resumed()
|
|
162
|
+
plan(kind, 0, kind === "resume");
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
suspended() {
|
|
166
|
+
if (destroyed)
|
|
167
|
+
return;
|
|
168
|
+
planned = null;
|
|
169
|
+
clearRead();
|
|
170
|
+
clearNet();
|
|
171
|
+
isSuspended = true;
|
|
172
|
+
seq++;
|
|
173
|
+
view = null;
|
|
174
|
+
deliver(null);
|
|
175
|
+
},
|
|
176
|
+
resumed() {
|
|
177
|
+
if (destroyed)
|
|
178
|
+
return;
|
|
179
|
+
isSuspended = false;
|
|
180
|
+
plan("resume", 0, true);
|
|
181
|
+
},
|
|
182
|
+
destroy() {
|
|
183
|
+
destroyed = true;
|
|
184
|
+
planned = null;
|
|
185
|
+
clearRead();
|
|
186
|
+
clearNet();
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** A failed read worth keeping the shown view for: no answer (0), a timeout (408), a throttle (429) or a server error (5xx). */
|
|
191
|
+
function isTransientStatus(status) {
|
|
192
|
+
return status === 0 || status === 408 || status === 429 || status >= 500;
|
|
193
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Client } from "openapi-fetch";
|
|
2
2
|
import type { StorefrontPaths } from "../index.js";
|
|
3
|
-
import { type CheckoutResult, type OrderResult, type StatusResult } from "./types.js";
|
|
3
|
+
import { type CheckoutResult, type LiveShippingResult, type OrderResult, type StatusResult } from "./types.js";
|
|
4
4
|
export type CheckoutClient = Client<StorefrontPaths>;
|
|
5
5
|
export interface StartCheckoutOptions {
|
|
6
6
|
/** One of `GET /v1/site` → `markets[].checkout.options[].key`; omit for the market's default. */
|
|
@@ -25,6 +25,9 @@ export declare function syncCheckout(client: CheckoutClient, cartToken: string,
|
|
|
25
25
|
export declare function completeCheckout(client: CheckoutClient, attemptId: string, cartToken?: string): Promise<StatusResult>;
|
|
26
26
|
/** `GET /v1/checkout/{attemptId}`: the non-mutating status read the browser module polls with. */
|
|
27
27
|
export declare function getCheckoutStatus(client: CheckoutClient, attemptId: string, cartToken: string): Promise<StatusResult>;
|
|
28
|
+
/** `GET /v1/checkout/{attemptId}/shipping` with the cart token: the live shipping view behind `mountCheckout`'s `liveShipping`.
|
|
29
|
+
* Forward `retryAfter` (the `Retry-After` header) from a proxy; a 200 `unavailable` view may carry it too. */
|
|
30
|
+
export declare function liveShippingFor(client: CheckoutClient, cartToken: string, attemptId: string): Promise<LiveShippingResult>;
|
|
28
31
|
/** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3). */
|
|
29
32
|
export declare function getOrder(client: CheckoutClient, orderId: string, token: string): Promise<OrderResult>;
|
|
30
33
|
export interface AwaitCompletionOptions {
|
package/dist/checkout/server.js
CHANGED
|
@@ -41,6 +41,15 @@ export async function getCheckoutStatus(client, attemptId, cartToken) {
|
|
|
41
41
|
return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter: retryAfterSeconds(response) };
|
|
42
42
|
return { ok: true, data };
|
|
43
43
|
}
|
|
44
|
+
/** `GET /v1/checkout/{attemptId}/shipping` with the cart token: the live shipping view behind `mountCheckout`'s `liveShipping`.
|
|
45
|
+
* Forward `retryAfter` (the `Retry-After` header) from a proxy; a 200 `unavailable` view may carry it too. */
|
|
46
|
+
export async function liveShippingFor(client, cartToken, attemptId) {
|
|
47
|
+
const { data, error, response } = await client.GET("/v1/checkout/{attemptId}/shipping", { params: { path: { attemptId } }, headers: { "X-Cart-Token": cartToken } });
|
|
48
|
+
const retryAfter = retryAfterSeconds(response);
|
|
49
|
+
if (error || !data)
|
|
50
|
+
return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter };
|
|
51
|
+
return { ok: true, data, retryAfter };
|
|
52
|
+
}
|
|
44
53
|
/** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3). */
|
|
45
54
|
export async function getOrder(client, orderId, token) {
|
|
46
55
|
const { data, error, response } = await client.GET("/v1/orders/{orderId}", { params: { path: { orderId } }, headers: { "X-Order-Token": token } });
|
package/dist/checkout/types.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type CheckoutDto = components["schemas"]["CheckoutDto"];
|
|
|
3
3
|
export type CheckoutStatusDto = components["schemas"]["CheckoutStatusDto"];
|
|
4
4
|
export type CheckoutOrderRefDto = components["schemas"]["CheckoutOrderRefDto"];
|
|
5
5
|
export type CheckoutWidgetDto = components["schemas"]["CheckoutWidgetDto"];
|
|
6
|
+
export type CheckoutShippingViewDto = components["schemas"]["CheckoutShippingViewDto"];
|
|
6
7
|
export type OrderDto = components["schemas"]["OrderDto"];
|
|
7
8
|
export type StartCheckoutRequest = components["schemas"]["StartCheckoutRequest"];
|
|
8
9
|
/** The problem body; `cart` is the corrected cart on 409 CHECKOUT_CART_CHANGED (an extension, so it is typed loosely here). */
|
|
@@ -28,6 +29,17 @@ export type StatusResult = {
|
|
|
28
29
|
problem: StorefrontProblem;
|
|
29
30
|
retryAfter?: number;
|
|
30
31
|
};
|
|
32
|
+
/** `GET /v1/checkout/{attemptId}/shipping`: the live shipping view, the HTTP status on failure and the `Retry-After` delay in seconds. */
|
|
33
|
+
export type LiveShippingResult = {
|
|
34
|
+
ok: true;
|
|
35
|
+
data: CheckoutShippingViewDto;
|
|
36
|
+
retryAfter?: number;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
status: number;
|
|
40
|
+
problem: StorefrontProblem;
|
|
41
|
+
retryAfter?: number;
|
|
42
|
+
};
|
|
31
43
|
export type OrderResult = {
|
|
32
44
|
ok: true;
|
|
33
45
|
data: OrderDto;
|
package/dist/index.d.ts
CHANGED
|
@@ -47,8 +47,8 @@ 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";
|
|
50
|
+
export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, liveShippingFor, startCheckout, syncCheckout, type CheckoutClient, type StartCheckoutOptions, type SyncRetryOptions, type AwaitCompletionOptions } from "./checkout/server.js";
|
|
51
|
+
export { retryAfterSeconds, type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type CheckoutShippingViewDto, type LiveShippingResult, type CheckoutStatusDto, type CheckoutWidgetDto, type OrderDto, type OrderResult, type StatusResult, type StorefrontProblem } from "./checkout/types.js";
|
|
52
52
|
export { mountCheckout, type CheckoutHandle, type MountOptions, type MinimalDocument, type MinimalElement, type MinimalScript } from "./checkout/browser.js";
|
|
53
53
|
export { controllers, type CheckoutController, type ProviderWindow } from "./checkout/controllers.js";
|
|
54
54
|
export { breadcrumbJsonLd } from "./seo/breadcrumb.js";
|
package/dist/index.js
CHANGED
|
@@ -36,7 +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)*
|
|
39
|
+
export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, liveShippingFor, startCheckout, syncCheckout } from "./checkout/server.js"; // SyncRetryOptions *(plan review 3 #5)*
|
|
40
40
|
export { retryAfterSeconds } from "./checkout/types.js";
|
|
41
41
|
export { mountCheckout } from "./checkout/browser.js";
|
|
42
42
|
export { controllers } from "./checkout/controllers.js";
|
package/dist/schema.d.ts
CHANGED
|
@@ -183,6 +183,22 @@ export interface paths {
|
|
|
183
183
|
patch?: never;
|
|
184
184
|
trace?: never;
|
|
185
185
|
};
|
|
186
|
+
"/v1/checkout/{attemptId}/shipping": {
|
|
187
|
+
parameters: {
|
|
188
|
+
query?: never;
|
|
189
|
+
header?: never;
|
|
190
|
+
path?: never;
|
|
191
|
+
cookie?: never;
|
|
192
|
+
};
|
|
193
|
+
get: operations["GetCheckoutShipping"];
|
|
194
|
+
put?: never;
|
|
195
|
+
post?: never;
|
|
196
|
+
delete?: never;
|
|
197
|
+
options?: never;
|
|
198
|
+
head?: never;
|
|
199
|
+
patch?: never;
|
|
200
|
+
trace?: never;
|
|
201
|
+
};
|
|
186
202
|
"/v1/collections": {
|
|
187
203
|
parameters: {
|
|
188
204
|
query?: never;
|
|
@@ -593,6 +609,32 @@ export interface components {
|
|
|
593
609
|
kind: string;
|
|
594
610
|
url: null | string;
|
|
595
611
|
};
|
|
612
|
+
CheckoutShippingPointDto: {
|
|
613
|
+
city: null | string;
|
|
614
|
+
name: string;
|
|
615
|
+
postalCode: null | string;
|
|
616
|
+
street: null | string;
|
|
617
|
+
};
|
|
618
|
+
CheckoutShippingViewDto: {
|
|
619
|
+
/** Format: int32 */
|
|
620
|
+
cartVersion: number | null | string;
|
|
621
|
+
currency: null | string;
|
|
622
|
+
deliveryType: null | string;
|
|
623
|
+
goodsInc: null | string;
|
|
624
|
+
name: null | string;
|
|
625
|
+
option: null | string;
|
|
626
|
+
otherInc: null | string;
|
|
627
|
+
point: null | components["schemas"]["CheckoutShippingPointDto"];
|
|
628
|
+
/** Format: date-time */
|
|
629
|
+
readAt: null | string;
|
|
630
|
+
reason: null | string;
|
|
631
|
+
/** Format: int32 */
|
|
632
|
+
retryAfterSeconds: number | null | string;
|
|
633
|
+
shippingInc: null | string;
|
|
634
|
+
state: string;
|
|
635
|
+
totalInc: null | string;
|
|
636
|
+
totalSource: null | string;
|
|
637
|
+
};
|
|
596
638
|
CheckoutStatusDto: {
|
|
597
639
|
attemptId: string;
|
|
598
640
|
order: null | components["schemas"]["CheckoutOrderRefDto"];
|
|
@@ -2401,6 +2443,91 @@ export interface operations {
|
|
|
2401
2443
|
};
|
|
2402
2444
|
};
|
|
2403
2445
|
};
|
|
2446
|
+
GetCheckoutShipping: {
|
|
2447
|
+
parameters: {
|
|
2448
|
+
query?: never;
|
|
2449
|
+
header: {
|
|
2450
|
+
/** @description Publishable, preview or secret site key. Required on every request; secret keys are server-side only. */
|
|
2451
|
+
"X-Site-Key": string;
|
|
2452
|
+
/** @description Market code (e.g. "se"). Omit for the site's default market. */
|
|
2453
|
+
"X-Market"?: string;
|
|
2454
|
+
/** @description BCP-47 language tag. Omit to use the market's language. */
|
|
2455
|
+
"Accept-Language"?: string;
|
|
2456
|
+
/** @description The cart's bearer token. The checkout works on this cart. */
|
|
2457
|
+
"X-Cart-Token": string;
|
|
2458
|
+
};
|
|
2459
|
+
path: {
|
|
2460
|
+
attemptId: string;
|
|
2461
|
+
};
|
|
2462
|
+
cookie?: never;
|
|
2463
|
+
};
|
|
2464
|
+
requestBody?: never;
|
|
2465
|
+
responses: {
|
|
2466
|
+
/** @description OK */
|
|
2467
|
+
200: {
|
|
2468
|
+
headers: {
|
|
2469
|
+
[name: string]: unknown;
|
|
2470
|
+
};
|
|
2471
|
+
content: {
|
|
2472
|
+
"application/json": components["schemas"]["CheckoutShippingViewDto"];
|
|
2473
|
+
};
|
|
2474
|
+
};
|
|
2475
|
+
/** @description problem+json. NOT_FOUND (X-Cart-Token missing). */
|
|
2476
|
+
400: {
|
|
2477
|
+
headers: {
|
|
2478
|
+
[name: string]: unknown;
|
|
2479
|
+
};
|
|
2480
|
+
content: {
|
|
2481
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2482
|
+
};
|
|
2483
|
+
};
|
|
2484
|
+
/** @description problem+json. SITE_KEY_MISSING or SITE_KEY_INVALID. */
|
|
2485
|
+
401: {
|
|
2486
|
+
headers: {
|
|
2487
|
+
[name: string]: unknown;
|
|
2488
|
+
};
|
|
2489
|
+
content: {
|
|
2490
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2491
|
+
};
|
|
2492
|
+
};
|
|
2493
|
+
/** @description problem+json. SECRET_KEY_FROM_BROWSER: a secret key was sent from a browser. */
|
|
2494
|
+
403: {
|
|
2495
|
+
headers: {
|
|
2496
|
+
[name: string]: unknown;
|
|
2497
|
+
};
|
|
2498
|
+
content: {
|
|
2499
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2500
|
+
};
|
|
2501
|
+
};
|
|
2502
|
+
/** @description problem+json. CHECKOUT_NOT_FOUND: no such attempt for this site and cart; or SITE_INACTIVE. */
|
|
2503
|
+
404: {
|
|
2504
|
+
headers: {
|
|
2505
|
+
[name: string]: unknown;
|
|
2506
|
+
};
|
|
2507
|
+
content: {
|
|
2508
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2509
|
+
};
|
|
2510
|
+
};
|
|
2511
|
+
/** @description problem+json. RATE_LIMITED; retryAfterSeconds and the Retry-After header say how long to wait. */
|
|
2512
|
+
429: {
|
|
2513
|
+
headers: {
|
|
2514
|
+
[name: string]: unknown;
|
|
2515
|
+
};
|
|
2516
|
+
content: {
|
|
2517
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2518
|
+
};
|
|
2519
|
+
};
|
|
2520
|
+
/** @description problem+json. SITE_REGISTRY_WARMING or INDEX_UNAVAILABLE — retry, the request was not wrong. */
|
|
2521
|
+
503: {
|
|
2522
|
+
headers: {
|
|
2523
|
+
[name: string]: unknown;
|
|
2524
|
+
};
|
|
2525
|
+
content: {
|
|
2526
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2527
|
+
};
|
|
2528
|
+
};
|
|
2529
|
+
};
|
|
2530
|
+
};
|
|
2404
2531
|
GetCollections: {
|
|
2405
2532
|
parameters: {
|
|
2406
2533
|
query?: never;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asstio-dev/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"test": "vitest run",
|
|
22
22
|
"test:browser": "playwright test -c browser/playwright.config.ts",
|
|
23
23
|
"check": "npm run generate && npm run typecheck-examples && npm test && git diff --exit-code -- src/schema.d.ts && test -z \"$(git status --porcelain -- src/schema.d.ts)\"",
|
|
24
|
-
"smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"AnalyticsError\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
|
|
24
|
+
"smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"AnalyticsError\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"openapi-fetch": "0.17.0"
|
package/src/schema.d.ts
CHANGED
|
@@ -183,6 +183,22 @@ export interface paths {
|
|
|
183
183
|
patch?: never;
|
|
184
184
|
trace?: never;
|
|
185
185
|
};
|
|
186
|
+
"/v1/checkout/{attemptId}/shipping": {
|
|
187
|
+
parameters: {
|
|
188
|
+
query?: never;
|
|
189
|
+
header?: never;
|
|
190
|
+
path?: never;
|
|
191
|
+
cookie?: never;
|
|
192
|
+
};
|
|
193
|
+
get: operations["GetCheckoutShipping"];
|
|
194
|
+
put?: never;
|
|
195
|
+
post?: never;
|
|
196
|
+
delete?: never;
|
|
197
|
+
options?: never;
|
|
198
|
+
head?: never;
|
|
199
|
+
patch?: never;
|
|
200
|
+
trace?: never;
|
|
201
|
+
};
|
|
186
202
|
"/v1/collections": {
|
|
187
203
|
parameters: {
|
|
188
204
|
query?: never;
|
|
@@ -593,6 +609,32 @@ export interface components {
|
|
|
593
609
|
kind: string;
|
|
594
610
|
url: null | string;
|
|
595
611
|
};
|
|
612
|
+
CheckoutShippingPointDto: {
|
|
613
|
+
city: null | string;
|
|
614
|
+
name: string;
|
|
615
|
+
postalCode: null | string;
|
|
616
|
+
street: null | string;
|
|
617
|
+
};
|
|
618
|
+
CheckoutShippingViewDto: {
|
|
619
|
+
/** Format: int32 */
|
|
620
|
+
cartVersion: number | null | string;
|
|
621
|
+
currency: null | string;
|
|
622
|
+
deliveryType: null | string;
|
|
623
|
+
goodsInc: null | string;
|
|
624
|
+
name: null | string;
|
|
625
|
+
option: null | string;
|
|
626
|
+
otherInc: null | string;
|
|
627
|
+
point: null | components["schemas"]["CheckoutShippingPointDto"];
|
|
628
|
+
/** Format: date-time */
|
|
629
|
+
readAt: null | string;
|
|
630
|
+
reason: null | string;
|
|
631
|
+
/** Format: int32 */
|
|
632
|
+
retryAfterSeconds: number | null | string;
|
|
633
|
+
shippingInc: null | string;
|
|
634
|
+
state: string;
|
|
635
|
+
totalInc: null | string;
|
|
636
|
+
totalSource: null | string;
|
|
637
|
+
};
|
|
596
638
|
CheckoutStatusDto: {
|
|
597
639
|
attemptId: string;
|
|
598
640
|
order: null | components["schemas"]["CheckoutOrderRefDto"];
|
|
@@ -2401,6 +2443,91 @@ export interface operations {
|
|
|
2401
2443
|
};
|
|
2402
2444
|
};
|
|
2403
2445
|
};
|
|
2446
|
+
GetCheckoutShipping: {
|
|
2447
|
+
parameters: {
|
|
2448
|
+
query?: never;
|
|
2449
|
+
header: {
|
|
2450
|
+
/** @description Publishable, preview or secret site key. Required on every request; secret keys are server-side only. */
|
|
2451
|
+
"X-Site-Key": string;
|
|
2452
|
+
/** @description Market code (e.g. "se"). Omit for the site's default market. */
|
|
2453
|
+
"X-Market"?: string;
|
|
2454
|
+
/** @description BCP-47 language tag. Omit to use the market's language. */
|
|
2455
|
+
"Accept-Language"?: string;
|
|
2456
|
+
/** @description The cart's bearer token. The checkout works on this cart. */
|
|
2457
|
+
"X-Cart-Token": string;
|
|
2458
|
+
};
|
|
2459
|
+
path: {
|
|
2460
|
+
attemptId: string;
|
|
2461
|
+
};
|
|
2462
|
+
cookie?: never;
|
|
2463
|
+
};
|
|
2464
|
+
requestBody?: never;
|
|
2465
|
+
responses: {
|
|
2466
|
+
/** @description OK */
|
|
2467
|
+
200: {
|
|
2468
|
+
headers: {
|
|
2469
|
+
[name: string]: unknown;
|
|
2470
|
+
};
|
|
2471
|
+
content: {
|
|
2472
|
+
"application/json": components["schemas"]["CheckoutShippingViewDto"];
|
|
2473
|
+
};
|
|
2474
|
+
};
|
|
2475
|
+
/** @description problem+json. NOT_FOUND (X-Cart-Token missing). */
|
|
2476
|
+
400: {
|
|
2477
|
+
headers: {
|
|
2478
|
+
[name: string]: unknown;
|
|
2479
|
+
};
|
|
2480
|
+
content: {
|
|
2481
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2482
|
+
};
|
|
2483
|
+
};
|
|
2484
|
+
/** @description problem+json. SITE_KEY_MISSING or SITE_KEY_INVALID. */
|
|
2485
|
+
401: {
|
|
2486
|
+
headers: {
|
|
2487
|
+
[name: string]: unknown;
|
|
2488
|
+
};
|
|
2489
|
+
content: {
|
|
2490
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2491
|
+
};
|
|
2492
|
+
};
|
|
2493
|
+
/** @description problem+json. SECRET_KEY_FROM_BROWSER: a secret key was sent from a browser. */
|
|
2494
|
+
403: {
|
|
2495
|
+
headers: {
|
|
2496
|
+
[name: string]: unknown;
|
|
2497
|
+
};
|
|
2498
|
+
content: {
|
|
2499
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2500
|
+
};
|
|
2501
|
+
};
|
|
2502
|
+
/** @description problem+json. CHECKOUT_NOT_FOUND: no such attempt for this site and cart; or SITE_INACTIVE. */
|
|
2503
|
+
404: {
|
|
2504
|
+
headers: {
|
|
2505
|
+
[name: string]: unknown;
|
|
2506
|
+
};
|
|
2507
|
+
content: {
|
|
2508
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2509
|
+
};
|
|
2510
|
+
};
|
|
2511
|
+
/** @description problem+json. RATE_LIMITED; retryAfterSeconds and the Retry-After header say how long to wait. */
|
|
2512
|
+
429: {
|
|
2513
|
+
headers: {
|
|
2514
|
+
[name: string]: unknown;
|
|
2515
|
+
};
|
|
2516
|
+
content: {
|
|
2517
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2518
|
+
};
|
|
2519
|
+
};
|
|
2520
|
+
/** @description problem+json. SITE_REGISTRY_WARMING or INDEX_UNAVAILABLE — retry, the request was not wrong. */
|
|
2521
|
+
503: {
|
|
2522
|
+
headers: {
|
|
2523
|
+
[name: string]: unknown;
|
|
2524
|
+
};
|
|
2525
|
+
content: {
|
|
2526
|
+
"application/problem+json": components["schemas"]["StorefrontProblem"];
|
|
2527
|
+
};
|
|
2528
|
+
};
|
|
2529
|
+
};
|
|
2530
|
+
};
|
|
2404
2531
|
GetCollections: {
|
|
2405
2532
|
parameters: {
|
|
2406
2533
|
query?: never;
|