@asstio-dev/storefront 0.12.0 → 0.13.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 +21 -0
- package/dist/checkout/browser.js +39 -20
- package/dist/checkout/controllers.d.ts +22 -5
- package/dist/checkout/controllers.js +99 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -380,6 +380,27 @@ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `c
|
|
|
380
380
|
- When our validation declines a purchase (the cart moved on, the attempt expired, a line is out of stock), Qliro shows
|
|
381
381
|
its out-of-stock message and fires `q1.onPaymentDeclined`; the SDK then syncs by itself and remounts or resumes.
|
|
382
382
|
|
|
383
|
+
#### Kustom (`klarna-kco`)
|
|
384
|
+
|
|
385
|
+
- The widget is Kustom's HTML snippet (Kustom Checkout v3); `mountCheckout` re-creates its scripts so they run.
|
|
386
|
+
- After every cart change, `handle.sync(update)` suspends the checkout with `suspend({ autoResume: { enabled: false } })`,
|
|
387
|
+
so Kustom never reopens payment by itself during a slow sync or while the SDK retries a `locked` answer. It resumes only
|
|
388
|
+
on the confirmed `open` answer for the same attempt; Kustom re-reads its order on `resume()`. A change made before the
|
|
389
|
+
snippet's script has defined `window._klarnaCheckout` is handed over as soon as it has.
|
|
390
|
+
- When our validation declines a purchase, Kustom sends the **whole page** to your checkout page with `declined=validation`
|
|
391
|
+
added to its query (the server answers Kustom with a 303 to the attempt's checkout page). That page is a fresh load:
|
|
392
|
+
show a short message for `declined=validation`, sync the real cart, and handle `completed`/`pending` before mounting a
|
|
393
|
+
widget, as on any page load. There is no `onDeclined` for Kustom.
|
|
394
|
+
- `can_not_complete_order` (no offered payment method can complete the order) reaches `onProblem` with the code
|
|
395
|
+
`SDK_PROVIDER_CANNOT_COMPLETE`; the copy is yours.
|
|
396
|
+
- The re-entry refresh (a stored snippet older than ten minutes is re-fetched) and the acknowledgement to Kustom are the
|
|
397
|
+
server's; the page does nothing for them.
|
|
398
|
+
- The checkout page needs a `checkoutPath` and a `termsPath` in the market's checkout block (Purchase refuses a Kustom
|
|
399
|
+
option without them).
|
|
400
|
+
- **CSP hosts: pending K-C10** (`docs/ops/kustom-playground-checks.md`). Until the playground run records the hosts the
|
|
401
|
+
snippet really loads (card with 3-D Secure and the default method), allow `*.kustom.co` and `*.klarna.com` for
|
|
402
|
+
`script-src`, `frame-src` and `connect-src` as the general paragraph above says, and watch the console for blocked requests.
|
|
403
|
+
|
|
383
404
|
## Headers
|
|
384
405
|
|
|
385
406
|
Every call the client makes carries these; you never set them by hand.
|
package/dist/checkout/browser.js
CHANGED
|
@@ -35,6 +35,7 @@ export function mountCheckout(container, checkout, options) {
|
|
|
35
35
|
let active = 0;
|
|
36
36
|
let queue = Promise.resolve(); // *(plan review 1 #43)*
|
|
37
37
|
let unsubscribeDeclined;
|
|
38
|
+
let unsubscribeCannotComplete;
|
|
38
39
|
let expiryTimer;
|
|
39
40
|
let firedPreExpiryFor; // *(plan review 1 #42, plan review 2 #12)*
|
|
40
41
|
// Every pending sleep's resolver, so a remount or destroy can settle it instead of clearing its timer and leaving
|
|
@@ -107,10 +108,16 @@ export function mountCheckout(container, checkout, options) {
|
|
|
107
108
|
}
|
|
108
109
|
function suspend() {
|
|
109
110
|
suspended = true;
|
|
110
|
-
if (suspendedOn
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
if (suspendedOn === controller)
|
|
112
|
+
return;
|
|
113
|
+
// Recorded before the call, as on main: a controller whose suspend throws (a broken provider `lock()`) stays recorded, so a
|
|
114
|
+
// later autonomous sync reaches the server instead of throwing again *(final review B3 T2; review F2-#21)*. Only the
|
|
115
|
+
// explicit "not handed over yet" answer (`false`, spec F §7.1) un-records it, so the next suspend() offers it again —
|
|
116
|
+
// recording it while the provider global was absent made every later suspend() skip the controller (round B).
|
|
117
|
+
const target = controller;
|
|
118
|
+
suspendedOn = target;
|
|
119
|
+
if (target.suspend() === false && suspendedOn === target)
|
|
120
|
+
suspendedOn = undefined;
|
|
114
121
|
}
|
|
115
122
|
function resume(target) { if (suspended) {
|
|
116
123
|
suspended = false;
|
|
@@ -182,28 +189,36 @@ export function mountCheckout(container, checkout, options) {
|
|
|
182
189
|
// and the new widget's decline signal was never bound *(plan review 2 #13)*.
|
|
183
190
|
unsubscribeDeclined?.();
|
|
184
191
|
unsubscribeDeclined = undefined;
|
|
192
|
+
unsubscribeCannotComplete?.();
|
|
193
|
+
unsubscribeCannotComplete = undefined;
|
|
185
194
|
controller = bound;
|
|
186
|
-
// A provider without the signal: nothing to bind and nothing to retry *(plan review 2 #13)*.
|
|
187
|
-
if (!bound.onDeclined)
|
|
188
|
-
return;
|
|
189
195
|
const gen = generation;
|
|
190
196
|
// `render` appends the provider's script; it has not executed when this runs, so the global the controller reads
|
|
191
|
-
// does not exist yet.
|
|
192
|
-
// unsubscriber) or the generation is gone — one interval for as long as the
|
|
193
|
-
// loads late is still bound.
|
|
197
|
+
// does not exist yet. Each signal the controller offers is retried every `bindRetryMs` until it is registered
|
|
198
|
+
// (the subscribe function returns its unsubscriber) or the generation is gone — one interval for as long as the
|
|
199
|
+
// widget is mounted, so a script that loads late is still bound. A controller without the signal binds nothing and
|
|
200
|
+
// retries nothing. The timers are the handle's, so a remount or destroy cancels them
|
|
194
201
|
// *(plan review 1 #43, plan review 2 #13, plan review 3 #4, final review S8)*.
|
|
195
|
-
const
|
|
196
|
-
if (!
|
|
197
|
-
return;
|
|
198
|
-
const unsubscribe = bound.onDeclined(() => autonomousSync("declined", gen));
|
|
199
|
-
if (unsubscribe) {
|
|
200
|
-
unsubscribeDeclined = unsubscribe;
|
|
202
|
+
const bind = (subscribe, handler, keep) => {
|
|
203
|
+
if (!subscribe)
|
|
201
204
|
return;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
const attach = () => {
|
|
206
|
+
if (!alive(gen))
|
|
207
|
+
return;
|
|
208
|
+
const off = subscribe(handler);
|
|
209
|
+
if (off) {
|
|
210
|
+
keep(off);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const h = setT(() => { timers.delete(h); attach(); }, bindRetryMs);
|
|
214
|
+
timers.add(h);
|
|
215
|
+
};
|
|
216
|
+
attach();
|
|
205
217
|
};
|
|
206
|
-
|
|
218
|
+
bind(bound.onDeclined, () => autonomousSync("declined", gen), (off) => { unsubscribeDeclined = off; });
|
|
219
|
+
// Spec F §7.1: Kustom's can_not_complete_order is the app's problem to show, not a validation decline (no sync).
|
|
220
|
+
bind(bound.onCannotComplete, () => { if (alive(gen))
|
|
221
|
+
options.onProblem(localProblem("SDK_PROVIDER_CANNOT_COMPLETE", "can_not_complete_order"), { status: 0 }); }, (off) => { unsubscribeCannotComplete = off; });
|
|
207
222
|
}
|
|
208
223
|
function remount(dto) {
|
|
209
224
|
if (dto.status !== "open" || !dto.widget)
|
|
@@ -211,6 +226,7 @@ export function mountCheckout(container, checkout, options) {
|
|
|
211
226
|
// Resolved before any state changes: an unknown controller throws while the generation is still alive, so the
|
|
212
227
|
// dispatch's catch reports it instead of dropping it for a generation that moved on *(final review B3 T2)*.
|
|
213
228
|
const next = controllerFor(dto.widget.controller, win, controllerTimer);
|
|
229
|
+
controller.dispose?.(); // spec F §7.1: the old mount's deferred callbacks and timers do nothing now
|
|
214
230
|
generation++;
|
|
215
231
|
cancelTimers(); // *(plan review 1 #37)*
|
|
216
232
|
expiryTimer = undefined;
|
|
@@ -419,6 +435,9 @@ export function mountCheckout(container, checkout, options) {
|
|
|
419
435
|
cancelTimers(); // *(plan review 1 #37)*
|
|
420
436
|
unsubscribeDeclined?.();
|
|
421
437
|
unsubscribeDeclined = undefined;
|
|
438
|
+
unsubscribeCannotComplete?.();
|
|
439
|
+
unsubscribeCannotComplete = undefined;
|
|
440
|
+
controller.dispose?.();
|
|
422
441
|
doc.removeEventListener("visibilitychange", onVisible);
|
|
423
442
|
},
|
|
424
443
|
sync(update) {
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/** Kustom's client API (docs.kustom.co → Client-side events). A no-argument `suspend()` auto-resumes after 10 s; the SDK
|
|
2
|
+
* always passes `{ autoResume: { enabled: false } }` (spec F §7.1, review #5). */
|
|
3
|
+
export interface KlarnaCheckoutApi {
|
|
4
|
+
suspend(options?: {
|
|
5
|
+
autoResume?: {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
};
|
|
8
|
+
}): void;
|
|
9
|
+
resume(): void;
|
|
10
|
+
on?(events: Record<string, (data?: unknown) => void>): void;
|
|
11
|
+
}
|
|
1
12
|
/** What the provider's JavaScript offers (spec §4.4 `ClientSuspend`); each is optional on the page until the widget's script has loaded. */
|
|
2
13
|
export interface ProviderWindow {
|
|
3
14
|
q1?: {
|
|
@@ -10,10 +21,7 @@ export interface ProviderWindow {
|
|
|
10
21
|
* (developers.qliro.com → Listeners). */
|
|
11
22
|
onPaymentDeclined?(cb: (declineReason?: string, declineReasonMessage?: string) => void): void;
|
|
12
23
|
};
|
|
13
|
-
_klarnaCheckout?: (cb: (api:
|
|
14
|
-
suspend(): void;
|
|
15
|
-
resume(): void;
|
|
16
|
-
}) => void) => void;
|
|
24
|
+
_klarnaCheckout?: (cb: (api: KlarnaCheckoutApi) => void) => void;
|
|
17
25
|
walley?: {
|
|
18
26
|
checkout?: {
|
|
19
27
|
api?: {
|
|
@@ -33,7 +41,9 @@ export interface ResumeTarget {
|
|
|
33
41
|
onUnsynced(): void;
|
|
34
42
|
}
|
|
35
43
|
export interface CheckoutController {
|
|
36
|
-
|
|
44
|
+
/** Returns `false` when the suspension could not be handed to the provider yet (its script has not defined its API): the
|
|
45
|
+
* handle does not record it, and the controller hands it over once the script is ready (spec F §7.1). */
|
|
46
|
+
suspend(): void | false;
|
|
37
47
|
/** `target` lets a controller that can see the provider's order wait for it (qliro-q1); the others ignore it. */
|
|
38
48
|
resume(target?: ResumeTarget): void;
|
|
39
49
|
/** Subscribe to the provider's payment-declined signal where its API offers one. Returns the unsubscribe once the
|
|
@@ -41,7 +51,14 @@ export interface CheckoutController {
|
|
|
41
51
|
* handle retries only then. A controller whose provider has no such signal leaves this member out, and the handle
|
|
42
52
|
* never retries for it *(plan review 2 #13)*. */
|
|
43
53
|
onDeclined?(handler: () => void): (() => void) | undefined;
|
|
54
|
+
/** The provider says no offered payment method can complete the order (Kustom's `can_not_complete_order`). Same binding
|
|
55
|
+
* contract as `onDeclined`: `undefined` until the provider's script exposes it. */
|
|
56
|
+
onCannotComplete?(handler: () => void): (() => void) | undefined;
|
|
57
|
+
/** This mount is over (remount, destroy): every deferred provider callback and timer of this controller does nothing after it. */
|
|
58
|
+
dispose?(): void;
|
|
44
59
|
}
|
|
60
|
+
/** How often `klarna-kco` checks whether the snippet has defined `_klarnaCheckout`, while a suspension waits for it. */
|
|
61
|
+
export declare const KLARNA_READY_POLL_MS = 100;
|
|
45
62
|
/** A controller's own timer, scheduled on the handle so that a remount or `destroy()` cancels it. */
|
|
46
63
|
export type ControllerTimer = (fn: () => void, ms: number) => void;
|
|
47
64
|
export type ControllerFactory = (win: ProviderWindow, timer: ControllerTimer) => CheckoutController;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** How often `klarna-kco` checks whether the snippet has defined `_klarnaCheckout`, while a suspension waits for it. */
|
|
2
|
+
export const KLARNA_READY_POLL_MS = 100;
|
|
1
3
|
/** How long `qliro-q1`'s resume waits for `q1.onOrderUpdated` to show the confirmed cart. Past it the widget is
|
|
2
4
|
* re-rendered, never unlocked blindly *(plan review 1 #3)*. */
|
|
3
5
|
export const QLIRO_SYNC_DEADLINE_MS = 10_000;
|
|
@@ -58,10 +60,103 @@ export const controllers = {
|
|
|
58
60
|
},
|
|
59
61
|
};
|
|
60
62
|
},
|
|
61
|
-
"klarna-kco": (win) =>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
"klarna-kco": (win, timer) => {
|
|
64
|
+
// Spec F §7.1: every deferred _klarnaCheckout callback carries this mount (`disposed`) and the desired state when it was
|
|
65
|
+
// queued (`seq`, `desired`); it acts only while it is still the latest wish of a live mount. KCO re-reads its order on
|
|
66
|
+
// resume(), so no total comparison is needed — the lifecycle guards are.
|
|
67
|
+
let disposed = false;
|
|
68
|
+
let seq = 0;
|
|
69
|
+
let desired;
|
|
70
|
+
let waiting = false;
|
|
71
|
+
// The desired-state generation already handed to `_klarnaCheckout`: the readiness timer hands over only a wish that was
|
|
72
|
+
// not, so an immediate handover by a later suspend() is never repeated by the timer *(review F2-#22)*.
|
|
73
|
+
let handed = 0;
|
|
74
|
+
const hand = (want, mine) => {
|
|
75
|
+
const kco = win._klarnaCheckout;
|
|
76
|
+
if (!kco)
|
|
77
|
+
return false;
|
|
78
|
+
const before = handed;
|
|
79
|
+
handed = mine;
|
|
80
|
+
try {
|
|
81
|
+
kco((api) => {
|
|
82
|
+
if (disposed || mine !== seq || desired !== want)
|
|
83
|
+
return;
|
|
84
|
+
if (want === "suspended")
|
|
85
|
+
api.suspend({ autoResume: { enabled: false } });
|
|
86
|
+
else
|
|
87
|
+
api.resume();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
handed = before; // the provider never took it: a later handover is not suppressed
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
};
|
|
96
|
+
const awaitScript = () => {
|
|
97
|
+
if (waiting)
|
|
98
|
+
return;
|
|
99
|
+
waiting = true;
|
|
100
|
+
const tick = () => {
|
|
101
|
+
if (disposed)
|
|
102
|
+
return;
|
|
103
|
+
if (!win._klarnaCheckout) {
|
|
104
|
+
timer(tick, KLARNA_READY_POLL_MS);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (desired === "suspended" && handed !== seq) {
|
|
108
|
+
// A global that throws is not ready yet: keep waiting, never throw out of the handle's timer (review I1).
|
|
109
|
+
try {
|
|
110
|
+
hand("suspended", seq);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
timer(tick, KLARNA_READY_POLL_MS);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
waiting = false;
|
|
118
|
+
};
|
|
119
|
+
timer(tick, KLARNA_READY_POLL_MS);
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
suspend: () => {
|
|
123
|
+
const mine = ++seq;
|
|
124
|
+
desired = "suspended";
|
|
125
|
+
if (hand("suspended", mine))
|
|
126
|
+
return;
|
|
127
|
+
awaitScript();
|
|
128
|
+
return false;
|
|
129
|
+
},
|
|
130
|
+
resume: () => {
|
|
131
|
+
const mine = ++seq;
|
|
132
|
+
desired = "resumed";
|
|
133
|
+
// Before the script ran there is nothing to resume; a pending readiness check sees `resumed` and stays quiet. A global
|
|
134
|
+
// that throws is the same "not ready": `hand` restores `handed`, and nothing escapes into the handle (final review, Task 19).
|
|
135
|
+
try {
|
|
136
|
+
hand("resumed", mine);
|
|
137
|
+
}
|
|
138
|
+
catch { /* nothing to resume yet */ }
|
|
139
|
+
},
|
|
140
|
+
onCannotComplete: (handler) => {
|
|
141
|
+
const kco = win._klarnaCheckout;
|
|
142
|
+
if (!kco)
|
|
143
|
+
return undefined;
|
|
144
|
+
let live = true;
|
|
145
|
+
// A global that throws at bind time is "not bound yet": the handle retries, and mount/remount never throw for it
|
|
146
|
+
// (main's klarna-kco made no controller call at bind time) (review I1).
|
|
147
|
+
try {
|
|
148
|
+
kco((api) => api.on?.({ can_not_complete_order: () => { if (live && !disposed)
|
|
149
|
+
handler(); } }));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
live = false;
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
return () => { live = false; };
|
|
156
|
+
},
|
|
157
|
+
dispose: () => { disposed = true; },
|
|
158
|
+
};
|
|
159
|
+
},
|
|
65
160
|
walley: (win) => ({
|
|
66
161
|
suspend: () => win.walley?.checkout?.api?.suspend(),
|
|
67
162
|
resume: () => win.walley?.checkout?.api?.resume(),
|