@asstio-dev/storefront 0.17.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 CHANGED
@@ -251,10 +251,13 @@ Cards and cart lines carry the product facts the product page has: `brand`, `cat
251
251
  shape without `shortDescription` (`productFacts(orderLine).shortDescription` is `null`): its facts are the ones the
252
252
  charged checkout snapshot captured, never re-resolved — `url`, `facet` and attribute `facetKey`/`valueKeys` are `null`
253
253
  there, the blocks are empty on orders placed before they existed, and `null` on fee, shipping and adjustment lines.
254
- `productFacts()` reads them from any source:
254
+ A checkout cart line (`checkout.cart.lines[]`) has the order-line shape too, but is resolved **live while the attempt is
255
+ `open`**, like a cart line, with `url`, `facet` and attribute keys filled in. In every other status, and for a line the
256
+ catalogue can no longer describe, it carries the snapshot's frozen facts (links `null`). What it shows may therefore
257
+ differ from what the order later freezes. `productFacts()` reads them from any source:
255
258
 
256
259
  ```ts
257
- const facts = productFacts(line); // card, product, { product, variant }, cart line or product order line
260
+ const facts = productFacts(line); // card, product, { product, variant }, cart line, checkout cart line or product order line
258
261
  render(facts.brand?.label); // the item's brand: the variant's own, never a fallback to the family's
259
262
  render(facts.productBrand?.label); // the family brand
260
263
  ```
@@ -263,7 +266,8 @@ render(facts.productBrand?.label); // the family brand
263
266
  fall back to `productBrand`. **Categories carry no primary meaning**: `categories[0]` may be a gender, a brand or a
264
267
  product-type node. Pick the one you want by its root.
265
268
 
266
- `toGa4Item()` and `toGa4Ecommerce()` map the same sources to GA4 ecommerce data. They are pure: they send nothing and
269
+ `toGa4Item()` and `toGa4Ecommerce()` map the same sources to GA4 ecommerce data; `toGa4Ecommerce(checkout.cart)` is the
270
+ `begin_checkout` payload. They are pure: they send nothing and
267
271
  know nothing about consent or a data layer.
268
272
 
269
273
  | Source | `item_id` | `item_brand` | `price` | `quantity` | `item_variant` |
@@ -271,6 +275,7 @@ know nothing about consent or a data layer.
271
275
  | card (impression) | the family `sku` | `brand.label` | from-price | 1 | — |
272
276
  | `{ product, variant }` | the variant `sku` | the variant's brand | the variant's price | 1 | selected options |
273
277
  | cart line | the line `sku` | the variant's brand | the line's unit price | the line's | selected options |
278
+ | checkout cart line | the line `sku` | the variant's brand | the unit price | the line's | options |
274
279
  | order line (product) | the line `sku` | the variant's brand | the unit price | the line's | options |
275
280
 
276
281
  - A card is a family impression; later events carry the purchased sku. Pass `itemId` to report at family level.
@@ -431,6 +436,8 @@ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `c
431
436
  Qliro shows the confirmed total or version.
432
437
  - When our validation declines a purchase (the cart moved on, the attempt expired, a line is out of stock), Qliro shows
433
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.
434
441
 
435
442
  #### Kustom (`klarna-kco`)
436
443
 
@@ -452,6 +459,8 @@ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `c
452
459
  - Never bind `can_not_complete_order` yourself through `window._klarnaCheckout(api => api.on(...))`. Kustom's `api.on`
453
460
  replaces the listener per event instead of adding one, so yours and the SDK's would replace each other. Use
454
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.
455
464
  - **CSP hosts** (K-C10, `docs/ops/kustom-playground-checks.md`, 2026-10-09: a card with 3-D Secure and the default
456
465
  method, no blocked request). The checkout page itself loads only Kustom's script host. Everything else
457
466
  (`kco.assets.kustomcdn.co`, Klarna, Stripe, hCaptcha, Google Pay, Amazon Pay) loads inside Kustom's iframe under
@@ -500,6 +509,40 @@ if (estimate?.available) {
500
509
  normally held in Asstio with its reason (rarely the export waits for an operator instead); the page has nothing to
501
510
  do for it.
502
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
+
503
546
  ## Headers
504
547
 
505
548
  Every call the client makes carries these; you never set them by hand.
@@ -1,6 +1,6 @@
1
- import { type CartLineDto, type OrderLineDto, type ProductCardDto, type ProductDto, type ProductFacts, type SelectedVariant } from "./types.js";
1
+ import { type CartLineDto, type CheckoutCartLineDto, type OrderLineDto, type ProductCardDto, type ProductDto, type ProductFacts, type SelectedVariant } from "./types.js";
2
2
  /**
3
- * The product facts of a card, a product page (with or without a selected variant), a cart line or an order line, so a
3
+ * The product facts of a card, a product page (with or without a selected variant), a cart line, a checkout cart line or an order line, so a
4
4
  * frontend never needs to know where a surface keeps them. Returns the DTO's own arrays and objects — never copies,
5
5
  * filters or mutates them. `brand` is the item's brand: a selected or purchased variant's own, with no fallback to the
6
6
  * family brand (null can mean an explicit "none"). An order line that is not a product line throws.
@@ -9,4 +9,5 @@ export declare function productFacts(source: ProductCardDto): ProductFacts;
9
9
  export declare function productFacts(source: ProductDto): ProductFacts;
10
10
  export declare function productFacts(source: SelectedVariant): ProductFacts;
11
11
  export declare function productFacts(source: CartLineDto): ProductFacts;
12
+ export declare function productFacts(source: CheckoutCartLineDto): ProductFacts;
12
13
  export declare function productFacts(source: OrderLineDto): ProductFacts;
@@ -12,11 +12,13 @@ export function productFacts(source) {
12
12
  };
13
13
  }
14
14
  if (isObject(s.product) && isObject(s.variant)) {
15
- // A cart line or a selected-variant wrapper: the same fact paths.
15
+ // A cart line, a checkout cart line or a selected-variant wrapper: the same fact paths. A checkout line's product has no
16
+ // `shortDescription` (it is the order-line shape), so it reads null there.
16
17
  const { product, variant } = source;
18
+ const short = product.shortDescription;
17
19
  return {
18
20
  brand: variant.brand, productBrand: product.brand, categories: product.categories,
19
- attributes: product.attributes, shortDescription: product.shortDescription ?? null,
21
+ attributes: product.attributes, shortDescription: short ?? null,
20
22
  };
21
23
  }
22
24
  const flat = source;
@@ -1,4 +1,4 @@
1
- import { type CartDto, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type OrderDto } from "./types.js";
1
+ import { type CartDto, type CheckoutCartDto, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type OrderDto } from "./types.js";
2
2
  /** An item and its unit price in cents, so a payload's `value` sums exact cents rather than re-reading floats. */
3
3
  export type MappedItem = {
4
4
  item: Ga4Item;
@@ -6,7 +6,7 @@ export type MappedItem = {
6
6
  };
7
7
  /**
8
8
  * One GA4 item from a card (an impression: the family sku and from-price), a product with its selected variant, a cart
9
- * line or a product order line. `item_brand` and the categories come from `productFacts`, so they never disagree with it.
9
+ * line, a checkout cart line or a product order line. `item_brand` and the categories come from `productFacts`, so they never disagree with it.
10
10
  * A source without a price throws — never `price: 0`.
11
11
  */
12
12
  export declare function toGa4Item(source: Ga4ItemSource, opts?: Ga4ItemOptions): Ga4Item;
@@ -18,4 +18,5 @@ export declare function mapItem(source: Ga4ItemSource, opts?: Ga4ItemOptions): M
18
18
  * recorded VAT and its `shipping` comes from its shipping lines, each netted at its own rate and rounded once.
19
19
  */
20
20
  export declare function toGa4Ecommerce(source: CartDto, opts?: Ga4SharedOptions): Ga4Ecommerce;
21
+ export declare function toGa4Ecommerce(source: CheckoutCartDto, opts?: Ga4SharedOptions): Ga4Ecommerce;
21
22
  export declare function toGa4Ecommerce(source: OrderDto, opts?: Ga4SharedOptions): Ga4Purchase;
@@ -3,7 +3,7 @@ import { productFacts } from "./facts.js";
3
3
  import { AnalyticsError, } from "./types.js";
4
4
  /**
5
5
  * One GA4 item from a card (an impression: the family sku and from-price), a product with its selected variant, a cart
6
- * line or a product order line. `item_brand` and the categories come from `productFacts`, so they never disagree with it.
6
+ * line, a checkout cart line or a product order line. `item_brand` and the categories come from `productFacts`, so they never disagree with it.
7
7
  * A source without a price throws — never `price: 0`.
8
8
  */
9
9
  export function toGa4Item(source, opts = {}) {
@@ -65,6 +65,11 @@ function lineOf(source) {
65
65
  throw new AnalyticsError(`an order line of type "${l.type}" is not an item`);
66
66
  return { id: l.sku, name: l.name, gross: parseDecimal(l.unitPriceInc), vatRate: parseDecimal(l.vatRate), quantity: sourceQuantity(l.quantity), options: l.options };
67
67
  }
68
+ if (typeof s.unitPriceInc === "string" && typeof s.productId === "string" && typeof s.type !== "string") {
69
+ // A checkout cart line: the snapshot's own amounts (`unitPriceInc`, `vatRate`), not a PriceDto, and the options on the line.
70
+ const l = source;
71
+ return { id: l.sku, name: l.name, gross: parseDecimal(l.unitPriceInc), vatRate: parseDecimal(l.vatRate), quantity: sourceQuantity(l.quantity), options: l.options };
72
+ }
68
73
  if (typeof s.variant === "object" && s.variant !== null && "selectedOptions" in s.variant) {
69
74
  if ("quantity" in s) {
70
75
  const l = source;
@@ -7,6 +7,8 @@ export type CartDto = Schemas["CartDto"];
7
7
  export type CartLineDto = Schemas["CartLineDto"];
8
8
  export type OrderDto = Schemas["OrderDto"];
9
9
  export type OrderLineDto = Schemas["OrderLineDto"];
10
+ export type CheckoutCartDto = Schemas["CheckoutCartDto"];
11
+ export type CheckoutCartLineDto = Schemas["CheckoutCartLineDto"];
10
12
  export type BrandDto = Schemas["BrandDto"];
11
13
  export type CategoryRefDto = Schemas["CategoryRefDto"];
12
14
  export type AttributeDto = Schemas["AttributeDto"];
@@ -26,7 +28,7 @@ export type ProductFacts = {
26
28
  categories: CategoryRefDto[];
27
29
  /** As the source carries them: a `ProductDto` may include `html` attributes; cards, cart and order lines do not. */
28
30
  attributes: AttributeDto[];
29
- /** Null on order lines. */
31
+ /** Null on order lines and checkout cart lines. */
30
32
  shortDescription: string | null;
31
33
  };
32
34
  /** A source the analytics helpers cannot map: a non-product order line, an unpriced variant, an invalid quantity. */
@@ -70,7 +72,7 @@ export type Ga4ItemOptions = Ga4SharedOptions & {
70
72
  itemId?: string;
71
73
  index?: number;
72
74
  };
73
- export type Ga4ItemSource = ProductCardDto | SelectedVariant | CartLineDto | OrderLineDto;
75
+ export type Ga4ItemSource = ProductCardDto | SelectedVariant | CartLineDto | CheckoutCartLineDto | OrderLineDto;
74
76
  /** `view_cart`, `begin_checkout` and the like. */
75
77
  export type Ga4Ecommerce = {
76
78
  currency: string;
@@ -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;
@@ -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: on a shipping
73
- * market Qliro's total includes the fee, so the total alone never matches). The echo is a string in Qliro's schema; its
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>;