@flopay/react 1.3.0 → 1.3.1

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/README.md CHANGED
@@ -464,6 +464,29 @@ The `SplitCardForm` layout:
464
464
  6. Full Name input
465
465
  7. Submit button
466
466
 
467
+ #### AVS postcode validation
468
+
469
+ When AVS collection is enabled (`enableAVS`) and the postcode field is shown,
470
+ `SplitCardForm` validates the postcode **format against the live selected
471
+ country before the card is captured**, on both the Stripe and vault card paths.
472
+ It reuses `@flopay/shared`'s country-aware
473
+ [postcode helpers](../shared/README.md#postal-code-helpers) (the same
474
+ `validator` rules the billing API applies), so client and server agree.
475
+
476
+ - **Supported country + malformed postcode** → the submit is blocked (the vault
477
+ widget's submit button is gated; the Stripe path returns before tokenizing)
478
+ and an inline, country-specific message shows the expected format
479
+ (e.g. *"Enter a valid ZIP Code (e.g. 12345 or 12345-6789)"*). On the vault
480
+ path the inline message appears once the field is blurred, since the disabled
481
+ submit means it can't be reached by a submit attempt.
482
+ - **Supported country + empty postcode** → the same submit gate applies, and
483
+ once the field is blurred (or a submit is attempted) an inline *required*
484
+ message shows (e.g. *"ZIP Code is required"*) rather than the expected-format
485
+ copy.
486
+ - **Country with no postal system** (or a locale `validator` doesn't recognise)
487
+ → the postcode field is shown but **optional**: empty and format checks are
488
+ both skipped, so these buyers are never blocked.
489
+
467
490
  ### Vault PCI card form
468
491
 
469
492
  When the billing API returns a hosted vault card form on the session
package/dist/index.cjs CHANGED
@@ -1447,6 +1447,18 @@ function getButtonMethodLabel(method) {
1447
1447
  return "Card";
1448
1448
  }
1449
1449
  }
1450
+ function malformedPostcodeMessage(country) {
1451
+ const example = (0, import_shared5.getPostalCodeExample)(country);
1452
+ return `Enter a valid ${(0, import_shared5.getPostalCodeLabel)(country)}${example ? ` (e.g. ${example})` : ""}`;
1453
+ }
1454
+ function computePostalCodeState(country, zip, visible) {
1455
+ const supported = (0, import_shared5.isPostalCodeSupported)(country);
1456
+ const trimmed = zip.trim();
1457
+ const required = visible && supported;
1458
+ const empty = required && !trimmed;
1459
+ const malformed = required && !!trimmed && !(0, import_shared5.isValidPostalCode)(country, trimmed);
1460
+ return { visible, supported, required, empty, malformed };
1461
+ }
1450
1462
  function normalizeBeforeButtonClickError(method, err) {
1451
1463
  return err instanceof import_shared6.FloPayError ? err : new import_shared6.FloPayError(
1452
1464
  err instanceof Error ? err.message : `${getButtonMethodLabel(method)} before-click hook failed.`,
@@ -2679,6 +2691,11 @@ function SplitCardFormInner({
2679
2691
  if (!flopay || !vaultActive || !sessionId) return null;
2680
2692
  return flopay.cardCapture({ sessionId });
2681
2693
  }, [flopay, vaultActive, sessionId]);
2694
+ const postalCodeState = (0, import_react9.useMemo)(() => {
2695
+ const cc = selectedCountry;
2696
+ const visible = avsConfig ? (0, import_shared5.isAVSFieldVisible)(avsConfig.postal_code, cc) : false;
2697
+ return computePostalCodeState(cc, zipCode, visible);
2698
+ }, [avsConfig, selectedCountry, zipCode]);
2682
2699
  const { avsInvalid, invalidAvsFields } = (0, import_react9.useMemo)(() => {
2683
2700
  const none = { line1: false, city: false, state: false, zip: false };
2684
2701
  if (!vaultActive || !avsConfig) return { avsInvalid: false, invalidAvsFields: none };
@@ -2688,12 +2705,15 @@ function SplitCardFormInner({
2688
2705
  line1: isEmpty(avsConfig.address_line_1, addressLine1),
2689
2706
  city: isEmpty(avsConfig.city, city),
2690
2707
  state: isEmpty(avsConfig.state, stateValue),
2691
- zip: isEmpty(avsConfig.postal_code, zipCode)
2708
+ // Empty (required) or malformed both block; unsupported/no-postcode
2709
+ // locales never block (`postalCodeState` fails open above).
2710
+ zip: postalCodeState.empty || postalCodeState.malformed
2692
2711
  };
2693
2712
  const avsInvalid2 = invalidAvsFields2.line1 || invalidAvsFields2.city || invalidAvsFields2.state || invalidAvsFields2.zip;
2694
2713
  return { avsInvalid: avsInvalid2, invalidAvsFields: invalidAvsFields2 };
2695
- }, [vaultActive, avsConfig, selectedCountry, addressLine1, city, stateValue, zipCode]);
2714
+ }, [vaultActive, avsConfig, selectedCountry, addressLine1, city, stateValue, postalCodeState]);
2696
2715
  const [hasAttemptedSubmit, setHasAttemptedSubmit] = (0, import_react9.useState)(false);
2716
+ const [zipTouched, setZipTouched] = (0, import_react9.useState)(false);
2697
2717
  const [vaultMount, setVaultMount] = (0, import_react9.useState)(
2698
2718
  () => toVaultMount(session?.vault)
2699
2719
  );
@@ -2903,27 +2923,56 @@ function SplitCardFormInner({
2903
2923
  nonce,
2904
2924
  updateError
2905
2925
  ]);
2926
+ const vaultOutcomeRef = (0, import_react9.useRef)({
2927
+ onComplete,
2928
+ onError,
2929
+ updateError,
2930
+ emitDecline,
2931
+ resolvedAccount,
2932
+ fullName,
2933
+ avsConfig,
2934
+ avsCheckProp,
2935
+ sessionId,
2936
+ nonce,
2937
+ baseUrl
2938
+ });
2939
+ vaultOutcomeRef.current = {
2940
+ onComplete,
2941
+ onError,
2942
+ updateError,
2943
+ emitDecline,
2944
+ resolvedAccount,
2945
+ fullName,
2946
+ avsConfig,
2947
+ avsCheckProp,
2948
+ sessionId,
2949
+ nonce,
2950
+ baseUrl
2951
+ };
2952
+ const vaultCompletedRef = (0, import_react9.useRef)(false);
2906
2953
  (0, import_react9.useEffect)(() => {
2907
2954
  if (!vaultActive || !cardCapture) return;
2955
+ vaultCompletedRef.current = false;
2908
2956
  let cancelled = false;
2909
2957
  const offSubmitting = cardCapture.on("submitting", () => {
2910
2958
  setHasAttemptedSubmit(true);
2911
2959
  setOverlayStatus("processing");
2912
- if (!sessionId || !nonce) return;
2913
- const cc = selectedCountryRef.current || resolvedAccount.country || "US";
2914
- const stateVisible = avsConfig ? (0, import_shared5.isAVSFieldVisible)(avsConfig.state, cc) : false;
2915
- const line1Visible = avsConfig ? (0, import_shared5.isAVSFieldVisible)(avsConfig.address_line_1, cc) : false;
2916
- const zipVisible = avsConfig ? (0, import_shared5.isAVSFieldVisible)(avsConfig.postal_code, cc) : false;
2917
- const cityVisible = avsConfig ? (0, import_shared5.isAVSFieldVisible)(avsConfig.city, cc) : false;
2918
- const line2Visible = avsConfig ? (0, import_shared5.isAVSFieldVisible)(avsConfig.address_line_2, cc) : false;
2960
+ const { resolvedAccount: resolvedAccount2, avsConfig: avsConfig2, fullName: fullName2, avsCheckProp: avsCheckProp2, baseUrl: baseUrl2, sessionId: sessionId2, nonce: nonce2 } = vaultOutcomeRef.current;
2961
+ if (!sessionId2 || !nonce2) return;
2962
+ const cc = selectedCountryRef.current || resolvedAccount2.country || "US";
2963
+ const stateVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.state, cc) : false;
2964
+ const line1Visible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.address_line_1, cc) : false;
2965
+ const zipVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.postal_code, cc) : false;
2966
+ const cityVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.city, cc) : false;
2967
+ const line2Visible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.address_line_2, cc) : false;
2919
2968
  const derivedState = line1Visible && !stateVisible && zipVisible ? (0, import_shared5.getStateFromPostalCode)(cc, zipCodeRef.current ?? "") : null;
2920
2969
  const stateValue2 = stateVisible ? stateRef.current : derivedState;
2921
- void new import_js2.PaymentAPI(baseUrl).patchAccountSnapshot(sessionId, nonce, {
2970
+ void new import_js2.PaymentAPI(baseUrl2).patchAccountSnapshot(sessionId2, nonce2, {
2922
2971
  accountData: {
2923
- userId: resolvedAccount.userId ?? "",
2924
- email: resolvedAccount.email ?? "",
2925
- firstName: resolvedAccount.firstName ?? fullName.trim().split(/\s+/)[0] ?? "",
2926
- lastName: resolvedAccount.lastName ?? fullName.trim().split(/\s+/).slice(1).join(" ") ?? "",
2972
+ userId: resolvedAccount2.userId ?? "",
2973
+ email: resolvedAccount2.email ?? "",
2974
+ firstName: resolvedAccount2.firstName ?? fullName2.trim().split(/\s+/)[0] ?? "",
2975
+ lastName: resolvedAccount2.lastName ?? fullName2.trim().split(/\s+/).slice(1).join(" ") ?? "",
2927
2976
  ...zipVisible && zipCodeRef.current ? { zip: zipCodeRef.current } : {},
2928
2977
  ...cityVisible && cityRef.current ? { city: cityRef.current } : {},
2929
2978
  ...stateValue2 ? { state: stateValue2 } : {},
@@ -2931,10 +2980,10 @@ function SplitCardFormInner({
2931
2980
  ...line2Visible && addressLine2Ref.current ? { addressLine2: addressLine2Ref.current } : {},
2932
2981
  country: cc
2933
2982
  },
2934
- ...avsCheckProp !== void 0 ? { avsCheck: avsCheckProp } : {},
2935
- ...avsConfig ? {
2983
+ ...avsCheckProp2 !== void 0 ? { avsCheck: avsCheckProp2 } : {},
2984
+ ...avsConfig2 ? {
2936
2985
  avsConfig: {
2937
- country: (0, import_shared5.isAVSFieldVisible)(avsConfig.country, cc),
2986
+ country: (0, import_shared5.isAVSFieldVisible)(avsConfig2.country, cc),
2938
2987
  postal_code: zipVisible,
2939
2988
  address_line_1: line1Visible,
2940
2989
  address_line_2: line2Visible,
@@ -2946,30 +2995,33 @@ function SplitCardFormInner({
2946
2995
  });
2947
2996
  });
2948
2997
  const offComplete = cardCapture.on("complete", async (event) => {
2949
- markSessionRecentlyCompleted(event.sessionId ?? sessionId);
2998
+ markSessionRecentlyCompleted(event.sessionId ?? vaultOutcomeRef.current.sessionId);
2950
2999
  setOverlayStatus("success");
2951
3000
  await new Promise((r) => setTimeout(r, PROCESSING_OVERLAY_SUCCESS_DELAY_MS));
2952
- if (cancelled) return;
2953
- onComplete?.({
3001
+ if (vaultCompletedRef.current) return;
3002
+ vaultCompletedRef.current = true;
3003
+ vaultOutcomeRef.current.onComplete?.({
2954
3004
  status: "succeeded",
2955
3005
  paymentIntentId: event.intentId,
2956
3006
  checkoutMethod: "card"
2957
3007
  });
2958
3008
  });
2959
3009
  const offDecline = cardCapture.on("decline", async (event) => {
3010
+ const { updateError: updateError2, emitDecline: emitDecline2 } = vaultOutcomeRef.current;
2960
3011
  const message = event.message ?? "Your payment was declined. Please try another card or contact your bank.";
2961
- updateError(message);
3012
+ updateError2(message);
2962
3013
  setOverlayStatus("error");
2963
- emitDecline("card", message, event.declineReason ? { declineCode: event.declineReason } : void 0);
3014
+ emitDecline2("card", message, event.declineReason ? { declineCode: event.declineReason } : void 0);
2964
3015
  await new Promise((r) => setTimeout(r, PROCESSING_OVERLAY_ERROR_DELAY_MS));
2965
3016
  if (cancelled) return;
2966
3017
  setOverlayStatus(null);
2967
3018
  });
2968
3019
  const offError = cardCapture.on("error", async (event) => {
3020
+ const { updateError: updateError2, onError: onError2 } = vaultOutcomeRef.current;
2969
3021
  const message = event.message ?? "There was a problem processing your payment. Please try again.";
2970
- updateError(message);
3022
+ updateError2(message);
2971
3023
  setOverlayStatus("error");
2972
- onError?.(new import_shared6.FloPayError(message, "api_error"));
3024
+ onError2?.(new import_shared6.FloPayError(message, "api_error"));
2973
3025
  await new Promise((r) => setTimeout(r, PROCESSING_OVERLAY_ERROR_DELAY_MS));
2974
3026
  if (cancelled) return;
2975
3027
  setOverlayStatus(null);
@@ -2981,25 +3033,7 @@ function SplitCardFormInner({
2981
3033
  offDecline();
2982
3034
  offError();
2983
3035
  };
2984
- }, [
2985
- vaultActive,
2986
- cardCapture,
2987
- sessionId,
2988
- nonce,
2989
- baseUrl,
2990
- avsConfig,
2991
- avsCheckProp,
2992
- resolvedAccount.userId,
2993
- resolvedAccount.email,
2994
- resolvedAccount.firstName,
2995
- resolvedAccount.lastName,
2996
- resolvedAccount.country,
2997
- fullName,
2998
- onComplete,
2999
- onError,
3000
- updateError,
3001
- emitDecline
3002
- ]);
3036
+ }, [vaultActive, cardCapture]);
3003
3037
  const directPaypalConfigured = !!directPaypal?.clientId;
3004
3038
  const hasEnabledMethodsPayload = Array.isArray(enabledPaymentMethods);
3005
3039
  const hasEnabledMethods = hasEnabledMethodsPayload && enabledPaymentMethods.length > 0;
@@ -3501,10 +3535,21 @@ function SplitCardFormInner({
3501
3535
  try {
3502
3536
  if (avsConfig) {
3503
3537
  const country = selectedCountryRef.current;
3504
- if ((0, import_shared5.isAVSFieldVisible)(avsConfig.postal_code, country) && !zipCodeRef.current.trim()) {
3538
+ const postal = computePostalCodeState(
3539
+ country,
3540
+ zipCodeRef.current,
3541
+ (0, import_shared5.isAVSFieldVisible)(avsConfig.postal_code, country)
3542
+ );
3543
+ if (postal.empty) {
3544
+ setZipTouched(true);
3505
3545
  updateError((0, import_shared5.getPostalCodeLabel)(country) + " is required");
3506
3546
  return;
3507
3547
  }
3548
+ if (postal.malformed) {
3549
+ setZipTouched(true);
3550
+ updateError(malformedPostcodeMessage(country));
3551
+ return;
3552
+ }
3508
3553
  if ((0, import_shared5.isAVSFieldVisible)(avsConfig.address_line_1, country) && !addressLine1Ref.current.trim()) {
3509
3554
  updateError("Street address is required");
3510
3555
  return;
@@ -3645,6 +3690,8 @@ function SplitCardFormInner({
3645
3690
  const resolvedBorder = bStyles.cardInputBorder ?? (isButtons ? "#e5e7eb" : "#A4A4FF");
3646
3691
  const resolvedDangerColor = appearanceVars?.colorDanger ?? "#dc2626";
3647
3692
  const avsBorderColor = (fieldInvalid) => hasAttemptedSubmit && fieldInvalid ? resolvedDangerColor : resolvedBorder;
3693
+ const showPostcodeError = (postalCodeState.malformed || postalCodeState.empty) && (zipTouched || hasAttemptedSubmit);
3694
+ const zipBorderColor = showPostcodeError ? resolvedDangerColor : avsBorderColor(invalidAvsFields.zip);
3648
3695
  const cardBg = bStyles.cardFormContainer?.backgroundColor ?? themedWrapperBg ?? (isButtons ? "white" : "#EDEDFF");
3649
3696
  const cardInputBg = bStyles.cardInputBackground ?? appearanceVars?.colorBackground ?? "white";
3650
3697
  const hideBackButtonLabel = isEmptySlotContent(cardBackButtonContent);
@@ -4074,7 +4121,7 @@ function SplitCardFormInner({
4074
4121
  (0, import_shared5.isAVSFieldVisible)(avsConfig.postal_code, cc) && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { style: {
4075
4122
  flex: avsLayoutProp === "row" ? 1 : void 0,
4076
4123
  backgroundColor: cardInputBg,
4077
- border: `1px solid ${avsBorderColor(invalidAvsFields.zip)}`,
4124
+ border: `1px solid ${zipBorderColor}`,
4078
4125
  padding: "10px",
4079
4126
  ...avsLayoutProp === "row" && (0, import_shared5.isAVSFieldVisible)(avsConfig.country, cc) ? { borderRadius: "0", borderTopRightRadius: resolvedBorderRadius, borderBottomRightRadius: resolvedBorderRadius } : { borderRadius: resolvedBorderRadius }
4080
4127
  }, children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
@@ -4091,13 +4138,31 @@ function SplitCardFormInner({
4091
4138
  setZipCode(e.target.value);
4092
4139
  onZipChange?.(e.target.value);
4093
4140
  },
4141
+ onBlur: () => setZipTouched(true),
4094
4142
  disabled: isSubmitting,
4095
- required: true,
4143
+ required: postalCodeState.required,
4144
+ "aria-invalid": showPostcodeError || void 0,
4145
+ "aria-describedby": showPostcodeError ? "flopay-billing-postal-code-error" : void 0,
4096
4146
  "data-testid": "flopay-zip",
4097
4147
  style: inputFieldStyle()
4098
4148
  }
4099
4149
  ) })
4100
- ] })
4150
+ ] }),
4151
+ showPostcodeError && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
4152
+ "div",
4153
+ {
4154
+ id: "flopay-billing-postal-code-error",
4155
+ role: "alert",
4156
+ "data-testid": "flopay-zip-error",
4157
+ style: {
4158
+ marginTop: "0.375rem",
4159
+ color: resolvedDangerColor,
4160
+ ...sharedInputTypography,
4161
+ fontSize: "0.75rem"
4162
+ },
4163
+ children: postalCodeState.empty ? `${(0, import_shared5.getPostalCodeLabel)(cc)} is required` : malformedPostcodeMessage(cc)
4164
+ }
4165
+ )
4101
4166
  ] });
4102
4167
  })() }),
4103
4168
  vaultActive && cardPreFormSlot && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { style: { order: -2, width: "100%" }, children: cardPreFormSlot }),