@asstio-dev/storefront 0.18.0 → 0.20.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 +60 -0
- package/dist/analytics/events.d.ts +20 -0
- package/dist/analytics/events.js +23 -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 +3 -2
- package/dist/index.js +2 -1
- package/dist/schema.d.ts +292 -2
- package/package.json +2 -2
- package/src/schema.d.ts +292 -2
|
@@ -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";
|
|
@@ -58,5 +58,6 @@ export { productJsonLd } from "./seo/product.js";
|
|
|
58
58
|
export { jsonLdScript } from "./seo/serialize.js";
|
|
59
59
|
export { productFacts } from "./analytics/facts.js";
|
|
60
60
|
export { toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
|
|
61
|
+
export { trackSearchEvents, type SearchEvent, type SearchEventsResponse } from "./analytics/events.js";
|
|
61
62
|
export { AnalyticsError, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type ProductFacts, type SelectedVariant, } from "./analytics/types.js";
|
|
62
63
|
export { SeoError, type BreadcrumbLink, type CollectionHeadOptions, type HeadOptions, type JsonLd, type NextMetadataLike, type ProductJsonLdOptions, type SchemaProperty, type SeoContext, type SeoHead, type SeoMeta, type VariantAvailability, } from "./seo/types.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";
|
|
@@ -47,5 +47,6 @@ export { productJsonLd } from "./seo/product.js";
|
|
|
47
47
|
export { jsonLdScript } from "./seo/serialize.js";
|
|
48
48
|
export { productFacts } from "./analytics/facts.js";
|
|
49
49
|
export { toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
|
|
50
|
+
export { trackSearchEvents } from "./analytics/events.js";
|
|
50
51
|
export { AnalyticsError, } from "./analytics/types.js";
|
|
51
52
|
export { SeoError, } from "./seo/types.js";
|