@asteby/storefront-react 0.17.0 → 0.18.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. */
@@ -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. */
package/dist/payments.js CHANGED
@@ -485,7 +485,7 @@ function useEmbeddedPayment(options) {
485
485
  autoRetries = 2,
486
486
  retryDelayMs = 600,
487
487
  pollIntervalMs = 2e3,
488
- pollTimeoutMs = 6e4,
488
+ pollTimeoutMs = 3e5,
489
489
  verifyTimeoutMs = 12e4,
490
490
  verifyIntervalMs = 2e3,
491
491
  scope = "default"
@@ -514,6 +514,7 @@ function useEmbeddedPayment(options) {
514
514
  const alive = useRef(true);
515
515
  const generation = useRef(0);
516
516
  const statusRef = useRef("idle");
517
+ const leftChallenge = useRef(false);
517
518
  statusRef.current = status;
518
519
  errorRef.current = error;
519
520
  const amountKey = `${amount.amount}|${amount.currency_code}`;
@@ -697,6 +698,7 @@ function useEmbeddedPayment(options) {
697
698
  (result) => {
698
699
  const o = paymentOutcome(result, live.current.locale, live.current.messages);
699
700
  pendingData.current = null;
701
+ leftChallenge.current = false;
700
702
  setOutcome(o);
701
703
  setError(null);
702
704
  setAttemptsLeft(o.attempts_left);
@@ -857,7 +859,7 @@ function useEmbeddedPayment(options) {
857
859
  }
858
860
  let stop = false;
859
861
  const ctl = new AbortController();
860
- const until = Date.now() + verifyTimeoutMs;
862
+ const until = Date.now() + (leftChallenge.current ? Math.max(verifyTimeoutMs, pollTimeoutMs) : verifyTimeoutMs);
861
863
  setVerifying(true);
862
864
  (async () => {
863
865
  let wait = verifyIntervalMs;
@@ -869,6 +871,8 @@ function useEmbeddedPayment(options) {
869
871
  const r = await transport.refresh({ signal: ctl.signal });
870
872
  if (stop || !alive.current) return;
871
873
  if (isResolvedAttempt(r)) {
874
+ const settled = r.payment.status === "paid" || r.payment.status === "failed" || r.payment.status === "expired" || r.payment.status === "cancelled";
875
+ if (leftChallenge.current && !settled && r.payment.action?.type === "challenge") continue;
872
876
  setVerifying(false);
873
877
  finish(r);
874
878
  return;
@@ -883,11 +887,18 @@ function useEmbeddedPayment(options) {
883
887
  ctl.abort();
884
888
  setVerifying(false);
885
889
  };
886
- }, [unknownOutcome, transport, verifyTimeoutMs, verifyIntervalMs, finish]);
890
+ }, [unknownOutcome, transport, verifyTimeoutMs, verifyIntervalMs, pollTimeoutMs, finish]);
887
891
  const dismiss = useCallback(() => {
888
892
  setError(null);
889
893
  setOutcome((o) => o?.kind === "rejected" ? null : o);
890
894
  }, []);
895
+ const cancelChallenge = useCallback(() => {
896
+ if (statusRef.current !== "challenge") return;
897
+ leftChallenge.current = true;
898
+ setOutcome(null);
899
+ setError({ kind: "payment_in_progress", message: live.current.msgs["challenge.left"], retrySameAttempt: false, canRetry: false });
900
+ setStatus("blocked");
901
+ }, []);
891
902
  const message = status === "processing" ? msgs["state.processing"] : status === "fallback" ? msgs["state.fallback"] : error?.message ?? outcome?.message ?? (status === "loading" ? msgs["state.loading"] : null);
892
903
  return {
893
904
  status,
@@ -903,7 +914,8 @@ function useEmbeddedPayment(options) {
903
914
  containerId,
904
915
  submit,
905
916
  retry,
906
- dismiss
917
+ dismiss,
918
+ cancelChallenge
907
919
  };
908
920
  }
909
921
  function VoucherCard({ outcome, locale, messages, className }) {
@@ -940,15 +952,55 @@ function VoucherCard({ outcome, locale, messages, className }) {
940
952
  /* @__PURE__ */ jsx("p", { children: m["voucher.email"] })
941
953
  ] });
942
954
  }
943
- function PaymentChallenge({ outcome, locale, messages, render, onCancel }) {
955
+ function PaymentChallenge({ outcome, locale, messages, render, onCancel, escapeAfterMs = 12e4 }) {
944
956
  const m = paymentMessages(locale, messages);
945
- const { ref } = useModalDialog({ open: true, onClose: () => onCancel?.() });
957
+ const { ref } = useModalDialog({ open: true, onClose: () => {
958
+ } });
959
+ const [canEscape, setCanEscape] = useState(false);
960
+ const [closed, setClosed] = useState(false);
961
+ const cancelRef = useRef(onCancel);
962
+ cancelRef.current = onCancel;
963
+ const hasCancel = !!onCancel;
964
+ useEffect(() => {
965
+ if (!hasCancel) return;
966
+ const t = setTimeout(() => setCanEscape(true), escapeAfterMs);
967
+ return () => clearTimeout(t);
968
+ }, [hasCancel, escapeAfterMs]);
969
+ useEffect(() => {
970
+ const el = ref.current;
971
+ if (!el) return;
972
+ const onClosed = () => setClosed(true);
973
+ el.addEventListener("close", onClosed);
974
+ return () => el.removeEventListener("close", onClosed);
975
+ }, [ref]);
976
+ const reopen = () => {
977
+ const el = ref.current;
978
+ if (!el) return;
979
+ try {
980
+ if (typeof el.showModal === "function") el.showModal();
981
+ else el.setAttribute("open", "");
982
+ } catch {
983
+ el.setAttribute("open", "");
984
+ }
985
+ setClosed(false);
986
+ el.querySelector("[data-sf-autofocus]")?.focus({ preventScroll: true });
987
+ };
946
988
  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
- ] }) });
989
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
990
+ closed && /* @__PURE__ */ jsxs("p", { role: "status", "data-sf-challenge-closed": true, children: [
991
+ m["state.challenge"],
992
+ " ",
993
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: reopen, "data-sf-challenge-reopen": true, style: { minWidth: 44, minHeight: 44 }, children: m["challenge.title"] })
994
+ ] }),
995
+ /* @__PURE__ */ jsx("dialog", { ref, "aria-labelledby": "sf-challenge-title", "data-sf-challenge": true, children: /* @__PURE__ */ jsxs("div", { "data-sf-challenge-body": true, children: [
996
+ /* @__PURE__ */ jsx("h2", { id: "sf-challenge-title", "data-sf-autofocus": true, tabIndex: -1, children: m["challenge.title"] }),
997
+ 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"] }),
998
+ hasCancel && /* @__PURE__ */ jsx("div", { "data-sf-challenge-escape": true, "aria-live": "polite", children: canEscape && /* @__PURE__ */ jsxs(Fragment, { children: [
999
+ /* @__PURE__ */ jsx("p", { children: m["challenge.escape_hint"] }),
1000
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: () => cancelRef.current?.(), style: { minWidth: 44, minHeight: 44 }, children: m["challenge.escape"] })
1001
+ ] }) })
1002
+ ] }) })
1003
+ ] });
952
1004
  }
953
1005
  var PaymentBrick = forwardRef(function PaymentBrick2(props, ref) {
954
1006
  const { submitLabel, hideAmountInButton, renderVoucher, renderChallenge, minHeight = 360, className, style, ...hookOptions } = props;
@@ -986,7 +1038,7 @@ var PaymentBrick = forwardRef(function PaymentBrick2(props, ref) {
986
1038
  state.status === "network_error" && /* @__PURE__ */ jsx("button", { type: "button", onClick: () => void state.retry(), style: { minHeight: 44 }, children: m["action.retry"] })
987
1039
  ] }) : /* @__PURE__ */ jsx("p", { role: "status", "aria-live": "polite", "data-sf-pay-message": true, children: state.message }),
988
1040
  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 } : {} }),
1041
+ outcome?.kind === "challenge" && /* @__PURE__ */ jsx(PaymentChallenge, { outcome, locale, onCancel: state.cancelChallenge, ...props.messages ? { messages: props.messages } : {}, ...renderChallenge ? { render: renderChallenge } : {} }),
990
1042
  submitLabel !== void 0 && showForm && /* @__PURE__ */ jsxs(
991
1043
  "button",
992
1044
  {