@asteby/storefront-react 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/dist/payments.css CHANGED
@@ -87,3 +87,68 @@
87
87
  flex-wrap: wrap;
88
88
  gap: 0.5rem;
89
89
  }
90
+
91
+ /*
92
+ * 3DS challenge (`<PaymentChallenge>`): a near full-screen modal. Colors are fixed on purpose: the bank page inside
93
+ * the iframe is always white, and the dialog often lives in a themed (dark) panel. Override with a more specific rule.
94
+ */
95
+ dialog[data-sf-challenge] {
96
+ margin: auto;
97
+ box-sizing: border-box;
98
+ width: min(60rem, calc(100% - 2rem));
99
+ max-width: none;
100
+ max-height: 94dvh;
101
+ overflow: auto;
102
+ border: 1px solid #c9d2ee;
103
+ border-radius: 0.75rem;
104
+ background: #ffffff;
105
+ color: #0b1220;
106
+ /* Claro aunque la página esté en oscuro: barras de desplazamiento y controles del navegador sobre fondo blanco. */
107
+ color-scheme: light;
108
+ padding: 1.25rem;
109
+ }
110
+ dialog[data-sf-challenge]::backdrop {
111
+ background: rgb(0 8 31 / 0.72);
112
+ }
113
+ dialog[data-sf-challenge] h2 {
114
+ margin: 0 0 0.75rem;
115
+ font-weight: 800;
116
+ color: #001963;
117
+ }
118
+ dialog[data-sf-challenge] h2:focus {
119
+ outline: none;
120
+ }
121
+ dialog[data-sf-challenge] [data-sf-challenge-frame] {
122
+ display: block;
123
+ height: min(70dvh, 40rem);
124
+ background: #ffffff;
125
+ }
126
+ dialog[data-sf-challenge] [data-sf-challenge-escape] {
127
+ margin-top: 0.75rem;
128
+ font-size: 0.875rem;
129
+ }
130
+ dialog[data-sf-challenge] [data-sf-challenge-escape]:empty {
131
+ margin: 0;
132
+ }
133
+ dialog[data-sf-challenge] [data-sf-challenge-escape] button {
134
+ margin-top: 0.5rem;
135
+ padding: 0 1rem;
136
+ background: #ffffff;
137
+ color: #0b1220;
138
+ border: 1px solid #8a94b3;
139
+ border-radius: 0.5rem;
140
+ font-weight: 600;
141
+ }
142
+ @media (max-width: 40rem) {
143
+ dialog[data-sf-challenge] {
144
+ width: 100%;
145
+ height: 100dvh;
146
+ max-height: 100dvh;
147
+ border-radius: 0;
148
+ border: 0;
149
+ padding: 1rem;
150
+ }
151
+ dialog[data-sf-challenge] [data-sf-challenge-frame] {
152
+ height: calc(100dvh - 8rem);
153
+ }
154
+ }
@@ -226,6 +226,14 @@ type EmbeddedPaymentState = {
226
226
  retry: () => Promise<void>;
227
227
  /** After a rejection: clears the message (the form is remounted by the hook). */
228
228
  dismiss: () => void;
229
+ /**
230
+ * Leaves a pending 3DS challenge (the buyer's emergency exit). The outcome is UNKNOWN (the bank may still approve the
231
+ * attempt the buyer just verified), so the attempt key is kept and no new payment can start: the hook verifies the
232
+ * payment state through `transport.refresh` (status `blocked`, `error.kind = 'payment_in_progress'`, `verifying`).
233
+ * Only a definitive answer settles it: approved, or rejected / expired / cancelled, which gives a new attempt and a
234
+ * fresh form. A still-pending challenge does not reopen the dialog.
235
+ */
236
+ cancelChallenge: () => void;
229
237
  };
230
238
  /**
231
239
  * Embedded payment state machine for one card form.
@@ -256,10 +264,20 @@ type PaymentChallengeProps = {
256
264
  messages?: Partial<PaymentMessages>;
257
265
  /** Replace the default iframe (e.g. mount the provider's status screen). */
258
266
  render?: (outcome: PaymentOutcome) => ReactNode;
267
+ /**
268
+ * The buyer's emergency exit. The dialog is NOT dismissible (no Esc, no backdrop click) while the payment is in
269
+ * progress; this button only appears after `escapeAfterMs`, so a stuck bank page never traps the buyer (WCAG 2.2.1).
270
+ */
259
271
  onCancel?: () => void;
272
+ /** How long the dialog stays locked before the exit shows. Default 120000. */
273
+ escapeAfterMs?: number;
260
274
  };
261
- /** 3DS inside the page: a modal dialog with title and focus trap. Default content is the bank's https iframe. */
262
- declare function PaymentChallenge({ outcome, locale, messages, render, onCancel }: PaymentChallengeProps): react.JSX.Element;
275
+ /**
276
+ * 3DS inside the page: a near full-screen modal dialog (`showModal()`: focus trap, inert page) with an accessible title.
277
+ * Esc and backdrop clicks do nothing until the payment ends (the owner unmounts it). Default content is the bank's
278
+ * https iframe. Layout comes from `payments.css` (`dialog[data-sf-challenge]`), overridable with your own CSS.
279
+ */
280
+ declare function PaymentChallenge({ outcome, locale, messages, render, onCancel, escapeAfterMs }: PaymentChallengeProps): react.JSX.Element;
263
281
  type PaymentBrickHandle = EmbeddedPaymentState;
264
282
  type PaymentBrickProps = Omit<UseEmbeddedPaymentOptions, 'amount'> & {
265
283
  /** The server's total. Shown on the button and passed to the provider widget. */
@@ -494,6 +512,8 @@ type MercadoPagoAdapterOptions = {
494
512
  getSdk?: () => MercadoPagoCtor | undefined;
495
513
  /** Reads the anti-fraud device id. Default: `window.MP_DEVICE_SESSION_ID`. */
496
514
  getDeviceSessionId?: () => string | undefined;
515
+ /** Wait before the single internal re-read of the form data (see `collect`). Default 400. */
516
+ collectRetryDelayMs?: number;
497
517
  };
498
518
  /**
499
519
  * The only fields that may leave the browser, and the only place that decides it. Anything else the SDK returns
@@ -502,7 +522,8 @@ type MercadoPagoAdapterOptions = {
502
522
  declare function sanitizeMercadoPagoFormData(data: Record<string, unknown> | null | undefined): Record<string, unknown>;
503
523
  /**
504
524
  * Card Payment Brick adapter. The payment button is hidden: the shop's own button calls `collect()` (MP's
505
- * `getFormData()`), which tokenises inside MP's iframes. Card data never touches this code.
525
+ * `getFormData()`), which tokenises inside MP's iframes. Card data never touches this code. `collect()` re-reads the
526
+ * form once if the first read fails, before anything is sent to the shop's server.
506
527
  */
507
528
  declare function mercadoPagoBricksAdapter(options?: MercadoPagoAdapterOptions): PaymentFormAdapter;
508
529
 
@@ -226,6 +226,14 @@ type EmbeddedPaymentState = {
226
226
  retry: () => Promise<void>;
227
227
  /** After a rejection: clears the message (the form is remounted by the hook). */
228
228
  dismiss: () => void;
229
+ /**
230
+ * Leaves a pending 3DS challenge (the buyer's emergency exit). The outcome is UNKNOWN (the bank may still approve the
231
+ * attempt the buyer just verified), so the attempt key is kept and no new payment can start: the hook verifies the
232
+ * payment state through `transport.refresh` (status `blocked`, `error.kind = 'payment_in_progress'`, `verifying`).
233
+ * Only a definitive answer settles it: approved, or rejected / expired / cancelled, which gives a new attempt and a
234
+ * fresh form. A still-pending challenge does not reopen the dialog.
235
+ */
236
+ cancelChallenge: () => void;
229
237
  };
230
238
  /**
231
239
  * Embedded payment state machine for one card form.
@@ -256,10 +264,20 @@ type PaymentChallengeProps = {
256
264
  messages?: Partial<PaymentMessages>;
257
265
  /** Replace the default iframe (e.g. mount the provider's status screen). */
258
266
  render?: (outcome: PaymentOutcome) => ReactNode;
267
+ /**
268
+ * The buyer's emergency exit. The dialog is NOT dismissible (no Esc, no backdrop click) while the payment is in
269
+ * progress; this button only appears after `escapeAfterMs`, so a stuck bank page never traps the buyer (WCAG 2.2.1).
270
+ */
259
271
  onCancel?: () => void;
272
+ /** How long the dialog stays locked before the exit shows. Default 120000. */
273
+ escapeAfterMs?: number;
260
274
  };
261
- /** 3DS inside the page: a modal dialog with title and focus trap. Default content is the bank's https iframe. */
262
- declare function PaymentChallenge({ outcome, locale, messages, render, onCancel }: PaymentChallengeProps): react.JSX.Element;
275
+ /**
276
+ * 3DS inside the page: a near full-screen modal dialog (`showModal()`: focus trap, inert page) with an accessible title.
277
+ * Esc and backdrop clicks do nothing until the payment ends (the owner unmounts it). Default content is the bank's
278
+ * https iframe. Layout comes from `payments.css` (`dialog[data-sf-challenge]`), overridable with your own CSS.
279
+ */
280
+ declare function PaymentChallenge({ outcome, locale, messages, render, onCancel, escapeAfterMs }: PaymentChallengeProps): react.JSX.Element;
263
281
  type PaymentBrickHandle = EmbeddedPaymentState;
264
282
  type PaymentBrickProps = Omit<UseEmbeddedPaymentOptions, 'amount'> & {
265
283
  /** The server's total. Shown on the button and passed to the provider widget. */
@@ -494,6 +512,8 @@ type MercadoPagoAdapterOptions = {
494
512
  getSdk?: () => MercadoPagoCtor | undefined;
495
513
  /** Reads the anti-fraud device id. Default: `window.MP_DEVICE_SESSION_ID`. */
496
514
  getDeviceSessionId?: () => string | undefined;
515
+ /** Wait before the single internal re-read of the form data (see `collect`). Default 400. */
516
+ collectRetryDelayMs?: number;
497
517
  };
498
518
  /**
499
519
  * The only fields that may leave the browser, and the only place that decides it. Anything else the SDK returns
@@ -502,7 +522,8 @@ type MercadoPagoAdapterOptions = {
502
522
  declare function sanitizeMercadoPagoFormData(data: Record<string, unknown> | null | undefined): Record<string, unknown>;
503
523
  /**
504
524
  * Card Payment Brick adapter. The payment button is hidden: the shop's own button calls `collect()` (MP's
505
- * `getFormData()`), which tokenises inside MP's iframes. Card data never touches this code.
525
+ * `getFormData()`), which tokenises inside MP's iframes. Card data never touches this code. `collect()` re-reads the
526
+ * form once if the first read fails, before anything is sent to the shop's server.
506
527
  */
507
528
  declare function mercadoPagoBricksAdapter(options?: MercadoPagoAdapterOptions): PaymentFormAdapter;
508
529
 
package/dist/payments.js CHANGED
@@ -84,6 +84,7 @@ function sanitizeMercadoPagoFormData(data) {
84
84
  function mercadoPagoBricksAdapter(options = {}) {
85
85
  const allowed = options.allowedSdkOrigins ?? ["https://sdk.mercadopago.com"];
86
86
  const getSdk = options.getSdk ?? (() => typeof window !== "undefined" ? window.MercadoPago : void 0);
87
+ const retryDelay = options.collectRetryDelayMs ?? 400;
87
88
  const getDevice = options.getDeviceSessionId ?? (() => typeof window !== "undefined" ? window.MP_DEVICE_SESSION_ID : void 0);
88
89
  return {
89
90
  kind: MERCADOPAGO_BRICKS_KIND,
@@ -125,9 +126,21 @@ function mercadoPagoBricksAdapter(options = {}) {
125
126
  return {
126
127
  async collect() {
127
128
  if (gone) throw new Error("payment form is unmounted");
128
- const raw = await controller.getFormData();
129
- const provider_data = sanitizeMercadoPagoFormData(raw);
130
- if (!provider_data.token) throw new Error("payment form incomplete");
129
+ const read = async () => {
130
+ const raw = await controller.getFormData();
131
+ const data = sanitizeMercadoPagoFormData(raw);
132
+ if (!data.token) throw new Error("payment form incomplete");
133
+ return data;
134
+ };
135
+ let provider_data;
136
+ try {
137
+ provider_data = await read();
138
+ } catch (first) {
139
+ if (gone) throw first;
140
+ await new Promise((r) => setTimeout(r, retryDelay));
141
+ if (gone) throw first;
142
+ provider_data = await read();
143
+ }
131
144
  const device = getDevice();
132
145
  return { provider_data, ...typeof device === "string" && device ? { device_session_id: device } : {} };
133
146
  },
@@ -485,7 +498,7 @@ function useEmbeddedPayment(options) {
485
498
  autoRetries = 2,
486
499
  retryDelayMs = 600,
487
500
  pollIntervalMs = 2e3,
488
- pollTimeoutMs = 6e4,
501
+ pollTimeoutMs = 3e5,
489
502
  verifyTimeoutMs = 12e4,
490
503
  verifyIntervalMs = 2e3,
491
504
  scope = "default"
@@ -514,6 +527,7 @@ function useEmbeddedPayment(options) {
514
527
  const alive = useRef(true);
515
528
  const generation = useRef(0);
516
529
  const statusRef = useRef("idle");
530
+ const leftChallenge = useRef(false);
517
531
  statusRef.current = status;
518
532
  errorRef.current = error;
519
533
  const amountKey = `${amount.amount}|${amount.currency_code}`;
@@ -697,6 +711,7 @@ function useEmbeddedPayment(options) {
697
711
  (result) => {
698
712
  const o = paymentOutcome(result, live.current.locale, live.current.messages);
699
713
  pendingData.current = null;
714
+ leftChallenge.current = false;
700
715
  setOutcome(o);
701
716
  setError(null);
702
717
  setAttemptsLeft(o.attempts_left);
@@ -781,15 +796,21 @@ function useEmbeddedPayment(options) {
781
796
  setError(null);
782
797
  setOutcome(null);
783
798
  emit({ type: "submit" });
799
+ const withdrawn = () => !alive.current || form.current !== m;
800
+ const settle = () => {
801
+ if (alive.current) setStatus((s) => s === "processing" ? "idle" : s);
802
+ };
784
803
  let data;
785
804
  try {
786
805
  data = await m.collect();
787
806
  } catch {
807
+ if (withdrawn()) return settle();
788
808
  emit({ type: "tokenize_error" });
789
809
  setError({ kind: "generic", message: live.current.msgs["error.card_data"], retrySameAttempt: false, canRetry: true });
790
810
  setStatus("ready");
791
811
  return;
792
812
  }
813
+ if (withdrawn()) return settle();
793
814
  const attempt = readAttempt();
794
815
  setAttemptId(attempt);
795
816
  await send(data, attempt, total);
@@ -857,7 +878,7 @@ function useEmbeddedPayment(options) {
857
878
  }
858
879
  let stop = false;
859
880
  const ctl = new AbortController();
860
- const until = Date.now() + verifyTimeoutMs;
881
+ const until = Date.now() + (leftChallenge.current ? Math.max(verifyTimeoutMs, pollTimeoutMs) : verifyTimeoutMs);
861
882
  setVerifying(true);
862
883
  (async () => {
863
884
  let wait = verifyIntervalMs;
@@ -869,6 +890,8 @@ function useEmbeddedPayment(options) {
869
890
  const r = await transport.refresh({ signal: ctl.signal });
870
891
  if (stop || !alive.current) return;
871
892
  if (isResolvedAttempt(r)) {
893
+ const settled = r.payment.status === "paid" || r.payment.status === "failed" || r.payment.status === "expired" || r.payment.status === "cancelled";
894
+ if (leftChallenge.current && !settled && r.payment.action?.type === "challenge") continue;
872
895
  setVerifying(false);
873
896
  finish(r);
874
897
  return;
@@ -883,11 +906,18 @@ function useEmbeddedPayment(options) {
883
906
  ctl.abort();
884
907
  setVerifying(false);
885
908
  };
886
- }, [unknownOutcome, transport, verifyTimeoutMs, verifyIntervalMs, finish]);
909
+ }, [unknownOutcome, transport, verifyTimeoutMs, verifyIntervalMs, pollTimeoutMs, finish]);
887
910
  const dismiss = useCallback(() => {
888
911
  setError(null);
889
912
  setOutcome((o) => o?.kind === "rejected" ? null : o);
890
913
  }, []);
914
+ const cancelChallenge = useCallback(() => {
915
+ if (statusRef.current !== "challenge") return;
916
+ leftChallenge.current = true;
917
+ setOutcome(null);
918
+ setError({ kind: "payment_in_progress", message: live.current.msgs["challenge.left"], retrySameAttempt: false, canRetry: false });
919
+ setStatus("blocked");
920
+ }, []);
891
921
  const message = status === "processing" ? msgs["state.processing"] : status === "fallback" ? msgs["state.fallback"] : error?.message ?? outcome?.message ?? (status === "loading" ? msgs["state.loading"] : null);
892
922
  return {
893
923
  status,
@@ -903,7 +933,8 @@ function useEmbeddedPayment(options) {
903
933
  containerId,
904
934
  submit,
905
935
  retry,
906
- dismiss
936
+ dismiss,
937
+ cancelChallenge
907
938
  };
908
939
  }
909
940
  function VoucherCard({ outcome, locale, messages, className }) {
@@ -940,15 +971,55 @@ function VoucherCard({ outcome, locale, messages, className }) {
940
971
  /* @__PURE__ */ jsx("p", { children: m["voucher.email"] })
941
972
  ] });
942
973
  }
943
- function PaymentChallenge({ outcome, locale, messages, render, onCancel }) {
974
+ function PaymentChallenge({ outcome, locale, messages, render, onCancel, escapeAfterMs = 12e4 }) {
944
975
  const m = paymentMessages(locale, messages);
945
- const { ref } = useModalDialog({ open: true, onClose: () => onCancel?.() });
976
+ const { ref } = useModalDialog({ open: true, onClose: () => {
977
+ } });
978
+ const [canEscape, setCanEscape] = useState(false);
979
+ const [closed, setClosed] = useState(false);
980
+ const cancelRef = useRef(onCancel);
981
+ cancelRef.current = onCancel;
982
+ const hasCancel = !!onCancel;
983
+ useEffect(() => {
984
+ if (!hasCancel) return;
985
+ const t = setTimeout(() => setCanEscape(true), escapeAfterMs);
986
+ return () => clearTimeout(t);
987
+ }, [hasCancel, escapeAfterMs]);
988
+ useEffect(() => {
989
+ const el = ref.current;
990
+ if (!el) return;
991
+ const onClosed = () => setClosed(true);
992
+ el.addEventListener("close", onClosed);
993
+ return () => el.removeEventListener("close", onClosed);
994
+ }, [ref]);
995
+ const reopen = () => {
996
+ const el = ref.current;
997
+ if (!el) return;
998
+ try {
999
+ if (typeof el.showModal === "function") el.showModal();
1000
+ else el.setAttribute("open", "");
1001
+ } catch {
1002
+ el.setAttribute("open", "");
1003
+ }
1004
+ setClosed(false);
1005
+ el.querySelector("[data-sf-autofocus]")?.focus({ preventScroll: true });
1006
+ };
946
1007
  const url = outcome.challenge?.url;
947
- return /* @__PURE__ */ jsx("dialog", { ref, "aria-labelledby": "sf-challenge-title", "data-sf-challenge": true, children: /* @__PURE__ */ jsxs("div", { children: [
948
- /* @__PURE__ */ jsx("h2", { id: "sf-challenge-title", "data-sf-autofocus": true, tabIndex: -1, children: m["challenge.title"] }),
949
- render ? render(outcome) : url ? /* @__PURE__ */ jsx("iframe", { src: url, title: m["challenge.title"], style: { width: "100%", minHeight: 420, border: 0 } }) : /* @__PURE__ */ jsx("p", { children: m["state.challenge"] }),
950
- onCancel && /* @__PURE__ */ jsx("button", { type: "button", onClick: onCancel, style: { minWidth: 44, minHeight: 44 }, children: m["action.cancel"] })
951
- ] }) });
1008
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1009
+ closed && /* @__PURE__ */ jsxs("p", { role: "status", "data-sf-challenge-closed": true, children: [
1010
+ m["state.challenge"],
1011
+ " ",
1012
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: reopen, "data-sf-challenge-reopen": true, style: { minWidth: 44, minHeight: 44 }, children: m["challenge.title"] })
1013
+ ] }),
1014
+ /* @__PURE__ */ jsx("dialog", { ref, "aria-labelledby": "sf-challenge-title", "data-sf-challenge": true, children: /* @__PURE__ */ jsxs("div", { "data-sf-challenge-body": true, children: [
1015
+ /* @__PURE__ */ jsx("h2", { id: "sf-challenge-title", "data-sf-autofocus": true, tabIndex: -1, children: m["challenge.title"] }),
1016
+ render ? render(outcome) : url ? /* @__PURE__ */ jsx("iframe", { src: url, title: m["challenge.title"], "data-sf-challenge-frame": true, style: { width: "100%", minHeight: 420, border: 0 } }) : /* @__PURE__ */ jsx("p", { children: m["state.challenge"] }),
1017
+ hasCancel && /* @__PURE__ */ jsx("div", { "data-sf-challenge-escape": true, "aria-live": "polite", children: canEscape && /* @__PURE__ */ jsxs(Fragment, { children: [
1018
+ /* @__PURE__ */ jsx("p", { children: m["challenge.escape_hint"] }),
1019
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: () => cancelRef.current?.(), style: { minWidth: 44, minHeight: 44 }, children: m["challenge.escape"] })
1020
+ ] }) })
1021
+ ] }) })
1022
+ ] });
952
1023
  }
953
1024
  var PaymentBrick = forwardRef(function PaymentBrick2(props, ref) {
954
1025
  const { submitLabel, hideAmountInButton, renderVoucher, renderChallenge, minHeight = 360, className, style, ...hookOptions } = props;
@@ -986,7 +1057,7 @@ var PaymentBrick = forwardRef(function PaymentBrick2(props, ref) {
986
1057
  state.status === "network_error" && /* @__PURE__ */ jsx("button", { type: "button", onClick: () => void state.retry(), style: { minHeight: 44 }, children: m["action.retry"] })
987
1058
  ] }) : /* @__PURE__ */ jsx("p", { role: "status", "aria-live": "polite", "data-sf-pay-message": true, children: state.message }),
988
1059
  outcome?.kind === "voucher" && (renderVoucher ? renderVoucher(outcome) : /* @__PURE__ */ jsx(VoucherCard, { outcome, locale, ...props.messages ? { messages: props.messages } : {} })),
989
- outcome?.kind === "challenge" && /* @__PURE__ */ jsx(PaymentChallenge, { outcome, locale, ...props.messages ? { messages: props.messages } : {}, ...renderChallenge ? { render: renderChallenge } : {} }),
1060
+ outcome?.kind === "challenge" && /* @__PURE__ */ jsx(PaymentChallenge, { outcome, locale, onCancel: state.cancelChallenge, ...props.messages ? { messages: props.messages } : {}, ...renderChallenge ? { render: renderChallenge } : {} }),
990
1061
  submitLabel !== void 0 && showForm && /* @__PURE__ */ jsxs(
991
1062
  "button",
992
1063
  {