@shoppexio/storefront 1.0.63 → 1.0.64

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.64
4
+
5
+ - Resolve checkout verification challenges automatically: `checkout()` and `buildCheckoutUrl()` mount the hosted broker themselves on a challenge refusal and replay the create once with the delivered proof — no merchant wiring, no second click. `mountCheckoutChallenge()` gains an `onVisibilityChange` callback.
6
+
3
7
  ## 1.0.63
4
8
 
5
9
  - Keep managed checkout verification collapsed until Cloudflare requires buyer interaction.
package/README.md CHANGED
@@ -66,6 +66,46 @@ currency-bound; Shoppex rejects a mismatch as
66
66
  `errors.checkout.quote_token_stale` instead of creating an invoice for a total
67
67
  the buyer did not approve.
68
68
 
69
+ ## Checkout verification on custom domains
70
+
71
+ Shoppex can require human verification before it creates an invoice. Use the
72
+ hosted verification broker from the SDK. Do not render the returned Turnstile
73
+ site key directly on your custom domain.
74
+
75
+ ```ts
76
+ import shoppex, { mountCheckoutChallenge } from '@shoppexio/storefront';
77
+
78
+ const container = document.getElementById('checkout-verification');
79
+ let frame: ReturnType<typeof mountCheckoutChallenge> | undefined;
80
+
81
+ if (!container) throw new Error('Checkout verification container is missing.');
82
+
83
+ async function startCheckout(turnstileToken?: string) {
84
+ const result = await shoppex.checkout({ turnstileToken });
85
+
86
+ if (result.success) return;
87
+ if (!result.challenge) throw new Error(result.message ?? 'Checkout could not be started.');
88
+
89
+ frame?.dispose();
90
+ frame = mountCheckoutChallenge(container, result.challenge, {
91
+ onSuccess(token) {
92
+ frame?.dispose();
93
+ frame = undefined;
94
+ void startCheckout(token);
95
+ },
96
+ onUnavailable() {
97
+ frame?.dispose();
98
+ frame = undefined;
99
+ console.error('Checkout verification could not load.');
100
+ },
101
+ });
102
+ }
103
+ ```
104
+
105
+ If your site sends a Content Security Policy, add
106
+ `https://checkout.shoppex.io` to `frame-src`. Call `dispose()` when the checkout
107
+ component unmounts or before you mount another challenge.
108
+
69
109
  ## Custom customer account
70
110
 
71
111
  Build your own OTP login, order history, and download UI without adding a
package/dist/index.cjs CHANGED
@@ -19934,6 +19934,152 @@ async function quoteCart(coupon, currency) {
19934
19934
  return response;
19935
19935
  }
19936
19936
 
19937
+ // ../sdk/src/modules/checkout-challenge.ts
19938
+ var TURNSTILE_FRAME_MESSAGE_SOURCE = "shoppex-turnstile";
19939
+ var TURNSTILE_FRAME_MESSAGE_VERSION = 1;
19940
+ var TURNSTILE_FRAME_READY_TIMEOUT_MS = 1e4;
19941
+ function readFrameMessage(value, nonce) {
19942
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
19943
+ const record2 = value;
19944
+ if (record2.source !== TURNSTILE_FRAME_MESSAGE_SOURCE || record2.version !== TURNSTILE_FRAME_MESSAGE_VERSION || record2.nonce !== nonce || !["ready", "visible", "hidden", "success", "expired", "timeout", "error"].includes(String(record2.type))) return null;
19945
+ if (record2.type === "success" && (typeof record2.token !== "string" || !record2.token.trim())) {
19946
+ return null;
19947
+ }
19948
+ return {
19949
+ type: record2.type,
19950
+ ...typeof record2.token === "string" ? { token: record2.token.trim() } : {}
19951
+ };
19952
+ }
19953
+ var AUTO_CHALLENGE_INVISIBLE_TIMEOUT_MS = 3e4;
19954
+ async function resolveCheckoutChallengeProof(challenge) {
19955
+ if (typeof document === "undefined" || challenge.provider !== "turnstile" || !challenge.siteKey.trim()) {
19956
+ return null;
19957
+ }
19958
+ const host = document.createElement("div");
19959
+ host.setAttribute("data-shoppex-checkout-challenge", "");
19960
+ host.style.position = "fixed";
19961
+ host.style.inset = "0";
19962
+ host.style.display = "none";
19963
+ host.style.alignItems = "center";
19964
+ host.style.justifyContent = "center";
19965
+ host.style.background = "rgba(0, 0, 0, 0.55)";
19966
+ host.style.zIndex = "2147483646";
19967
+ const card = document.createElement("div");
19968
+ card.style.width = "min(340px, 90vw)";
19969
+ card.style.background = "#ffffff";
19970
+ card.style.borderRadius = "12px";
19971
+ card.style.padding = "16px";
19972
+ card.style.boxShadow = "0 12px 40px rgba(0, 0, 0, 0.35)";
19973
+ host.appendChild(card);
19974
+ document.body.appendChild(host);
19975
+ return new Promise((resolve) => {
19976
+ let settled = false;
19977
+ let timeoutId = null;
19978
+ let frame = null;
19979
+ const finish = (token) => {
19980
+ if (settled) return;
19981
+ settled = true;
19982
+ if (timeoutId !== null) window.clearTimeout(timeoutId);
19983
+ frame?.dispose();
19984
+ host.remove();
19985
+ resolve(token);
19986
+ };
19987
+ const armTimeout = () => {
19988
+ timeoutId = window.setTimeout(() => finish(null), AUTO_CHALLENGE_INVISIBLE_TIMEOUT_MS);
19989
+ };
19990
+ try {
19991
+ frame = mountCheckoutChallenge(card, challenge, {
19992
+ onSuccess: (token) => finish(token),
19993
+ // `refresh-expired: auto` renews expired runs on its own; the
19994
+ // invisible-run timeout stays the bound.
19995
+ onExpired: () => {
19996
+ },
19997
+ onUnavailable: () => finish(null),
19998
+ onVisibilityChange: (visible) => {
19999
+ host.style.display = visible ? "flex" : "none";
20000
+ if (visible) {
20001
+ if (timeoutId !== null) {
20002
+ window.clearTimeout(timeoutId);
20003
+ timeoutId = null;
20004
+ }
20005
+ } else if (timeoutId === null && !settled) {
20006
+ armTimeout();
20007
+ }
20008
+ }
20009
+ });
20010
+ } catch {
20011
+ host.remove();
20012
+ resolve(null);
20013
+ return;
20014
+ }
20015
+ armTimeout();
20016
+ });
20017
+ }
20018
+ function mountCheckoutChallenge(container, challenge, callbacks) {
20019
+ if (challenge.provider !== "turnstile" || !challenge.siteKey.trim()) {
20020
+ throw new Error("Checkout challenge is invalid.");
20021
+ }
20022
+ const win = container.ownerDocument.defaultView;
20023
+ if (!win) throw new Error("Checkout challenge requires a browser document.");
20024
+ const checkoutBaseUrl = getConfig().checkoutBaseUrl;
20025
+ const frameUrl = new URL("/turnstile", checkoutBaseUrl);
20026
+ if (frameUrl.protocol !== "https:" && frameUrl.protocol !== "http:") {
20027
+ throw new Error("Checkout base URL must use http or https.");
20028
+ }
20029
+ const nonce = win.crypto.randomUUID();
20030
+ frameUrl.searchParams.set("site_key", challenge.siteKey.trim());
20031
+ frameUrl.searchParams.set("nonce", nonce);
20032
+ const frame = container.ownerDocument.createElement("iframe");
20033
+ frame.src = frameUrl.toString();
20034
+ frame.title = "Checkout verification";
20035
+ frame.referrerPolicy = "no-referrer";
20036
+ frame.style.border = "0";
20037
+ frame.style.width = "100%";
20038
+ frame.style.height = "0";
20039
+ let disposed = false;
20040
+ let ready = false;
20041
+ const readyTimeout = win.setTimeout(() => {
20042
+ if (!disposed && !ready) callbacks.onUnavailable?.();
20043
+ }, TURNSTILE_FRAME_READY_TIMEOUT_MS);
20044
+ const onMessage = (event) => {
20045
+ if (disposed || event.origin !== frameUrl.origin || event.source !== frame.contentWindow) return;
20046
+ const message = readFrameMessage(event.data, nonce);
20047
+ if (!message) return;
20048
+ ready = true;
20049
+ win.clearTimeout(readyTimeout);
20050
+ if (message.type === "visible") {
20051
+ frame.style.height = "72px";
20052
+ callbacks.onVisibilityChange?.(true);
20053
+ }
20054
+ if (message.type === "hidden") {
20055
+ frame.style.height = "0";
20056
+ callbacks.onVisibilityChange?.(false);
20057
+ }
20058
+ if (message.type === "success") {
20059
+ frame.style.height = "0";
20060
+ callbacks.onVisibilityChange?.(false);
20061
+ callbacks.onSuccess(message.token);
20062
+ }
20063
+ if (message.type === "expired" || message.type === "timeout") callbacks.onExpired?.();
20064
+ if (message.type === "error") callbacks.onUnavailable?.();
20065
+ };
20066
+ const onFrameError = () => callbacks.onUnavailable?.();
20067
+ win.addEventListener("message", onMessage);
20068
+ frame.addEventListener("error", onFrameError, { once: true });
20069
+ container.appendChild(frame);
20070
+ return {
20071
+ element: frame,
20072
+ dispose() {
20073
+ if (disposed) return;
20074
+ disposed = true;
20075
+ win.clearTimeout(readyTimeout);
20076
+ win.removeEventListener("message", onMessage);
20077
+ frame.removeEventListener("error", onFrameError);
20078
+ frame.remove();
20079
+ }
20080
+ };
20081
+ }
20082
+
19937
20083
  // ../sdk/src/modules/checkout.ts
19938
20084
  var CheckoutCreateError = class _CheckoutCreateError extends Error {
19939
20085
  constructor(message, options = {}) {
@@ -20176,6 +20322,16 @@ function mapCartItemsForApi(items) {
20176
20322
  }
20177
20323
  async function checkout(couponOrOptions, options) {
20178
20324
  const resolvedOptions = resolveCheckoutOptions(couponOrOptions, options);
20325
+ const firstAttempt = await performCheckout(resolvedOptions);
20326
+ if (!firstAttempt.success && firstAttempt.challenge?.provider === "turnstile" && !resolvedOptions.turnstileToken && typeof document !== "undefined") {
20327
+ const proof = await resolveCheckoutChallengeProof(firstAttempt.challenge);
20328
+ if (proof) {
20329
+ return performCheckout({ ...resolvedOptions, turnstileToken: proof });
20330
+ }
20331
+ }
20332
+ return firstAttempt;
20333
+ }
20334
+ async function performCheckout(resolvedOptions) {
20179
20335
  const { autoRedirect = true, email: email3 } = resolvedOptions;
20180
20336
  const checkoutCodes = resolveCheckoutCodes(resolvedOptions);
20181
20337
  const normalizedCoupon = checkoutCodes.coupon;
@@ -20293,6 +20449,19 @@ async function checkout(couponOrOptions, options) {
20293
20449
  }
20294
20450
  async function buildCheckoutUrl(couponOrOptions, options) {
20295
20451
  const resolvedOptions = resolveCheckoutOptions(couponOrOptions, options);
20452
+ try {
20453
+ return await performBuildCheckoutUrl(resolvedOptions);
20454
+ } catch (error51) {
20455
+ if (error51 instanceof CheckoutCreateError && error51.challenge?.provider === "turnstile" && !resolvedOptions.turnstileToken && typeof document !== "undefined") {
20456
+ const proof = await resolveCheckoutChallengeProof(error51.challenge);
20457
+ if (proof) {
20458
+ return performBuildCheckoutUrl({ ...resolvedOptions, turnstileToken: proof });
20459
+ }
20460
+ }
20461
+ throw error51;
20462
+ }
20463
+ }
20464
+ async function performBuildCheckoutUrl(resolvedOptions) {
20296
20465
  const { email: email3 } = resolvedOptions;
20297
20466
  const checkoutCodes = resolveCheckoutCodes(resolvedOptions);
20298
20467
  const normalizedCoupon = checkoutCodes.coupon;
@@ -20381,80 +20550,6 @@ function buildCheckoutUrlSync() {
20381
20550
  throw new Error("buildCheckoutUrlSync is deprecated. Use buildCheckoutUrl (async) instead.");
20382
20551
  }
20383
20552
 
20384
- // ../sdk/src/modules/checkout-challenge.ts
20385
- var TURNSTILE_FRAME_MESSAGE_SOURCE = "shoppex-turnstile";
20386
- var TURNSTILE_FRAME_MESSAGE_VERSION = 1;
20387
- var TURNSTILE_FRAME_READY_TIMEOUT_MS = 1e4;
20388
- function readFrameMessage(value, nonce) {
20389
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
20390
- const record2 = value;
20391
- if (record2.source !== TURNSTILE_FRAME_MESSAGE_SOURCE || record2.version !== TURNSTILE_FRAME_MESSAGE_VERSION || record2.nonce !== nonce || !["ready", "visible", "hidden", "success", "expired", "timeout", "error"].includes(String(record2.type))) return null;
20392
- if (record2.type === "success" && (typeof record2.token !== "string" || !record2.token.trim())) {
20393
- return null;
20394
- }
20395
- return {
20396
- type: record2.type,
20397
- ...typeof record2.token === "string" ? { token: record2.token.trim() } : {}
20398
- };
20399
- }
20400
- function mountCheckoutChallenge(container, challenge, callbacks) {
20401
- if (challenge.provider !== "turnstile" || !challenge.siteKey.trim()) {
20402
- throw new Error("Checkout challenge is invalid.");
20403
- }
20404
- const win = container.ownerDocument.defaultView;
20405
- if (!win) throw new Error("Checkout challenge requires a browser document.");
20406
- const checkoutBaseUrl = getConfig().checkoutBaseUrl;
20407
- const frameUrl = new URL("/turnstile", checkoutBaseUrl);
20408
- if (frameUrl.protocol !== "https:" && frameUrl.protocol !== "http:") {
20409
- throw new Error("Checkout base URL must use http or https.");
20410
- }
20411
- const nonce = win.crypto.randomUUID();
20412
- frameUrl.searchParams.set("site_key", challenge.siteKey.trim());
20413
- frameUrl.searchParams.set("nonce", nonce);
20414
- const frame = container.ownerDocument.createElement("iframe");
20415
- frame.src = frameUrl.toString();
20416
- frame.title = "Checkout verification";
20417
- frame.referrerPolicy = "no-referrer";
20418
- frame.style.border = "0";
20419
- frame.style.width = "100%";
20420
- frame.style.height = "0";
20421
- let disposed = false;
20422
- let ready = false;
20423
- const readyTimeout = win.setTimeout(() => {
20424
- if (!disposed && !ready) callbacks.onUnavailable?.();
20425
- }, TURNSTILE_FRAME_READY_TIMEOUT_MS);
20426
- const onMessage = (event) => {
20427
- if (disposed || event.origin !== frameUrl.origin || event.source !== frame.contentWindow) return;
20428
- const message = readFrameMessage(event.data, nonce);
20429
- if (!message) return;
20430
- ready = true;
20431
- win.clearTimeout(readyTimeout);
20432
- if (message.type === "visible") frame.style.height = "72px";
20433
- if (message.type === "hidden") frame.style.height = "0";
20434
- if (message.type === "success") {
20435
- frame.style.height = "0";
20436
- callbacks.onSuccess(message.token);
20437
- }
20438
- if (message.type === "expired" || message.type === "timeout") callbacks.onExpired?.();
20439
- if (message.type === "error") callbacks.onUnavailable?.();
20440
- };
20441
- const onFrameError = () => callbacks.onUnavailable?.();
20442
- win.addEventListener("message", onMessage);
20443
- frame.addEventListener("error", onFrameError, { once: true });
20444
- container.appendChild(frame);
20445
- return {
20446
- element: frame,
20447
- dispose() {
20448
- if (disposed) return;
20449
- disposed = true;
20450
- win.clearTimeout(readyTimeout);
20451
- win.removeEventListener("message", onMessage);
20452
- frame.removeEventListener("error", onFrameError);
20453
- frame.remove();
20454
- }
20455
- };
20456
- }
20457
-
20458
20553
  // ../sdk/src/modules/coupons.ts
20459
20554
  async function resolveShopId() {
20460
20555
  const cachedShopId2 = getShopId();