@flopay/react 1.3.0 → 1.3.2

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.mjs CHANGED
@@ -248,6 +248,9 @@ import {
248
248
  resolveAVSConfig,
249
249
  isAVSFieldVisible,
250
250
  getStateFromPostalCode,
251
+ isPostalCodeSupported,
252
+ isValidPostalCode,
253
+ getPostalCodeExample,
251
254
  filterStripeMethodsByCountry,
252
255
  getStripeMethodDisplayName,
253
256
  hasVendoredStripeMethodLogo,
@@ -1334,6 +1337,9 @@ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs4 } from "react/jsx-run
1334
1337
  var STRIPE_RESUME_KEY = "flopay_stripe_resume";
1335
1338
  var LEGACY_WALLET_RESUME_KEY = "flopay_wallet_resume";
1336
1339
  var PAYPAL_RESUME_KEY = "flopay_paypal_resume";
1340
+ function isAccountValidationError(err) {
1341
+ return err instanceof FloPayError3 && typeof err.statusCode === "number" && err.statusCode >= 400 && err.statusCode < 500;
1342
+ }
1337
1343
  function toVaultMount(block) {
1338
1344
  if (!block?.html) return null;
1339
1345
  return {
@@ -1416,6 +1422,18 @@ function getButtonMethodLabel(method) {
1416
1422
  return "Card";
1417
1423
  }
1418
1424
  }
1425
+ function malformedPostcodeMessage(country) {
1426
+ const example = getPostalCodeExample(country);
1427
+ return `Enter a valid ${getPostalCodeLabel(country)}${example ? ` (e.g. ${example})` : ""}`;
1428
+ }
1429
+ function computePostalCodeState(country, zip, visible) {
1430
+ const supported = isPostalCodeSupported(country);
1431
+ const trimmed = zip.trim();
1432
+ const required = visible && supported;
1433
+ const empty = required && !trimmed;
1434
+ const malformed = required && !!trimmed && !isValidPostalCode(country, trimmed);
1435
+ return { visible, supported, required, empty, malformed };
1436
+ }
1419
1437
  function normalizeBeforeButtonClickError(method, err) {
1420
1438
  return err instanceof FloPayError3 ? err : new FloPayError3(
1421
1439
  err instanceof Error ? err.message : `${getButtonMethodLabel(method)} before-click hook failed.`,
@@ -2648,6 +2666,11 @@ function SplitCardFormInner({
2648
2666
  if (!flopay || !vaultActive || !sessionId) return null;
2649
2667
  return flopay.cardCapture({ sessionId });
2650
2668
  }, [flopay, vaultActive, sessionId]);
2669
+ const postalCodeState = useMemo3(() => {
2670
+ const cc = selectedCountry;
2671
+ const visible = avsConfig ? isAVSFieldVisible(avsConfig.postal_code, cc) : false;
2672
+ return computePostalCodeState(cc, zipCode, visible);
2673
+ }, [avsConfig, selectedCountry, zipCode]);
2651
2674
  const { avsInvalid, invalidAvsFields } = useMemo3(() => {
2652
2675
  const none = { line1: false, city: false, state: false, zip: false };
2653
2676
  if (!vaultActive || !avsConfig) return { avsInvalid: false, invalidAvsFields: none };
@@ -2657,12 +2680,15 @@ function SplitCardFormInner({
2657
2680
  line1: isEmpty(avsConfig.address_line_1, addressLine1),
2658
2681
  city: isEmpty(avsConfig.city, city),
2659
2682
  state: isEmpty(avsConfig.state, stateValue),
2660
- zip: isEmpty(avsConfig.postal_code, zipCode)
2683
+ // Empty (required) or malformed both block; unsupported/no-postcode
2684
+ // locales never block (`postalCodeState` fails open above).
2685
+ zip: postalCodeState.empty || postalCodeState.malformed
2661
2686
  };
2662
2687
  const avsInvalid2 = invalidAvsFields2.line1 || invalidAvsFields2.city || invalidAvsFields2.state || invalidAvsFields2.zip;
2663
2688
  return { avsInvalid: avsInvalid2, invalidAvsFields: invalidAvsFields2 };
2664
- }, [vaultActive, avsConfig, selectedCountry, addressLine1, city, stateValue, zipCode]);
2689
+ }, [vaultActive, avsConfig, selectedCountry, addressLine1, city, stateValue, postalCodeState]);
2665
2690
  const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState3(false);
2691
+ const [zipTouched, setZipTouched] = useState3(false);
2666
2692
  const [vaultMount, setVaultMount] = useState3(
2667
2693
  () => toVaultMount(session?.vault)
2668
2694
  );
@@ -2815,10 +2841,6 @@ function SplitCardFormInner({
2815
2841
  },
2816
2842
  [onErrorChange]
2817
2843
  );
2818
- useEffect5(() => {
2819
- if (!vaultActive || !cardCapture) return;
2820
- cardCapture.setSubmitGate?.(avsInvalid);
2821
- }, [vaultActive, cardCapture, avsInvalid]);
2822
2844
  useEffect5(() => {
2823
2845
  if (!vaultActive || !cardCapture) return;
2824
2846
  cardCapture.setCardFieldOrder?.(cardFieldOrder ?? null, avsConfig == null);
@@ -2872,73 +2894,118 @@ function SplitCardFormInner({
2872
2894
  nonce,
2873
2895
  updateError
2874
2896
  ]);
2897
+ const vaultOutcomeRef = useRef4({
2898
+ onComplete,
2899
+ onError,
2900
+ updateError,
2901
+ emitDecline,
2902
+ resolvedAccount,
2903
+ fullName,
2904
+ avsConfig,
2905
+ avsCheckProp,
2906
+ sessionId,
2907
+ nonce,
2908
+ baseUrl
2909
+ });
2910
+ vaultOutcomeRef.current = {
2911
+ onComplete,
2912
+ onError,
2913
+ updateError,
2914
+ emitDecline,
2915
+ resolvedAccount,
2916
+ fullName,
2917
+ avsConfig,
2918
+ avsCheckProp,
2919
+ sessionId,
2920
+ nonce,
2921
+ baseUrl
2922
+ };
2923
+ const vaultCompletedRef = useRef4(false);
2924
+ const buildVaultAccountSnapshot = useCallback(() => {
2925
+ const { resolvedAccount: resolvedAccount2, avsConfig: avsConfig2, fullName: fullName2, avsCheckProp: avsCheckProp2 } = vaultOutcomeRef.current;
2926
+ const cc = selectedCountryRef.current || resolvedAccount2.country || "US";
2927
+ const stateVisible = avsConfig2 ? isAVSFieldVisible(avsConfig2.state, cc) : false;
2928
+ const line1Visible = avsConfig2 ? isAVSFieldVisible(avsConfig2.address_line_1, cc) : false;
2929
+ const zipVisible = avsConfig2 ? isAVSFieldVisible(avsConfig2.postal_code, cc) : false;
2930
+ const cityVisible = avsConfig2 ? isAVSFieldVisible(avsConfig2.city, cc) : false;
2931
+ const line2Visible = avsConfig2 ? isAVSFieldVisible(avsConfig2.address_line_2, cc) : false;
2932
+ const derivedState = line1Visible && !stateVisible && zipVisible ? getStateFromPostalCode(cc, (zipCodeRef.current ?? "").trim()) : null;
2933
+ const stateValue2 = stateVisible ? stateRef.current : derivedState;
2934
+ return {
2935
+ accountData: {
2936
+ userId: resolvedAccount2.userId ?? "",
2937
+ email: resolvedAccount2.email ?? "",
2938
+ firstName: resolvedAccount2.firstName ?? fullName2.trim().split(/\s+/)[0] ?? "",
2939
+ lastName: resolvedAccount2.lastName ?? fullName2.trim().split(/\s+/).slice(1).join(" ") ?? "",
2940
+ ...zipVisible && zipCodeRef.current ? { zip: zipCodeRef.current } : {},
2941
+ ...cityVisible && cityRef.current ? { city: cityRef.current } : {},
2942
+ ...stateValue2 ? { state: stateValue2 } : {},
2943
+ ...line1Visible && addressLine1Ref.current ? { addressLine1: addressLine1Ref.current } : {},
2944
+ ...line2Visible && addressLine2Ref.current ? { addressLine2: addressLine2Ref.current } : {},
2945
+ country: cc
2946
+ },
2947
+ ...avsCheckProp2 !== void 0 ? { avsCheck: avsCheckProp2 } : {},
2948
+ ...avsConfig2 ? {
2949
+ avsConfig: {
2950
+ country: isAVSFieldVisible(avsConfig2.country, cc),
2951
+ postal_code: zipVisible,
2952
+ address_line_1: line1Visible,
2953
+ address_line_2: line2Visible,
2954
+ city: cityVisible,
2955
+ state: stateVisible
2956
+ }
2957
+ } : {}
2958
+ };
2959
+ }, []);
2875
2960
  useEffect5(() => {
2876
2961
  if (!vaultActive || !cardCapture) return;
2962
+ cardCapture.setSubmitGate?.(avsInvalid);
2963
+ }, [vaultActive, cardCapture, avsInvalid]);
2964
+ useEffect5(() => {
2965
+ if (!vaultActive || !cardCapture) return;
2966
+ vaultCompletedRef.current = false;
2877
2967
  let cancelled = false;
2878
2968
  const offSubmitting = cardCapture.on("submitting", () => {
2879
2969
  setHasAttemptedSubmit(true);
2880
2970
  setOverlayStatus("processing");
2881
- if (!sessionId || !nonce) return;
2882
- const cc = selectedCountryRef.current || resolvedAccount.country || "US";
2883
- const stateVisible = avsConfig ? isAVSFieldVisible(avsConfig.state, cc) : false;
2884
- const line1Visible = avsConfig ? isAVSFieldVisible(avsConfig.address_line_1, cc) : false;
2885
- const zipVisible = avsConfig ? isAVSFieldVisible(avsConfig.postal_code, cc) : false;
2886
- const cityVisible = avsConfig ? isAVSFieldVisible(avsConfig.city, cc) : false;
2887
- const line2Visible = avsConfig ? isAVSFieldVisible(avsConfig.address_line_2, cc) : false;
2888
- const derivedState = line1Visible && !stateVisible && zipVisible ? getStateFromPostalCode(cc, zipCodeRef.current ?? "") : null;
2889
- const stateValue2 = stateVisible ? stateRef.current : derivedState;
2890
- void new PaymentAPI2(baseUrl).patchAccountSnapshot(sessionId, nonce, {
2891
- accountData: {
2892
- userId: resolvedAccount.userId ?? "",
2893
- email: resolvedAccount.email ?? "",
2894
- firstName: resolvedAccount.firstName ?? fullName.trim().split(/\s+/)[0] ?? "",
2895
- lastName: resolvedAccount.lastName ?? fullName.trim().split(/\s+/).slice(1).join(" ") ?? "",
2896
- ...zipVisible && zipCodeRef.current ? { zip: zipCodeRef.current } : {},
2897
- ...cityVisible && cityRef.current ? { city: cityRef.current } : {},
2898
- ...stateValue2 ? { state: stateValue2 } : {},
2899
- ...line1Visible && addressLine1Ref.current ? { addressLine1: addressLine1Ref.current } : {},
2900
- ...line2Visible && addressLine2Ref.current ? { addressLine2: addressLine2Ref.current } : {},
2901
- country: cc
2902
- },
2903
- ...avsCheckProp !== void 0 ? { avsCheck: avsCheckProp } : {},
2904
- ...avsConfig ? {
2905
- avsConfig: {
2906
- country: isAVSFieldVisible(avsConfig.country, cc),
2907
- postal_code: zipVisible,
2908
- address_line_1: line1Visible,
2909
- address_line_2: line2Visible,
2910
- city: cityVisible,
2911
- state: stateVisible
2912
- }
2913
- } : {}
2914
- }).catch(() => {
2971
+ const { baseUrl: baseUrl2, sessionId: sessionId2, nonce: nonce2, updateError: updateError2, onError: onError2 } = vaultOutcomeRef.current;
2972
+ if (!sessionId2 || !nonce2) return;
2973
+ void new PaymentAPI2(baseUrl2).patchAccountSnapshot(sessionId2, nonce2, buildVaultAccountSnapshot()).catch((err) => {
2974
+ if (!isAccountValidationError(err)) return;
2975
+ const message = err instanceof Error ? err.message : "Please check your billing address and try again.";
2976
+ updateError2(message);
2977
+ setOverlayStatus(null);
2978
+ onError2?.(err instanceof FloPayError3 ? err : new FloPayError3(message, "api_error"));
2915
2979
  });
2916
2980
  });
2917
2981
  const offComplete = cardCapture.on("complete", async (event) => {
2918
- markSessionRecentlyCompleted(event.sessionId ?? sessionId);
2982
+ markSessionRecentlyCompleted(event.sessionId ?? vaultOutcomeRef.current.sessionId);
2919
2983
  setOverlayStatus("success");
2920
2984
  await new Promise((r) => setTimeout(r, PROCESSING_OVERLAY_SUCCESS_DELAY_MS));
2921
- if (cancelled) return;
2922
- onComplete?.({
2985
+ if (vaultCompletedRef.current) return;
2986
+ vaultCompletedRef.current = true;
2987
+ vaultOutcomeRef.current.onComplete?.({
2923
2988
  status: "succeeded",
2924
2989
  paymentIntentId: event.intentId,
2925
2990
  checkoutMethod: "card"
2926
2991
  });
2927
2992
  });
2928
2993
  const offDecline = cardCapture.on("decline", async (event) => {
2994
+ const { updateError: updateError2, emitDecline: emitDecline2 } = vaultOutcomeRef.current;
2929
2995
  const message = event.message ?? "Your payment was declined. Please try another card or contact your bank.";
2930
- updateError(message);
2996
+ updateError2(message);
2931
2997
  setOverlayStatus("error");
2932
- emitDecline("card", message, event.declineReason ? { declineCode: event.declineReason } : void 0);
2998
+ emitDecline2("card", message, event.declineReason ? { declineCode: event.declineReason } : void 0);
2933
2999
  await new Promise((r) => setTimeout(r, PROCESSING_OVERLAY_ERROR_DELAY_MS));
2934
3000
  if (cancelled) return;
2935
3001
  setOverlayStatus(null);
2936
3002
  });
2937
3003
  const offError = cardCapture.on("error", async (event) => {
3004
+ const { updateError: updateError2, onError: onError2 } = vaultOutcomeRef.current;
2938
3005
  const message = event.message ?? "There was a problem processing your payment. Please try again.";
2939
- updateError(message);
3006
+ updateError2(message);
2940
3007
  setOverlayStatus("error");
2941
- onError?.(new FloPayError3(message, "api_error"));
3008
+ onError2?.(new FloPayError3(message, "api_error"));
2942
3009
  await new Promise((r) => setTimeout(r, PROCESSING_OVERLAY_ERROR_DELAY_MS));
2943
3010
  if (cancelled) return;
2944
3011
  setOverlayStatus(null);
@@ -2950,25 +3017,7 @@ function SplitCardFormInner({
2950
3017
  offDecline();
2951
3018
  offError();
2952
3019
  };
2953
- }, [
2954
- vaultActive,
2955
- cardCapture,
2956
- sessionId,
2957
- nonce,
2958
- baseUrl,
2959
- avsConfig,
2960
- avsCheckProp,
2961
- resolvedAccount.userId,
2962
- resolvedAccount.email,
2963
- resolvedAccount.firstName,
2964
- resolvedAccount.lastName,
2965
- resolvedAccount.country,
2966
- fullName,
2967
- onComplete,
2968
- onError,
2969
- updateError,
2970
- emitDecline
2971
- ]);
3020
+ }, [vaultActive, cardCapture]);
2972
3021
  const directPaypalConfigured = !!directPaypal?.clientId;
2973
3022
  const hasEnabledMethodsPayload = Array.isArray(enabledPaymentMethods);
2974
3023
  const hasEnabledMethods = hasEnabledMethodsPayload && enabledPaymentMethods.length > 0;
@@ -3156,7 +3205,7 @@ function SplitCardFormInner({
3156
3205
  const stateVisible = isAVSFieldVisible(avsConfig.state, c);
3157
3206
  const line1Visible = isAVSFieldVisible(avsConfig.address_line_1, c);
3158
3207
  const zipVisible = isAVSFieldVisible(avsConfig.postal_code, c);
3159
- const derivedState = line1Visible && !stateVisible && zipVisible ? getStateFromPostalCode(c, zipCodeRef.current ?? "") : null;
3208
+ const derivedState = line1Visible && !stateVisible && zipVisible ? getStateFromPostalCode(c, (zipCodeRef.current ?? "").trim()) : null;
3160
3209
  const stateValue2 = stateVisible ? stateRef.current : derivedState;
3161
3210
  return {
3162
3211
  country: c,
@@ -3470,10 +3519,21 @@ function SplitCardFormInner({
3470
3519
  try {
3471
3520
  if (avsConfig) {
3472
3521
  const country = selectedCountryRef.current;
3473
- if (isAVSFieldVisible(avsConfig.postal_code, country) && !zipCodeRef.current.trim()) {
3522
+ const postal = computePostalCodeState(
3523
+ country,
3524
+ zipCodeRef.current,
3525
+ isAVSFieldVisible(avsConfig.postal_code, country)
3526
+ );
3527
+ if (postal.empty) {
3528
+ setZipTouched(true);
3474
3529
  updateError(getPostalCodeLabel(country) + " is required");
3475
3530
  return;
3476
3531
  }
3532
+ if (postal.malformed) {
3533
+ setZipTouched(true);
3534
+ updateError(malformedPostcodeMessage(country));
3535
+ return;
3536
+ }
3477
3537
  if (isAVSFieldVisible(avsConfig.address_line_1, country) && !addressLine1Ref.current.trim()) {
3478
3538
  updateError("Street address is required");
3479
3539
  return;
@@ -3614,6 +3674,8 @@ function SplitCardFormInner({
3614
3674
  const resolvedBorder = bStyles.cardInputBorder ?? (isButtons ? "#e5e7eb" : "#A4A4FF");
3615
3675
  const resolvedDangerColor = appearanceVars?.colorDanger ?? "#dc2626";
3616
3676
  const avsBorderColor = (fieldInvalid) => hasAttemptedSubmit && fieldInvalid ? resolvedDangerColor : resolvedBorder;
3677
+ const showPostcodeError = (postalCodeState.malformed || postalCodeState.empty) && (zipTouched || hasAttemptedSubmit);
3678
+ const zipBorderColor = showPostcodeError ? resolvedDangerColor : avsBorderColor(invalidAvsFields.zip);
3617
3679
  const cardBg = bStyles.cardFormContainer?.backgroundColor ?? themedWrapperBg ?? (isButtons ? "white" : "#EDEDFF");
3618
3680
  const cardInputBg = bStyles.cardInputBackground ?? appearanceVars?.colorBackground ?? "white";
3619
3681
  const hideBackButtonLabel = isEmptySlotContent(cardBackButtonContent);
@@ -4043,7 +4105,7 @@ function SplitCardFormInner({
4043
4105
  isAVSFieldVisible(avsConfig.postal_code, cc) && /* @__PURE__ */ jsx7("div", { style: {
4044
4106
  flex: avsLayoutProp === "row" ? 1 : void 0,
4045
4107
  backgroundColor: cardInputBg,
4046
- border: `1px solid ${avsBorderColor(invalidAvsFields.zip)}`,
4108
+ border: `1px solid ${zipBorderColor}`,
4047
4109
  padding: "10px",
4048
4110
  ...avsLayoutProp === "row" && isAVSFieldVisible(avsConfig.country, cc) ? { borderRadius: "0", borderTopRightRadius: resolvedBorderRadius, borderBottomRightRadius: resolvedBorderRadius } : { borderRadius: resolvedBorderRadius }
4049
4111
  }, children: /* @__PURE__ */ jsx7(
@@ -4060,13 +4122,31 @@ function SplitCardFormInner({
4060
4122
  setZipCode(e.target.value);
4061
4123
  onZipChange?.(e.target.value);
4062
4124
  },
4125
+ onBlur: () => setZipTouched(true),
4063
4126
  disabled: isSubmitting,
4064
- required: true,
4127
+ required: postalCodeState.required,
4128
+ "aria-invalid": showPostcodeError || void 0,
4129
+ "aria-describedby": showPostcodeError ? "flopay-billing-postal-code-error" : void 0,
4065
4130
  "data-testid": "flopay-zip",
4066
4131
  style: inputFieldStyle()
4067
4132
  }
4068
4133
  ) })
4069
- ] })
4134
+ ] }),
4135
+ showPostcodeError && /* @__PURE__ */ jsx7(
4136
+ "div",
4137
+ {
4138
+ id: "flopay-billing-postal-code-error",
4139
+ role: "alert",
4140
+ "data-testid": "flopay-zip-error",
4141
+ style: {
4142
+ marginTop: "0.375rem",
4143
+ color: resolvedDangerColor,
4144
+ ...sharedInputTypography,
4145
+ fontSize: "0.75rem"
4146
+ },
4147
+ children: postalCodeState.empty ? `${getPostalCodeLabel(cc)} is required` : malformedPostcodeMessage(cc)
4148
+ }
4149
+ )
4070
4150
  ] });
4071
4151
  })() }),
4072
4152
  vaultActive && cardPreFormSlot && /* @__PURE__ */ jsx7("div", { style: { order: -2, width: "100%" }, children: cardPreFormSlot }),