@flopay/react 1.3.3 → 1.3.4

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/index.cjs CHANGED
@@ -802,8 +802,63 @@ var import_react8 = require("react");
802
802
  var import_paypal_js = require("@paypal/paypal-js");
803
803
  var import_js = require("@flopay/js");
804
804
  var import_shared4 = require("@flopay/shared");
805
+
806
+ // src/external-method-recovery.ts
807
+ var EXTERNAL_METHOD_CALLBACK_GRACE_MS = 600;
808
+ function getProviderErrorMessage(err) {
809
+ if (err instanceof Error) return err.message;
810
+ if (typeof err === "object" && err !== null) {
811
+ const message = err.message;
812
+ return typeof message === "string" ? message : void 0;
813
+ }
814
+ return typeof err === "string" ? err : void 0;
815
+ }
816
+ function sanitizeExternalFailureCode(value) {
817
+ if (typeof value !== "string") return void 0;
818
+ const trimmed = value.trim();
819
+ return /^[a-z0-9_.-]{1,64}$/i.test(trimmed) ? trimmed : void 0;
820
+ }
821
+ function getProviderErrorCode(err) {
822
+ if (typeof err !== "object" || err === null) return void 0;
823
+ const record = err;
824
+ return sanitizeExternalFailureCode(record.code) ?? sanitizeExternalFailureCode(record.type) ?? sanitizeExternalFailureCode(record.name);
825
+ }
826
+ function getProviderDeclineCode(err) {
827
+ if (typeof err !== "object" || err === null) return void 0;
828
+ return sanitizeExternalFailureCode(err.decline_code);
829
+ }
830
+ function isProviderDecline(err) {
831
+ if (typeof err !== "object" || err === null) return false;
832
+ const type = sanitizeExternalFailureCode(err.type)?.toLowerCase();
833
+ return type === "card_error" || getProviderDeclineCode(err) !== void 0;
834
+ }
835
+ function isPopupBlockedError(err) {
836
+ const message = getProviderErrorMessage(err)?.toLowerCase() ?? "";
837
+ const code = getProviderErrorCode(err)?.toLowerCase() ?? "";
838
+ const signal = `${code} ${message}`;
839
+ return signal.includes("popup") && signal.includes("block");
840
+ }
841
+
842
+ // src/direct-paypal-button.tsx
805
843
  var import_jsx_runtime6 = require("react/jsx-runtime");
806
844
  var DEFAULT_BUTTON_HEIGHT = 45;
845
+ var DIRECT_PAYPAL_RECOVERY_MESSAGE = "We couldn't open PayPal. Try again or choose another payment method.";
846
+ var DIRECT_PAYPAL_RECOVERY_ACTION_STYLE = {
847
+ width: "100%",
848
+ minHeight: DEFAULT_BUTTON_HEIGHT,
849
+ margin: "0 0 0.5rem",
850
+ padding: "0.75rem 0.875rem",
851
+ border: "1px solid #2563eb",
852
+ borderRadius: 8,
853
+ background: "#eff6ff",
854
+ color: "#1d4ed8",
855
+ fontSize: "0.95rem",
856
+ fontWeight: 700,
857
+ cursor: "pointer"
858
+ };
859
+ function buildDirectPayPalRecoveryMessage(popupBlocked) {
860
+ return popupBlocked ? `${DIRECT_PAYPAL_RECOVERY_MESSAGE} Allow pop-ups for this site, then try again.` : DIRECT_PAYPAL_RECOVERY_MESSAGE;
861
+ }
807
862
  function DirectPayPalButton({
808
863
  sessionId,
809
864
  nonce,
@@ -817,6 +872,7 @@ function DirectPayPalButton({
817
872
  onComplete,
818
873
  onErrorChange,
819
874
  onDecline,
875
+ onTechnicalFailure,
820
876
  isProcessing = false,
821
877
  onLoadStateChange,
822
878
  onButtonClick,
@@ -826,7 +882,12 @@ function DirectPayPalButton({
826
882
  debug = false
827
883
  }) {
828
884
  const containerRef = (0, import_react8.useRef)(null);
885
+ const focusTargetRef = (0, import_react8.useRef)(null);
886
+ const pendingProviderFocusRef = (0, import_react8.useRef)(false);
829
887
  const [ready, setReady] = (0, import_react8.useState)(false);
888
+ const [renderGeneration, setRenderGeneration] = (0, import_react8.useState)(0);
889
+ const activeRenderGenerationRef = (0, import_react8.useRef)(0);
890
+ const [showRetryFocusTarget, setShowRetryFocusTarget] = (0, import_react8.useState)(false);
830
891
  const [failed, setFailed] = (0, import_react8.useState)(false);
831
892
  const [submitting, setSubmitting] = (0, import_react8.useState)(false);
832
893
  const baseUrl = (0, import_react8.useMemo)(() => billingApiUrl.replace(/\/+$/, ""), [billingApiUrl]);
@@ -839,6 +900,7 @@ function DirectPayPalButton({
839
900
  const onCompleteRef = (0, import_react8.useRef)(onComplete);
840
901
  const onErrorChangeRef = (0, import_react8.useRef)(onErrorChange);
841
902
  const onDeclineRef = (0, import_react8.useRef)(onDecline);
903
+ const onTechnicalFailureRef = (0, import_react8.useRef)(onTechnicalFailure);
842
904
  const onButtonClickRef = (0, import_react8.useRef)(onButtonClick);
843
905
  const onLoadStateChangeRef = (0, import_react8.useRef)(onLoadStateChange);
844
906
  const runBeforeButtonClickRef = (0, import_react8.useRef)(runBeforeButtonClick);
@@ -846,6 +908,11 @@ function DirectPayPalButton({
846
908
  const emailRef = (0, import_react8.useRef)(email);
847
909
  const nonceRef = (0, import_react8.useRef)(nonce);
848
910
  const beforeClickRef = (0, import_react8.useRef)(null);
911
+ const attemptGenerationRef = (0, import_react8.useRef)(0);
912
+ const attemptRef = (0, import_react8.useRef)(null);
913
+ const invalidatedAttemptGenerationRef = (0, import_react8.useRef)(null);
914
+ const attemptContextBySurfaceRef = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
915
+ const approvalContextByTokenRef = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
849
916
  (0, import_react8.useEffect)(() => {
850
917
  onTokenizedBodyRef.current = onTokenizedBody;
851
918
  }, [onTokenizedBody]);
@@ -858,6 +925,9 @@ function DirectPayPalButton({
858
925
  (0, import_react8.useEffect)(() => {
859
926
  onDeclineRef.current = onDecline;
860
927
  }, [onDecline]);
928
+ (0, import_react8.useEffect)(() => {
929
+ onTechnicalFailureRef.current = onTechnicalFailure;
930
+ }, [onTechnicalFailure]);
861
931
  (0, import_react8.useEffect)(() => {
862
932
  onButtonClickRef.current = onButtonClick;
863
933
  }, [onButtonClick]);
@@ -876,6 +946,129 @@ function DirectPayPalButton({
876
946
  (0, import_react8.useEffect)(() => {
877
947
  nonceRef.current = nonce;
878
948
  }, [nonce]);
949
+ (0, import_react8.useEffect)(() => {
950
+ activeRenderGenerationRef.current = renderGeneration;
951
+ }, [renderGeneration]);
952
+ (0, import_react8.useEffect)(() => {
953
+ if (showRetryFocusTarget) focusTargetRef.current?.focus();
954
+ }, [showRetryFocusTarget, renderGeneration]);
955
+ const focusRetryTarget = (0, import_react8.useCallback)(() => {
956
+ window.setTimeout(() => {
957
+ focusTargetRef.current?.focus();
958
+ }, 0);
959
+ }, []);
960
+ const remountPayPalButtons = (0, import_react8.useCallback)(() => {
961
+ setReady(false);
962
+ setRenderGeneration((current) => current + 1);
963
+ }, []);
964
+ const focusPayPalSurface = (0, import_react8.useCallback)(() => {
965
+ setShowRetryFocusTarget(false);
966
+ const target = containerRef.current?.querySelector(
967
+ 'iframe, button, [tabindex]:not([tabindex="-1"])'
968
+ );
969
+ (target ?? containerRef.current)?.focus?.();
970
+ }, []);
971
+ (0, import_react8.useEffect)(() => {
972
+ if (!ready || !pendingProviderFocusRef.current) return;
973
+ pendingProviderFocusRef.current = false;
974
+ focusPayPalSurface();
975
+ }, [focusPayPalSurface, ready, renderGeneration]);
976
+ const notifyTechnicalFailure = (0, import_react8.useCallback)((err, options) => {
977
+ const handler = onTechnicalFailureRef.current;
978
+ if (handler) {
979
+ handler("paypal", err, options);
980
+ return;
981
+ }
982
+ onErrorChangeRef.current?.(
983
+ buildDirectPayPalRecoveryMessage(options?.popupBlocked ?? isPopupBlockedError(err))
984
+ );
985
+ }, []);
986
+ const clearAttemptTimer = () => {
987
+ if (attemptRef.current?.timer) {
988
+ clearTimeout(attemptRef.current.timer);
989
+ attemptRef.current.timer = null;
990
+ }
991
+ };
992
+ const startAttempt = () => {
993
+ clearAttemptTimer();
994
+ const generation = attemptGenerationRef.current + 1;
995
+ attemptGenerationRef.current = generation;
996
+ invalidatedAttemptGenerationRef.current = null;
997
+ attemptRef.current = { generation, timer: null, yieldedControl: false };
998
+ setShowRetryFocusTarget(false);
999
+ return generation;
1000
+ };
1001
+ const finishAttempt = (generation) => {
1002
+ if (generation !== void 0 && attemptRef.current?.generation !== generation) return false;
1003
+ clearAttemptTimer();
1004
+ attemptRef.current = null;
1005
+ invalidatedAttemptGenerationRef.current = null;
1006
+ return true;
1007
+ };
1008
+ const invalidateAttempt = (options) => {
1009
+ const generation = options?.generation ?? attemptRef.current?.generation ?? attemptGenerationRef.current;
1010
+ if (!options?.generation || attemptRef.current?.generation === generation) {
1011
+ clearAttemptTimer();
1012
+ attemptRef.current = null;
1013
+ }
1014
+ invalidatedAttemptGenerationRef.current = generation;
1015
+ if (options?.showRetry) setShowRetryFocusTarget(true);
1016
+ if (options?.remount) remountPayPalButtons();
1017
+ if (options?.focus !== false) focusRetryTarget();
1018
+ };
1019
+ const isAttemptActive = (generation) => attemptRef.current?.generation === generation && invalidatedAttemptGenerationRef.current !== generation;
1020
+ const armMissingCallbackRecovery = (attempt) => {
1021
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
1022
+ clearAttemptTimer();
1023
+ attempt.timer = setTimeout(() => {
1024
+ if (attemptRef.current?.generation !== attempt.generation) return;
1025
+ attemptRef.current = null;
1026
+ invalidatedAttemptGenerationRef.current = attempt.generation;
1027
+ notifyTechnicalFailure(
1028
+ new Error("External payment method returned without a terminal callback."),
1029
+ { code: "external_method_missing_terminal_callback" }
1030
+ );
1031
+ setShowRetryFocusTarget(true);
1032
+ remountPayPalButtons();
1033
+ focusRetryTarget();
1034
+ }, EXTERNAL_METHOD_CALLBACK_GRACE_MS);
1035
+ };
1036
+ const scheduleMissingCallbackRecovery = () => {
1037
+ const attempt = attemptRef.current;
1038
+ if (!attempt?.yieldedControl) return;
1039
+ armMissingCallbackRecovery(attempt);
1040
+ };
1041
+ const markAttemptYieldedControl = () => {
1042
+ const attempt = attemptRef.current;
1043
+ if (!attempt) return;
1044
+ attempt.yieldedControl = true;
1045
+ clearAttemptTimer();
1046
+ };
1047
+ const registerApprovalContext = (token) => {
1048
+ const context = beforeClickRef.current;
1049
+ if (context) {
1050
+ approvalContextByTokenRef.current.set(token, context);
1051
+ }
1052
+ return token;
1053
+ };
1054
+ (0, import_react8.useEffect)(() => {
1055
+ const handleVisibilityChange = () => {
1056
+ if (document.visibilityState === "visible") {
1057
+ scheduleMissingCallbackRecovery();
1058
+ } else {
1059
+ markAttemptYieldedControl();
1060
+ }
1061
+ };
1062
+ document.addEventListener("visibilitychange", handleVisibilityChange);
1063
+ window.addEventListener("focus", scheduleMissingCallbackRecovery);
1064
+ window.addEventListener("blur", markAttemptYieldedControl);
1065
+ return () => {
1066
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
1067
+ window.removeEventListener("focus", scheduleMissingCallbackRecovery);
1068
+ window.removeEventListener("blur", markAttemptYieldedControl);
1069
+ clearAttemptTimer();
1070
+ };
1071
+ }, []);
879
1072
  (0, import_react8.useEffect)(() => {
880
1073
  onLoadStateChangeRef.current?.(ready && !failed);
881
1074
  }, [ready, failed]);
@@ -1012,13 +1205,13 @@ function DirectPayPalButton({
1012
1205
  console.error("[FloPay] DirectPayPal load/render failure:", message);
1013
1206
  };
1014
1207
  setFailed(false);
1015
- const dispatchTokenizedBody = async (body) => {
1016
- const prepared = beforeClickRef.current;
1208
+ const dispatchTokenizedBody = async (body, prepared = beforeClickRef.current) => {
1017
1209
  const effectiveSessionId = prepared?.sessionId ?? sessionId;
1018
1210
  if (onTokenizedBodyRef.current) {
1019
1211
  onTokenizedBodyRef.current(body, {
1020
1212
  sessionId: effectiveSessionId,
1021
- accountPatch: prepared?.accountPatch
1213
+ accountPatch: prepared?.accountPatch,
1214
+ nonce: prepared?.nonce
1022
1215
  });
1023
1216
  return;
1024
1217
  }
@@ -1031,7 +1224,7 @@ function DirectPayPalButton({
1031
1224
  effectiveUserId,
1032
1225
  {
1033
1226
  sessionId: effectiveSessionId,
1034
- nonce: nonceRef.current,
1227
+ nonce: prepared?.nonce ?? nonceRef.current,
1035
1228
  tokenizedData: body,
1036
1229
  accountData: {
1037
1230
  userId: effectiveUserId,
@@ -1067,7 +1260,7 @@ function DirectPayPalButton({
1067
1260
  const effectiveSessionId = prepared?.sessionId ?? sessionId;
1068
1261
  const effectiveEmail = prepared?.accountPatch?.email ?? emailRef.current;
1069
1262
  const intentHeaders = { "Content-Type": "application/json" };
1070
- const currentNonce = nonceRef.current;
1263
+ const currentNonce = prepared?.nonce ?? nonceRef.current;
1071
1264
  if (currentNonce) intentHeaders["x-checkout-session-token"] = currentNonce;
1072
1265
  const response = await fetch(`${baseUrl}/v1/checkouts/payments/intents`, {
1073
1266
  method: "POST",
@@ -1089,6 +1282,7 @@ function DirectPayPalButton({
1089
1282
  }
1090
1283
  return id;
1091
1284
  };
1285
+ const effectRenderGeneration = renderGeneration;
1092
1286
  appendDebug("loadScript:scheduled (deferred 1 tick)");
1093
1287
  let loadPromise = null;
1094
1288
  const startTimer = setTimeout(() => {
@@ -1121,10 +1315,16 @@ function DirectPayPalButton({
1121
1315
  return;
1122
1316
  }
1123
1317
  const handleApprove = async (data) => {
1318
+ if (cancelled || activeRenderGenerationRef.current !== effectRenderGeneration) return;
1319
+ const token = data.subscriptionID ?? data.orderID ?? "";
1320
+ const context = token ? approvalContextByTokenRef.current.get(token) : attemptContextBySurfaceRef.current.get(effectRenderGeneration);
1321
+ if (!context || !isAttemptActive(context.generation)) return;
1322
+ if (!finishAttempt(context.generation)) return;
1323
+ approvalContextByTokenRef.current.delete(token);
1324
+ attemptContextBySurfaceRef.current.delete(context.surfaceKey);
1124
1325
  try {
1125
1326
  setSubmitting(true);
1126
1327
  onErrorChangeRef.current?.(null);
1127
- const token = data.subscriptionID ?? data.orderID ?? "";
1128
1328
  if (!token) {
1129
1329
  throw new import_shared4.FloPayError(
1130
1330
  "PayPal did not return an approval token.",
@@ -1135,7 +1335,7 @@ function DirectPayPalButton({
1135
1335
  await dispatchTokenizedBody({
1136
1336
  id: token,
1137
1337
  isPaypal: true
1138
- });
1338
+ }, context);
1139
1339
  } catch (err) {
1140
1340
  forwardError(err instanceof Error ? err.message : "PayPal capture failed.");
1141
1341
  } finally {
@@ -1151,47 +1351,70 @@ function DirectPayPalButton({
1151
1351
  // matching the Stripe-rendered PayPal flow.
1152
1352
  onClick: async (_data, actions) => {
1153
1353
  const runner = runBeforeButtonClickRef.current;
1354
+ let prepared = {};
1154
1355
  if (runner) {
1155
1356
  try {
1156
1357
  const beforeClick = await runner("paypal");
1157
1358
  if (!beforeClick.proceed) {
1158
1359
  beforeClickRef.current = null;
1360
+ attemptContextBySurfaceRef.current.delete(effectRenderGeneration);
1159
1361
  await actions.reject();
1160
1362
  return;
1161
1363
  }
1162
- beforeClickRef.current = {
1364
+ prepared = {
1163
1365
  sessionId: beforeClick.sessionId,
1164
- accountPatch: beforeClick.accountPatch
1366
+ accountPatch: beforeClick.accountPatch,
1367
+ nonce: beforeClick.nonce
1165
1368
  };
1166
1369
  } catch (err) {
1167
1370
  appendDebug(`onClick:runBeforeButtonClick rejected msg=${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`);
1168
1371
  beforeClickRef.current = null;
1372
+ attemptContextBySurfaceRef.current.delete(effectRenderGeneration);
1169
1373
  await actions.reject();
1170
1374
  return;
1171
1375
  }
1172
- } else {
1173
- beforeClickRef.current = null;
1174
1376
  }
1175
1377
  onButtonClickRef.current?.("paypal");
1378
+ const generation = startAttempt();
1379
+ const context = {
1380
+ generation,
1381
+ surfaceKey: effectRenderGeneration,
1382
+ ...prepared
1383
+ };
1384
+ beforeClickRef.current = context;
1385
+ attemptContextBySurfaceRef.current.set(effectRenderGeneration, context);
1176
1386
  await actions.resolve();
1177
1387
  },
1178
1388
  // When the SDK is already holding an order/subscription id from a
1179
1389
  // prior backend round-trip (the `paypal_direct_required` retry
1180
1390
  // path), feed it straight to PayPal instead of creating a new one.
1181
1391
  // Otherwise fall back to the normal create-intent call.
1182
- createOrder: isSubscription ? void 0 : existingOrderId ? () => Promise.resolve(existingOrderId) : () => createPaypalIntent("Failed to create PayPal order."),
1183
- createSubscription: isSubscription ? existingOrderId ? () => Promise.resolve(existingOrderId) : () => createPaypalIntent("Failed to create PayPal subscription.") : void 0,
1392
+ createOrder: isSubscription ? void 0 : existingOrderId ? () => Promise.resolve(registerApprovalContext(existingOrderId)) : () => createPaypalIntent("Failed to create PayPal order.").then(registerApprovalContext),
1393
+ createSubscription: isSubscription ? existingOrderId ? () => Promise.resolve(registerApprovalContext(existingOrderId)) : () => createPaypalIntent("Failed to create PayPal subscription.").then(registerApprovalContext) : void 0,
1184
1394
  onApprove: handleApprove,
1185
1395
  onCancel: () => {
1396
+ const context = attemptContextBySurfaceRef.current.get(effectRenderGeneration) ?? beforeClickRef.current;
1397
+ if (context) attemptContextBySurfaceRef.current.delete(context.surfaceKey);
1186
1398
  beforeClickRef.current = null;
1187
- onDeclineRef.current?.(buildDeclineEvent("paypal", "PayPal checkout was cancelled."));
1399
+ pendingProviderFocusRef.current = true;
1400
+ invalidateAttempt({ generation: context?.generation, remount: true, focus: false });
1188
1401
  },
1189
1402
  onError: (err) => {
1403
+ if (cancelled || activeRenderGenerationRef.current !== effectRenderGeneration) return;
1190
1404
  const message = err instanceof Error ? err.message : "PayPal failed to render.";
1191
1405
  appendDebug(`onError rendered=${rendered} msg=${message.slice(0, 120)}`);
1192
1406
  if (rendered) {
1193
- forwardError(message);
1194
- onDeclineRef.current?.(buildDeclineEvent("paypal", message));
1407
+ const context = attemptContextBySurfaceRef.current.get(effectRenderGeneration) ?? beforeClickRef.current;
1408
+ if (context && !isAttemptActive(context.generation)) return;
1409
+ if (!context && invalidatedAttemptGenerationRef.current === attemptGenerationRef.current) return;
1410
+ if (isZoidLifecycleMessage(message)) {
1411
+ finishAttempt(context?.generation);
1412
+ return;
1413
+ }
1414
+ if (context) attemptContextBySurfaceRef.current.delete(context.surfaceKey);
1415
+ beforeClickRef.current = null;
1416
+ invalidateAttempt({ generation: context?.generation, remount: true, showRetry: true, focus: true });
1417
+ notifyTechnicalFailure(err, { code: "paypal_runtime_failed" });
1195
1418
  return;
1196
1419
  }
1197
1420
  markRenderFailed(message);
@@ -1291,7 +1514,7 @@ function DirectPayPalButton({
1291
1514
  });
1292
1515
  }
1293
1516
  };
1294
- }, [baseUrl, clientId, currency, environment, isSubscription, sessionId, existingOrderId]);
1517
+ }, [baseUrl, clientId, currency, environment, isSubscription, sessionId, existingOrderId, renderGeneration]);
1295
1518
  const debugPanel = debug ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
1296
1519
  "pre",
1297
1520
  {
@@ -1346,15 +1569,29 @@ ${debugLines.join("\n")}`
1346
1569
  {
1347
1570
  ref: containerRef,
1348
1571
  "data-testid": "flopay-direct-paypal-container",
1572
+ tabIndex: -1,
1573
+ "aria-label": "PayPal payment method",
1349
1574
  style: {
1350
1575
  minHeight: DEFAULT_BUTTON_HEIGHT,
1351
1576
  display: "flex",
1352
1577
  opacity: ready ? 1 : 0
1353
1578
  },
1354
1579
  "aria-busy": submitting || isProcessing
1355
- }
1580
+ },
1581
+ renderGeneration
1356
1582
  )
1357
- ] })
1583
+ ] }),
1584
+ showRetryFocusTarget && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
1585
+ "button",
1586
+ {
1587
+ ref: focusTargetRef,
1588
+ type: "button",
1589
+ "data-testid": "flopay-direct-paypal-focus-target",
1590
+ onClick: focusPayPalSurface,
1591
+ style: DIRECT_PAYPAL_RECOVERY_ACTION_STYLE,
1592
+ children: "Try PayPal again"
1593
+ }
1594
+ )
1358
1595
  ] })
1359
1596
  );
1360
1597
  }
@@ -1438,6 +1675,19 @@ var BUTTONS_PANEL_HIDDEN_STYLE = {
1438
1675
  height: 0,
1439
1676
  overflow: "hidden"
1440
1677
  };
1678
+ var EXTERNAL_METHOD_RECOVERY_BUTTON_STYLE = {
1679
+ width: "100%",
1680
+ minHeight: DEFAULT_BUTTONS_LAYOUT_BUTTON_HEIGHT,
1681
+ margin: "0 0 0.5rem",
1682
+ padding: "0.75rem 0.875rem",
1683
+ border: "1px solid #2563eb",
1684
+ borderRadius: 8,
1685
+ background: "#eff6ff",
1686
+ color: "#1d4ed8",
1687
+ fontSize: "0.95rem",
1688
+ fontWeight: 700,
1689
+ cursor: "pointer"
1690
+ };
1441
1691
  function getButtonMethodLabel(method) {
1442
1692
  switch (method) {
1443
1693
  case "paypal":
@@ -1450,6 +1700,118 @@ function getButtonMethodLabel(method) {
1450
1700
  return "Card";
1451
1701
  }
1452
1702
  }
1703
+ function checkoutButtonMethodFromProviderMethod(method) {
1704
+ if (method === "paypal") return "paypal";
1705
+ if (method === "apple_pay") return "apple_pay";
1706
+ return "google_pay";
1707
+ }
1708
+ function getExternalMethodDisplayName(method) {
1709
+ return method === "paypal" ? "PayPal" : (0, import_shared5.getStripeMethodDisplayName)(method);
1710
+ }
1711
+ function buildExternalMethodRecoveryMessage(method, popupBlocked) {
1712
+ const base = `We couldn't open ${getExternalMethodDisplayName(method)}. Try again or choose another payment method.`;
1713
+ return popupBlocked ? `${base} Allow pop-ups for this site, then try again.` : base;
1714
+ }
1715
+ function useExternalAttemptReconciliation(onMissingTerminal) {
1716
+ const generationRef = (0, import_react9.useRef)(0);
1717
+ const invalidatedGenerationRef = (0, import_react9.useRef)(null);
1718
+ const attemptRef = (0, import_react9.useRef)(null);
1719
+ const clearAttemptTimer = (0, import_react9.useCallback)((targetAttempt = attemptRef.current) => {
1720
+ const attempt = targetAttempt;
1721
+ if (attempt?.timer) {
1722
+ clearTimeout(attempt.timer);
1723
+ attempt.timer = null;
1724
+ }
1725
+ }, []);
1726
+ const armAttemptTimer = (0, import_react9.useCallback)((attempt, delayMs) => {
1727
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
1728
+ clearAttemptTimer(attempt);
1729
+ attempt.timer = setTimeout(() => {
1730
+ attempt.timer = null;
1731
+ if (attemptRef.current?.generation !== attempt.generation) return;
1732
+ attemptRef.current = null;
1733
+ invalidatedGenerationRef.current = attempt.generation;
1734
+ onMissingTerminal(
1735
+ attempt.method,
1736
+ new Error("External payment method returned without a terminal callback."),
1737
+ { code: "external_method_missing_terminal_callback" }
1738
+ );
1739
+ }, delayMs);
1740
+ }, [clearAttemptTimer, onMissingTerminal]);
1741
+ const armRecoveryTimer = (0, import_react9.useCallback)((attempt) => {
1742
+ if (!attempt.yieldedControl) return;
1743
+ armAttemptTimer(attempt, EXTERNAL_METHOD_CALLBACK_GRACE_MS);
1744
+ }, [armAttemptTimer]);
1745
+ const startAttempt = (0, import_react9.useCallback)((method) => {
1746
+ clearAttemptTimer();
1747
+ const generation = generationRef.current + 1;
1748
+ generationRef.current = generation;
1749
+ invalidatedGenerationRef.current = null;
1750
+ const attempt = { generation, method, timer: null, yieldedControl: false };
1751
+ attemptRef.current = attempt;
1752
+ return generation;
1753
+ }, [clearAttemptTimer]);
1754
+ const finishAttempt = (0, import_react9.useCallback)((generation) => {
1755
+ const attempt = attemptRef.current;
1756
+ if (typeof generation === "number" && attempt?.generation !== generation) return false;
1757
+ clearAttemptTimer(attempt);
1758
+ attemptRef.current = null;
1759
+ if (typeof generation !== "number" || invalidatedGenerationRef.current === generation) {
1760
+ invalidatedGenerationRef.current = null;
1761
+ }
1762
+ return true;
1763
+ }, [clearAttemptTimer]);
1764
+ const invalidateAttempt = (0, import_react9.useCallback)((generation) => {
1765
+ const attempt = attemptRef.current;
1766
+ const targetGeneration = generation ?? attempt?.generation ?? generationRef.current;
1767
+ if (!generation || attempt?.generation === generation) {
1768
+ clearAttemptTimer(attempt);
1769
+ attemptRef.current = null;
1770
+ }
1771
+ invalidatedGenerationRef.current = targetGeneration;
1772
+ }, [clearAttemptTimer]);
1773
+ const isAttemptInvalidated = (0, import_react9.useCallback)(
1774
+ (generation) => invalidatedGenerationRef.current === (generation ?? generationRef.current),
1775
+ []
1776
+ );
1777
+ const isAttemptCurrent = (0, import_react9.useCallback)(
1778
+ (generation) => attemptRef.current?.generation === generation && invalidatedGenerationRef.current !== generation,
1779
+ []
1780
+ );
1781
+ const scheduleRecoveryIfReturned = (0, import_react9.useCallback)(() => {
1782
+ const attempt = attemptRef.current;
1783
+ if (!attempt) return;
1784
+ armRecoveryTimer(attempt);
1785
+ }, [armRecoveryTimer]);
1786
+ const markAttemptYieldedControl = (0, import_react9.useCallback)(() => {
1787
+ const attempt = attemptRef.current;
1788
+ if (!attempt) return;
1789
+ attempt.yieldedControl = true;
1790
+ clearAttemptTimer(attempt);
1791
+ }, [clearAttemptTimer]);
1792
+ (0, import_react9.useEffect)(() => {
1793
+ const handleVisibilityChange = () => {
1794
+ if (document.visibilityState === "visible") {
1795
+ scheduleRecoveryIfReturned();
1796
+ } else {
1797
+ markAttemptYieldedControl();
1798
+ }
1799
+ };
1800
+ const handleBlur = () => {
1801
+ markAttemptYieldedControl();
1802
+ };
1803
+ document.addEventListener("visibilitychange", handleVisibilityChange);
1804
+ window.addEventListener("blur", handleBlur);
1805
+ window.addEventListener("focus", scheduleRecoveryIfReturned);
1806
+ return () => {
1807
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
1808
+ window.removeEventListener("blur", handleBlur);
1809
+ window.removeEventListener("focus", scheduleRecoveryIfReturned);
1810
+ clearAttemptTimer();
1811
+ };
1812
+ }, [clearAttemptTimer, markAttemptYieldedControl, scheduleRecoveryIfReturned]);
1813
+ return { startAttempt, finishAttempt, invalidateAttempt, isAttemptInvalidated, isAttemptCurrent };
1814
+ }
1453
1815
  function malformedPostcodeMessage(country) {
1454
1816
  const example = (0, import_shared5.getPostalCodeExample)(country);
1455
1817
  return `Enter a valid ${(0, import_shared5.getPostalCodeLabel)(country)}${example ? ` (e.g. ${example})` : ""}`;
@@ -1544,6 +1906,7 @@ function PayPalButtonInner({
1544
1906
  isProcessing = false,
1545
1907
  onButtonClick,
1546
1908
  onDecline,
1909
+ onTechnicalFailure,
1547
1910
  runBeforeButtonClick,
1548
1911
  onLoadStateChange,
1549
1912
  placeholderBorderRadius
@@ -1556,8 +1919,55 @@ function PayPalButtonInner({
1556
1919
  }, [loadState, onLoadStateChange]);
1557
1920
  const [submitting, setSubmitting] = (0, import_react9.useState)(false);
1558
1921
  const paypalResumeAttempted = (0, import_react9.useRef)(false);
1559
- const beforeClickRef = (0, import_react9.useRef)(null);
1922
+ const focusTargetRef = (0, import_react9.useRef)(null);
1923
+ const recoveryActionRef = (0, import_react9.useRef)(null);
1924
+ const [surfaceKey, setSurfaceKey] = (0, import_react9.useState)(0);
1925
+ const [showRecoveryAction, setShowRecoveryAction] = (0, import_react9.useState)(false);
1926
+ const pendingProviderFocusRef = (0, import_react9.useRef)(false);
1927
+ const attemptContextBySurfaceRef = (0, import_react9.useRef)(/* @__PURE__ */ new Map());
1560
1928
  const baseUrl = billingApiUrl.replace(/\/+$/, "");
1929
+ const {
1930
+ startAttempt,
1931
+ finishAttempt,
1932
+ invalidateAttempt,
1933
+ isAttemptInvalidated,
1934
+ isAttemptCurrent
1935
+ } = useExternalAttemptReconciliation((method, err, options) => {
1936
+ onTechnicalFailure?.(method, err, {
1937
+ ...options,
1938
+ popupBlocked: options?.popupBlocked ?? isPopupBlockedError(err)
1939
+ });
1940
+ setShowRecoveryAction(true);
1941
+ setSurfaceKey((key) => key + 1);
1942
+ });
1943
+ (0, import_react9.useEffect)(() => {
1944
+ if (showRecoveryAction) recoveryActionRef.current?.focus();
1945
+ }, [showRecoveryAction, surfaceKey]);
1946
+ const resetSurface = (0, import_react9.useCallback)(() => {
1947
+ setSurfaceKey((key) => key + 1);
1948
+ }, []);
1949
+ const recoverTechnicalFailure = (0, import_react9.useCallback)((err, code, generation) => {
1950
+ invalidateAttempt(generation);
1951
+ onTechnicalFailure?.("paypal", err, { code, popupBlocked: isPopupBlockedError(err) });
1952
+ setShowRecoveryAction(true);
1953
+ resetSurface();
1954
+ }, [invalidateAttempt, onTechnicalFailure, resetSurface]);
1955
+ const focusProviderSurface = (0, import_react9.useCallback)(() => {
1956
+ window.setTimeout(() => {
1957
+ const target = focusTargetRef.current?.querySelector("iframe");
1958
+ (target ?? focusTargetRef.current)?.focus();
1959
+ }, 0);
1960
+ }, []);
1961
+ (0, import_react9.useEffect)(() => {
1962
+ if (!pendingProviderFocusRef.current) return;
1963
+ pendingProviderFocusRef.current = false;
1964
+ focusProviderSurface();
1965
+ }, [focusProviderSurface, surfaceKey]);
1966
+ const handleRecoveryActionClick = (0, import_react9.useCallback)(() => {
1967
+ setShowRecoveryAction(false);
1968
+ onErrorChange?.(null);
1969
+ focusProviderSurface();
1970
+ }, [focusProviderSurface, onErrorChange]);
1561
1971
  (0, import_react9.useEffect)(() => {
1562
1972
  if (!stripe || paypalResumeAttempted.current) return;
1563
1973
  const params = new URLSearchParams(window.location.search);
@@ -1631,22 +2041,33 @@ function PayPalButtonInner({
1631
2041
  }
1632
2042
  const beforeClick = runBeforeButtonClick ? await runBeforeButtonClick("paypal") : { proceed: true };
1633
2043
  if (!beforeClick.proceed) {
1634
- beforeClickRef.current = null;
2044
+ attemptContextBySurfaceRef.current.delete(surfaceKey);
1635
2045
  event.reject();
1636
2046
  return;
1637
2047
  }
1638
- beforeClickRef.current = {
2048
+ const generation = startAttempt("paypal");
2049
+ attemptContextBySurfaceRef.current.set(surfaceKey, {
2050
+ generation,
1639
2051
  accountPatch: beforeClick.accountPatch,
1640
2052
  sessionId: beforeClick.sessionId,
1641
2053
  nonce: beforeClick.nonce
1642
- };
2054
+ });
2055
+ setShowRecoveryAction(false);
1643
2056
  onButtonClick?.("paypal");
1644
2057
  event.resolve();
1645
- }, [isProcessing, onButtonClick, runBeforeButtonClick, submitting]);
2058
+ }, [attemptContextBySurfaceRef, isProcessing, onButtonClick, runBeforeButtonClick, startAttempt, submitting, surfaceKey]);
1646
2059
  const handlePayPalConfirm = (0, import_react9.useCallback)(async (event) => {
1647
2060
  if (!stripe || !elements) return;
1648
- let prepared = beforeClickRef.current;
1649
- beforeClickRef.current = null;
2061
+ const attemptContext = attemptContextBySurfaceRef.current.get(surfaceKey);
2062
+ if (attemptContext && !isAttemptCurrent(attemptContext.generation)) {
2063
+ attemptContextBySurfaceRef.current.delete(surfaceKey);
2064
+ return;
2065
+ }
2066
+ if (!attemptContext && isAttemptInvalidated()) return;
2067
+ if (attemptContext && !finishAttempt(attemptContext.generation)) return;
2068
+ if (!attemptContext) finishAttempt();
2069
+ let prepared = attemptContext ?? null;
2070
+ attemptContextBySurfaceRef.current.delete(surfaceKey);
1650
2071
  if (!prepared && runBeforeButtonClick) {
1651
2072
  const beforeClick = await runBeforeButtonClick("paypal");
1652
2073
  if (!beforeClick.proceed) {
@@ -1671,9 +2092,8 @@ function PayPalButtonInner({
1671
2092
  const createPM = stripe.createPaymentMethod;
1672
2093
  const { error: pmError, paymentMethod } = await createPM({ type: "paypal" });
1673
2094
  if (pmError) {
1674
- const message = pmError.message ?? "PayPal payment failed.";
1675
- onErrorChange?.(message);
1676
- event.paymentFailed({ reason: "fail", message });
2095
+ recoverTechnicalFailure(pmError, "stripe_paypal_create_payment_method_failed", attemptContext?.generation);
2096
+ event.paymentFailed({ reason: "fail" });
1677
2097
  return;
1678
2098
  }
1679
2099
  const intentHeaders = { "Content-Type": "application/json" };
@@ -1732,11 +2152,16 @@ function PayPalButtonInner({
1732
2152
  } catch {
1733
2153
  }
1734
2154
  }
1735
- const message = confirmError.message ?? "PayPal payment failed.";
1736
- onErrorChange?.(message);
1737
- onDecline?.(buildDeclineEvent("paypal", message, {
1738
- code: confirmError.code
1739
- }));
2155
+ if (isProviderDecline(confirmError)) {
2156
+ const message = confirmError.message ?? "Your payment was declined.";
2157
+ onErrorChange?.(message);
2158
+ onDecline?.(buildDeclineEvent("paypal", message, {
2159
+ code: getProviderErrorCode(confirmError),
2160
+ declineCode: getProviderDeclineCode(confirmError)
2161
+ }));
2162
+ } else {
2163
+ recoverTechnicalFailure(confirmError, "stripe_paypal_confirm_failed", attemptContext?.generation);
2164
+ }
1740
2165
  return;
1741
2166
  }
1742
2167
  if (typeof window !== "undefined") {
@@ -1758,11 +2183,11 @@ function PayPalButtonInner({
1758
2183
  });
1759
2184
  return;
1760
2185
  } catch (err) {
1761
- onErrorChange?.(err instanceof Error ? err.message : "PayPal payment failed. Please try again.");
2186
+ recoverTechnicalFailure(err, "stripe_paypal_failed", attemptContext?.generation);
1762
2187
  } finally {
1763
2188
  setSubmitting(false);
1764
2189
  }
1765
- }, [stripe, elements, sessionId, nonce, email, baseUrl, onTokenizedBody, onErrorChange, onDecline, runBeforeButtonClick]);
2190
+ }, [stripe, elements, sessionId, nonce, email, baseUrl, onTokenizedBody, onErrorChange, onDecline, runBeforeButtonClick, recoverTechnicalFailure, finishAttempt, isAttemptCurrent, isAttemptInvalidated, surfaceKey]);
1766
2191
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(import_jsx_runtime7.Fragment, { children: [
1767
2192
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1768
2193
  ExpressCheckoutReadySwap,
@@ -1770,29 +2195,57 @@ function PayPalButtonInner({
1770
2195
  state: loadState,
1771
2196
  placeholderTestId: "flopay-paypal-placeholder",
1772
2197
  borderRadius: placeholderBorderRadius,
1773
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1774
- import_react_stripe_js.ExpressCheckoutElement,
2198
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
2199
+ "div",
1775
2200
  {
1776
- onReady: (event) => setLoadState(resolveExpressCheckoutLoadState(event, ["paypal"])),
1777
- onLoadError: () => setLoadState("load_error"),
1778
- onClick: handlePayPalClick,
1779
- onConfirm: handlePayPalConfirm,
1780
- onCancel: () => {
1781
- beforeClickRef.current = null;
1782
- onDecline?.(buildDeclineEvent("paypal", "PayPal checkout was cancelled."));
1783
- },
1784
- options: {
1785
- buttonType: { paypal: "paypal" },
1786
- billingAddressRequired: false,
1787
- phoneNumberRequired: false,
1788
- shippingAddressRequired: false,
1789
- paymentMethods: {
1790
- applePay: "never",
1791
- googlePay: "never",
1792
- paypal: "auto",
1793
- link: "never"
1794
- }
1795
- }
2201
+ ref: focusTargetRef,
2202
+ tabIndex: -1,
2203
+ "data-testid": "flopay-paypal-focus-target",
2204
+ "aria-label": "PayPal payment method",
2205
+ style: { borderRadius: 8, outlineOffset: 4 },
2206
+ children: [
2207
+ showRecoveryAction && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
2208
+ "button",
2209
+ {
2210
+ ref: recoveryActionRef,
2211
+ type: "button",
2212
+ "data-testid": "flopay-paypal-retry-button",
2213
+ onClick: handleRecoveryActionClick,
2214
+ style: EXTERNAL_METHOD_RECOVERY_BUTTON_STYLE,
2215
+ children: "Try PayPal again"
2216
+ }
2217
+ ),
2218
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
2219
+ import_react_stripe_js.ExpressCheckoutElement,
2220
+ {
2221
+ onReady: (event) => setLoadState(resolveExpressCheckoutLoadState(event, ["paypal"])),
2222
+ onLoadError: () => setLoadState("load_error"),
2223
+ onClick: handlePayPalClick,
2224
+ onConfirm: handlePayPalConfirm,
2225
+ onCancel: () => {
2226
+ const attemptContext = attemptContextBySurfaceRef.current.get(surfaceKey);
2227
+ attemptContextBySurfaceRef.current.delete(surfaceKey);
2228
+ invalidateAttempt(attemptContext?.generation);
2229
+ setShowRecoveryAction(false);
2230
+ pendingProviderFocusRef.current = true;
2231
+ resetSurface();
2232
+ },
2233
+ options: {
2234
+ buttonType: { paypal: "paypal" },
2235
+ billingAddressRequired: false,
2236
+ phoneNumberRequired: false,
2237
+ shippingAddressRequired: false,
2238
+ paymentMethods: {
2239
+ applePay: "never",
2240
+ googlePay: "never",
2241
+ paypal: "auto",
2242
+ link: "never"
2243
+ }
2244
+ }
2245
+ },
2246
+ surfaceKey
2247
+ )
2248
+ ]
1796
2249
  }
1797
2250
  )
1798
2251
  }
@@ -1810,6 +2263,7 @@ function WalletButtonInner({
1810
2263
  onErrorChange,
1811
2264
  onButtonClick,
1812
2265
  onDecline,
2266
+ onTechnicalFailure,
1813
2267
  runBeforeButtonClick,
1814
2268
  onLoadStateChange,
1815
2269
  placeholderBorderRadius
@@ -1822,15 +2276,74 @@ function WalletButtonInner({
1822
2276
  }, [loadState, onLoadStateChange]);
1823
2277
  const [submitting, setSubmitting] = (0, import_react9.useState)(false);
1824
2278
  const baseUrl = billingApiUrl.replace(/\/+$/, "");
1825
- const lastWalletMethodRef = (0, import_react9.useRef)("card");
1826
- const beforeClickRef = (0, import_react9.useRef)(null);
2279
+ const focusTargetRef = (0, import_react9.useRef)(null);
2280
+ const recoveryActionRef = (0, import_react9.useRef)(null);
2281
+ const [surfaceKey, setSurfaceKey] = (0, import_react9.useState)(0);
2282
+ const [showRecoveryAction, setShowRecoveryAction] = (0, import_react9.useState)(false);
2283
+ const [recoveryActionMethod, setRecoveryActionMethod] = (0, import_react9.useState)("google_pay");
2284
+ const pendingProviderFocusRef = (0, import_react9.useRef)(false);
2285
+ const lastWalletProviderMethodRef = (0, import_react9.useRef)("google_pay");
2286
+ const lastWalletMethodRef = (0, import_react9.useRef)("google_pay");
2287
+ const attemptContextBySurfaceRef = (0, import_react9.useRef)(/* @__PURE__ */ new Map());
2288
+ const {
2289
+ startAttempt,
2290
+ finishAttempt,
2291
+ invalidateAttempt,
2292
+ isAttemptInvalidated,
2293
+ isAttemptCurrent
2294
+ } = useExternalAttemptReconciliation((method, err, options) => {
2295
+ onTechnicalFailure?.(method, err, {
2296
+ ...options,
2297
+ popupBlocked: options?.popupBlocked ?? isPopupBlockedError(err)
2298
+ });
2299
+ setRecoveryActionMethod(method);
2300
+ setShowRecoveryAction(true);
2301
+ setSurfaceKey((key) => key + 1);
2302
+ });
2303
+ (0, import_react9.useEffect)(() => {
2304
+ if (showRecoveryAction) recoveryActionRef.current?.focus();
2305
+ }, [showRecoveryAction, surfaceKey]);
2306
+ const resetSurface = (0, import_react9.useCallback)(() => {
2307
+ setSurfaceKey((key) => key + 1);
2308
+ }, []);
2309
+ const recoverTechnicalFailure = (0, import_react9.useCallback)((method, err, code, generation) => {
2310
+ invalidateAttempt(generation);
2311
+ onTechnicalFailure?.(method, err, { code, popupBlocked: isPopupBlockedError(err) });
2312
+ setRecoveryActionMethod(method);
2313
+ setShowRecoveryAction(true);
2314
+ resetSurface();
2315
+ }, [invalidateAttempt, onTechnicalFailure, resetSurface]);
2316
+ const focusProviderSurface = (0, import_react9.useCallback)(() => {
2317
+ window.setTimeout(() => {
2318
+ const target = focusTargetRef.current?.querySelector("iframe");
2319
+ (target ?? focusTargetRef.current)?.focus();
2320
+ }, 0);
2321
+ }, []);
2322
+ (0, import_react9.useEffect)(() => {
2323
+ if (!pendingProviderFocusRef.current) return;
2324
+ pendingProviderFocusRef.current = false;
2325
+ focusProviderSurface();
2326
+ }, [focusProviderSurface, surfaceKey]);
2327
+ const handleRecoveryActionClick = (0, import_react9.useCallback)(() => {
2328
+ setShowRecoveryAction(false);
2329
+ onErrorChange?.(null);
2330
+ focusProviderSurface();
2331
+ }, [focusProviderSurface, onErrorChange]);
1827
2332
  const handleWalletConfirm = (0, import_react9.useCallback)(
1828
2333
  async (event) => {
1829
2334
  if (!stripe || !elements) return;
1830
- const walletType = event.expressPaymentType;
1831
- let prepared = beforeClickRef.current;
1832
- beforeClickRef.current = null;
1833
- const buttonMethod = walletType === "apple_pay" ? "apple_pay" : "google_pay";
2335
+ const attemptContext = attemptContextBySurfaceRef.current.get(surfaceKey);
2336
+ if (attemptContext && !isAttemptCurrent(attemptContext.generation)) {
2337
+ attemptContextBySurfaceRef.current.delete(surfaceKey);
2338
+ return;
2339
+ }
2340
+ if (!attemptContext && isAttemptInvalidated()) return;
2341
+ if (attemptContext && !finishAttempt(attemptContext.generation)) return;
2342
+ if (!attemptContext) finishAttempt();
2343
+ const walletType = event.expressPaymentType ?? attemptContext?.method ?? lastWalletProviderMethodRef.current;
2344
+ let prepared = attemptContext ?? null;
2345
+ attemptContextBySurfaceRef.current.delete(surfaceKey);
2346
+ const buttonMethod = checkoutButtonMethodFromProviderMethod(walletType);
1834
2347
  if (!prepared && runBeforeButtonClick) {
1835
2348
  const beforeClick = await runBeforeButtonClick(buttonMethod);
1836
2349
  if (!beforeClick.proceed) {
@@ -1852,12 +2365,12 @@ function WalletButtonInner({
1852
2365
  onErrorChange?.(null);
1853
2366
  const { error: submitError } = await elements.submit();
1854
2367
  if (submitError) {
1855
- onErrorChange?.(submitError.message ?? "Wallet payment failed.");
2368
+ recoverTechnicalFailure(walletType, submitError, "stripe_wallet_submit_failed", attemptContext?.generation);
1856
2369
  return;
1857
2370
  }
1858
2371
  const { error: pmError, paymentMethod } = await stripe.createPaymentMethod({ elements });
1859
2372
  if (pmError || !paymentMethod) {
1860
- onErrorChange?.(pmError?.message ?? "Failed to create payment method.");
2373
+ recoverTechnicalFailure(walletType, pmError ?? new Error("Failed to create payment method."), "stripe_wallet_create_payment_method_failed", attemptContext?.generation);
1861
2374
  return;
1862
2375
  }
1863
2376
  if (!effectiveSessionId || !effectiveEmail) {
@@ -1890,11 +2403,16 @@ function WalletButtonInner({
1890
2403
  if (!intentClientSecret) throw new Error("No client_secret in payment intent response");
1891
2404
  const { error: confirmError, intentId } = (0, import_shared6.isSetupIntentClientSecret)(intentClientSecret) ? await stripe.confirmCardSetup(intentClientSecret, { payment_method: paymentMethod.id }).then((r) => ({ error: r.error, intentId: r.setupIntent?.id })) : await stripe.confirmCardPayment(intentClientSecret, { payment_method: paymentMethod.id }).then((r) => ({ error: r.error, intentId: r.paymentIntent?.id }));
1892
2405
  if (confirmError) {
1893
- const message = confirmError.message ?? "Wallet payment failed.";
1894
- onErrorChange?.(message);
1895
- onDecline?.(buildDeclineEvent(method, message, {
1896
- code: confirmError.code
1897
- }));
2406
+ if (isProviderDecline(confirmError)) {
2407
+ const message = confirmError.message ?? "Your payment was declined.";
2408
+ onErrorChange?.(message);
2409
+ onDecline?.(buildDeclineEvent(method, message, {
2410
+ code: getProviderErrorCode(confirmError),
2411
+ declineCode: getProviderDeclineCode(confirmError)
2412
+ }));
2413
+ } else {
2414
+ recoverTechnicalFailure(walletType, confirmError, "stripe_wallet_confirm_failed", attemptContext?.generation);
2415
+ }
1898
2416
  return;
1899
2417
  }
1900
2418
  onTokenizedBody({
@@ -1907,12 +2425,12 @@ function WalletButtonInner({
1907
2425
  nonce: effectiveNonce
1908
2426
  });
1909
2427
  } catch (err) {
1910
- onErrorChange?.(err instanceof Error ? err.message : "Wallet payment failed. Please try again.");
2428
+ recoverTechnicalFailure(walletType, err, "stripe_wallet_failed", attemptContext?.generation);
1911
2429
  } finally {
1912
2430
  setSubmitting(false);
1913
2431
  }
1914
2432
  },
1915
- [stripe, elements, sessionId, nonce, email, baseUrl, onTokenizedBody, onErrorChange, onDecline, runBeforeButtonClick]
2433
+ [stripe, elements, sessionId, nonce, email, baseUrl, onTokenizedBody, onErrorChange, onDecline, runBeforeButtonClick, recoverTechnicalFailure, finishAttempt, isAttemptCurrent, isAttemptInvalidated, surfaceKey]
1916
2434
  );
1917
2435
  const expressMethodMap = (0, import_react9.useMemo)(() => {
1918
2436
  const allKeys = ["applePay", "googlePay", "paypal", "link", "amazonPay", "klarna"];
@@ -1939,41 +2457,78 @@ function WalletButtonInner({
1939
2457
  state: loadState,
1940
2458
  placeholderTestId: "flopay-wallet-placeholder",
1941
2459
  borderRadius: placeholderBorderRadius,
1942
- children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1943
- import_react_stripe_js.ExpressCheckoutElement,
2460
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
2461
+ "div",
1944
2462
  {
1945
- onReady: (event) => {
1946
- setLoadState(resolveExpressCheckoutLoadState(event, availableMethodKeys));
1947
- },
1948
- onLoadError: (_event) => {
1949
- setLoadState("load_error");
1950
- },
1951
- onClick: async (event) => {
1952
- lastWalletMethodRef.current = event.expressPaymentType === "apple_pay" ? "apple_pay" : "google_pay";
1953
- const beforeClick = runBeforeButtonClick ? await runBeforeButtonClick(lastWalletMethodRef.current) : { proceed: true };
1954
- if (!beforeClick.proceed) {
1955
- beforeClickRef.current = null;
1956
- event.reject();
1957
- return;
1958
- }
1959
- beforeClickRef.current = {
1960
- accountPatch: beforeClick.accountPatch,
1961
- sessionId: beforeClick.sessionId,
1962
- nonce: beforeClick.nonce
1963
- };
1964
- onButtonClick?.(lastWalletMethodRef.current);
1965
- event.resolve();
1966
- },
1967
- onConfirm: handleWalletConfirm,
1968
- onCancel: () => {
1969
- beforeClickRef.current = null;
1970
- onDecline?.(buildDeclineEvent(lastWalletMethodRef.current, "Wallet checkout was cancelled."));
1971
- },
1972
- options: {
1973
- buttonType: { applePay: "plain", googlePay: "plain" },
1974
- paymentMethods: expressMethodMap,
1975
- layout: { maxColumns: 1, overflow: "never" }
1976
- }
2463
+ ref: focusTargetRef,
2464
+ tabIndex: -1,
2465
+ "data-testid": "flopay-wallet-focus-target",
2466
+ "aria-label": "Wallet payment methods",
2467
+ style: { borderRadius: 8, outlineOffset: 4 },
2468
+ children: [
2469
+ showRecoveryAction && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
2470
+ "button",
2471
+ {
2472
+ ref: recoveryActionRef,
2473
+ type: "button",
2474
+ "data-testid": "flopay-wallet-retry-button",
2475
+ onClick: handleRecoveryActionClick,
2476
+ style: EXTERNAL_METHOD_RECOVERY_BUTTON_STYLE,
2477
+ children: [
2478
+ "Try ",
2479
+ getExternalMethodDisplayName(recoveryActionMethod),
2480
+ " again"
2481
+ ]
2482
+ }
2483
+ ),
2484
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
2485
+ import_react_stripe_js.ExpressCheckoutElement,
2486
+ {
2487
+ onReady: (event) => {
2488
+ setLoadState(resolveExpressCheckoutLoadState(event, availableMethodKeys));
2489
+ },
2490
+ onLoadError: (_event) => {
2491
+ setLoadState("load_error");
2492
+ },
2493
+ onClick: async (event) => {
2494
+ lastWalletProviderMethodRef.current = event.expressPaymentType;
2495
+ lastWalletMethodRef.current = checkoutButtonMethodFromProviderMethod(event.expressPaymentType);
2496
+ const beforeClick = runBeforeButtonClick ? await runBeforeButtonClick(lastWalletMethodRef.current) : { proceed: true };
2497
+ if (!beforeClick.proceed) {
2498
+ attemptContextBySurfaceRef.current.delete(surfaceKey);
2499
+ event.reject();
2500
+ return;
2501
+ }
2502
+ const generation = startAttempt(event.expressPaymentType);
2503
+ attemptContextBySurfaceRef.current.set(surfaceKey, {
2504
+ generation,
2505
+ method: event.expressPaymentType,
2506
+ accountPatch: beforeClick.accountPatch,
2507
+ sessionId: beforeClick.sessionId,
2508
+ nonce: beforeClick.nonce
2509
+ });
2510
+ setShowRecoveryAction(false);
2511
+ onButtonClick?.(lastWalletMethodRef.current);
2512
+ event.resolve();
2513
+ },
2514
+ onConfirm: handleWalletConfirm,
2515
+ onCancel: () => {
2516
+ const attemptContext = attemptContextBySurfaceRef.current.get(surfaceKey);
2517
+ attemptContextBySurfaceRef.current.delete(surfaceKey);
2518
+ invalidateAttempt(attemptContext?.generation);
2519
+ setShowRecoveryAction(false);
2520
+ pendingProviderFocusRef.current = true;
2521
+ resetSurface();
2522
+ },
2523
+ options: {
2524
+ buttonType: { applePay: "plain", googlePay: "plain" },
2525
+ paymentMethods: expressMethodMap,
2526
+ layout: { maxColumns: 1, overflow: "never" }
2527
+ }
2528
+ },
2529
+ surfaceKey
2530
+ )
2531
+ ]
1977
2532
  }
1978
2533
  )
1979
2534
  }
@@ -2884,6 +3439,30 @@ function SplitCardFormInner({
2884
3439
  },
2885
3440
  [onDecline]
2886
3441
  );
3442
+ const recoverExternalMethodTechnicalFailure = (0, import_react9.useCallback)(
3443
+ (method, err, options) => {
3444
+ const popupBlocked = options?.popupBlocked ?? isPopupBlockedError(err);
3445
+ const message = buildExternalMethodRecoveryMessage(method, popupBlocked);
3446
+ const providerCode = sanitizeExternalFailureCode(options?.code) ?? getProviderErrorCode(err) ?? "external_payment_method_failed";
3447
+ const floPayError = new import_shared6.FloPayError(message, "api_error", {
3448
+ code: "external_payment_method_failed",
3449
+ param: method
3450
+ });
3451
+ setOverlayStatus(null);
3452
+ if (layout === "buttons") {
3453
+ setViewState("buttons");
3454
+ setExpandedApmMethod(null);
3455
+ }
3456
+ updateError(message);
3457
+ onError?.(floPayError);
3458
+ console.error("[FloPay] External payment method failed:", {
3459
+ method,
3460
+ code: providerCode,
3461
+ popupBlocked
3462
+ });
3463
+ },
3464
+ [layout, onError, updateError]
3465
+ );
2887
3466
  (0, import_react9.useEffect)(() => {
2888
3467
  if (!vaultActive || !sessionId) {
2889
3468
  setVaultMount(null);
@@ -4340,6 +4919,7 @@ function SplitCardFormInner({
4340
4919
  onComplete,
4341
4920
  onErrorChange: updateError,
4342
4921
  onDecline,
4922
+ onTechnicalFailure: recoverExternalMethodTechnicalFailure,
4343
4923
  onButtonClick,
4344
4924
  runBeforeButtonClick,
4345
4925
  isProcessing: isSubmitting,
@@ -4363,6 +4943,7 @@ function SplitCardFormInner({
4363
4943
  isProcessing: isSubmitting,
4364
4944
  onButtonClick,
4365
4945
  onDecline,
4946
+ onTechnicalFailure: recoverExternalMethodTechnicalFailure,
4366
4947
  runBeforeButtonClick,
4367
4948
  onLoadStateChange: setPaypalLoadState,
4368
4949
  placeholderBorderRadius: buttonBorderRadius
@@ -4380,6 +4961,7 @@ function SplitCardFormInner({
4380
4961
  onErrorChange: updateError,
4381
4962
  onButtonClick,
4382
4963
  onDecline,
4964
+ onTechnicalFailure: recoverExternalMethodTechnicalFailure,
4383
4965
  runBeforeButtonClick,
4384
4966
  onLoadStateChange: setWalletLoadState,
4385
4967
  placeholderBorderRadius: buttonBorderRadius
@@ -4735,6 +5317,7 @@ function SplitCardFormInner({
4735
5317
  onComplete,
4736
5318
  onErrorChange: updateError,
4737
5319
  onDecline,
5320
+ onTechnicalFailure: recoverExternalMethodTechnicalFailure,
4738
5321
  onButtonClick,
4739
5322
  runBeforeButtonClick,
4740
5323
  isProcessing: isSubmitting,
@@ -4758,6 +5341,7 @@ function SplitCardFormInner({
4758
5341
  isProcessing: isSubmitting,
4759
5342
  onButtonClick,
4760
5343
  onDecline,
5344
+ onTechnicalFailure: recoverExternalMethodTechnicalFailure,
4761
5345
  runBeforeButtonClick,
4762
5346
  onLoadStateChange: setPaypalLoadState,
4763
5347
  placeholderBorderRadius: buttonBorderRadius
@@ -4775,6 +5359,7 @@ function SplitCardFormInner({
4775
5359
  onErrorChange: updateError,
4776
5360
  onButtonClick,
4777
5361
  onDecline,
5362
+ onTechnicalFailure: recoverExternalMethodTechnicalFailure,
4778
5363
  runBeforeButtonClick,
4779
5364
  onLoadStateChange: setWalletLoadState,
4780
5365
  placeholderBorderRadius: buttonBorderRadius