diff --git a/src/Components/PaymentErrorBanner.res b/src/Components/PaymentErrorBanner.res new file mode 100644 index 000000000..1fb88de6c --- /dev/null +++ b/src/Components/PaymentErrorBanner.res @@ -0,0 +1,76 @@ +@val @scope("document") +external addDocumentEventListener: (string, _ => unit) => unit = "addEventListener" + +@val @scope("document") +external removeDocumentEventListener: (string, _ => unit) => unit = "removeEventListener" + +@react.component +let make = () => { + let errorMessage = Recoil.useRecoilValueFromAtom(RecoilAtoms.paymentFailedErrorMessage) + let setErrorMessage = Recoil.useSetRecoilState(RecoilAtoms.paymentFailedErrorMessage) + let {themeObj} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) + + React.useEffect(() => { + if errorMessage->String.length > 0 { + let handler = _event => { + setErrorMessage(_ => "") + } + addDocumentEventListener("input", handler) + addDocumentEventListener("click", handler) + Some( + () => { + removeDocumentEventListener("input", handler) + removeDocumentEventListener("click", handler) + }, + ) + } else { + None + } + }, [errorMessage]) + + String.length > 0}> +
+
+ + + + + + + {React.string(errorMessage)} + +
+
+
+} diff --git a/src/LoaderController.res b/src/LoaderController.res index d0fc69ea8..3e0978c50 100644 --- a/src/LoaderController.res +++ b/src/LoaderController.res @@ -134,11 +134,24 @@ let make = (~children, ~paymentMode, ~setIntegrateErrorError, ~logger, ~initTime optionsLocaleString == "" ? config.locale : optionsLocaleString, ) let constantString = await CardTheme.getConstantStringsObject() - let _ = await S3Utils.initializeCountryData(~locale=config.locale, ~logger) + let rawLocale = + optionsLocaleString != "" + ? optionsLocaleString + : config.locale === "auto" + ? Window.Navigator.language + : config.locale + // Normalize locale to canonical form using the SDK's locale mapping + // Known locales (e.g., "en-GB", "fr-BE", "zh-Hant") are preserved as-is + // Unknown variants (e.g., "en-US") are mapped to their base language ("en") + let resolvedLocale = + rawLocale + ->LocaleStringHelper.mapLocalStringToTypeLocale + ->LocaleStringHelper.localeTypeToString + let _ = await S3Utils.initializeCountryData(~locale=resolvedLocale, ~logger) setConfig(_ => { config: { appearance, - locale: config.locale === "auto" ? Window.Navigator.language : config.locale, + locale: resolvedLocale, fonts: config.fonts, clientSecret: config.clientSecret, pmClientSecret: config.pmClientSecret, diff --git a/src/LocaleStrings/LocaleStringHelper.res b/src/LocaleStrings/LocaleStringHelper.res index 699e2a1a7..b78339d7c 100644 --- a/src/LocaleStrings/LocaleStringHelper.res +++ b/src/LocaleStrings/LocaleStringHelper.res @@ -1,4 +1,29 @@ open LocaleStringTypes + +// Converts a locale type to the canonical string used by the backend translations table +let localeTypeToString = locale => { + switch locale { + | EN => "en" + | EN_GB => "en-GB" + | HE => "he" + | FR => "fr" + | FR_BE => "fr-BE" + | AR => "ar" + | JA => "ja" + | DE => "de" + | ES => "es" + | CA => "ca" + | PT => "pt" + | IT => "it" + | PL => "pl" + | NL => "nl" + | SV => "sv" + | RU => "ru" + | ZH => "zh" + | ZH_HANT => "zh-Hant" + } +} + let mapLocalStringToTypeLocale = val => { // First try the exact match let exactMatch = switch val->String.toLowerCase { diff --git a/src/PaymentElement.res b/src/PaymentElement.res index d89aa9624..7eadf4c61 100644 --- a/src/PaymentElement.res +++ b/src/PaymentElement.res @@ -18,6 +18,7 @@ let make = (~cardProps, ~expiryProps, ~cvcProps, ~paymentType: CardThemeType.mod customerPaymentMethods, displaySavedPaymentMethods, sdkHandleConfirmPayment, + displayPaymentFailureMessage, } = Recoil.useRecoilValueFromAtom(RecoilAtoms.optionAtom) let {localeString} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) let optionAtomValue = Recoil.useRecoilValueFromAtom(RecoilAtoms.optionAtom) @@ -37,6 +38,7 @@ let make = (~cardProps, ~expiryProps, ~cvcProps, ~paymentType: CardThemeType.mod RecoilAtoms.showPaymentMethodsScreen, ) let (paymentToken, setPaymentToken) = Recoil.useRecoilState(RecoilAtoms.paymentTokenAtom) + let setPaymentFailedErrorMessage = Recoil.useSetRecoilState(RecoilAtoms.paymentFailedErrorMessage) let (paymentMethodListValue, setPaymentMethodListValue) = Recoil.useRecoilState( paymentMethodListValue, ) @@ -260,6 +262,11 @@ let make = (~cardProps, ~expiryProps, ~cvcProps, ~paymentType: CardThemeType.mod None }, (selectedOption, cardOptions, dropDownOptions, showAllPaymentMethods, layoutClass)) + React.useEffect(() => { + setPaymentFailedErrorMessage(_ => "") + None + }, [selectedOption]) + let isSelectedOptionValid = React.useMemo(() => { selectedOption !== "" && paymentOptions->Array.includes(selectedOption) }, (paymentOptions, selectedOption)) @@ -606,6 +613,9 @@ let make = (~cardProps, ~expiryProps, ~cvcProps, ~paymentType: CardThemeType.mod }} + + +
diff --git a/src/Types/PaymentConfirmTypes.res b/src/Types/PaymentConfirmTypes.res index 0fa37776c..6fdbf1312 100644 --- a/src/Types/PaymentConfirmTypes.res +++ b/src/Types/PaymentConfirmTypes.res @@ -59,6 +59,7 @@ type intent = { payment_method_type: string, manualRetryAllowed: bool, connectorTransactionId: string, + userGuidanceMessage: string, } open Utils @@ -92,6 +93,7 @@ let defaultIntent = { payment_method_type: "", manualRetryAllowed: false, connectorTransactionId: "", + userGuidanceMessage: "", } let getAchCreditTransfer = (dict, str) => { @@ -186,6 +188,16 @@ let getNextAction = (dict, str) => { }) ->Option.getOr(defaultNextAction) } +let getUserGuidanceMessage = dict => { + dict + ->Dict.get("error_details") + ->Option.flatMap(JSON.Decode.object) + ->Option.flatMap(errorDetails => errorDetails->Dict.get("unified_details")) + ->Option.flatMap(JSON.Decode.object) + ->Option.flatMap(unifiedDetails => getOptionString(unifiedDetails, "user_guidance_message")) + ->Option.getOr("") +} + let itemToObjMapper = dict => { { nextAction: getNextAction(dict, "next_action"), @@ -196,5 +208,6 @@ let itemToObjMapper = dict => { payment_method_type: getString(dict, "payment_method_type", ""), manualRetryAllowed: getBool(dict, "manual_retry_allowed", false), connectorTransactionId: getString(dict, "connector_transaction_id", ""), + userGuidanceMessage: getUserGuidanceMessage(dict), } } diff --git a/src/Types/PaymentType.res b/src/Types/PaymentType.res index 92eacdbbd..63667dbfc 100644 --- a/src/Types/PaymentType.res +++ b/src/Types/PaymentType.res @@ -229,6 +229,7 @@ type options = { customMessageForCardTerms: string, showShortSurchargeMessage: bool, paymentMethodsConfig: paymentMethodsConfig, + displayPaymentFailureMessage: bool, } type payerDetails = { @@ -406,6 +407,7 @@ let defaultOptions = { customMessageForCardTerms: "", showShortSurchargeMessage: false, paymentMethodsConfig: [], + displayPaymentFailureMessage: false, } let getMessageDisplayMode = (str, key) => { @@ -1239,6 +1241,7 @@ let allowedPaymentElementOptions = [ "customMessageForCardTerms", "showShortSurchargeMessage", "paymentMethodsConfig", + "displayPaymentFailureMessage", ] let fieldsToExcludeFromMasking = ["layout", "wallets", "paymentMethodsConfig", "terms"] @@ -1333,6 +1336,7 @@ let itemToObjMapper = (dict, logger: HyperLoggerTypes.loggerMake) => { customMessageForCardTerms: getString(dict, "customMessageForCardTerms", ""), showShortSurchargeMessage: getBool(dict, "showShortSurchargeMessage", false), paymentMethodsConfig: getPaymentMethodsConfig(dict, "paymentMethodsConfig", logger), + displayPaymentFailureMessage: getBool(dict, "displayPaymentFailureMessage", false), } } diff --git a/src/Utilities/PaymentHelpers.res b/src/Utilities/PaymentHelpers.res index 99bab2353..ca39a9f0b 100644 --- a/src/Utilities/PaymentHelpers.res +++ b/src/Utilities/PaymentHelpers.res @@ -333,6 +333,7 @@ let rec intentCall = ( ~iframeId, ~fetchMethod, ~setIsManualRetryEnabled, + ~setPaymentFailedErrorMessage, ~customPodUri, ~sdkHandleOneClickConfirmPayment, ~counter, @@ -344,6 +345,9 @@ let rec intentCall = ( ) => { open Promise let isConfirm = uri->String.includes("/confirm") + if isConfirm { + setPaymentFailedErrorMessage(_ => "") + } let isCompleteAuthorize = uri->String.includes("/complete_authorize") let isPostSessionTokens = uri->String.includes("/post_session_tokens") @@ -492,6 +496,7 @@ let rec intentCall = ( ~iframeId, ~fetchMethod=#GET, ~setIsManualRetryEnabled, + ~setPaymentFailedErrorMessage, ~customPodUri, ~sdkHandleOneClickConfirmPayment, ~counter=counter + 1, @@ -894,6 +899,7 @@ let rec intentCall = ( } if intent.status === "failed" { setIsManualRetryEnabled(_ => intent.manualRetryAllowed) + setPaymentFailedErrorMessage(_ => intent.userGuidanceMessage) } handleProcessingStatus(paymentType, sdkHandleOneClickConfirmPayment) } else if !isPaymentSession { @@ -965,6 +971,7 @@ let rec intentCall = ( ~iframeId, ~fetchMethod=#GET, ~setIsManualRetryEnabled, + ~setPaymentFailedErrorMessage, ~customPodUri, ~sdkHandleOneClickConfirmPayment, ~counter=counter + 1, @@ -1005,11 +1012,13 @@ let usePaymentSync = (optLogger: option, paymentTyp let customPodUri = Recoil.useRecoilValueFromAtom(customPodUri) let redirectionFlags = Recoil.useRecoilValueFromAtom(redirectionFlagsAtom) let setIsManualRetryEnabled = Recoil.useSetRecoilState(isManualRetryEnabled) + let setPaymentFailedErrorMessage = Recoil.useSetRecoilState(paymentFailedErrorMessage) + let {config} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) (~handleUserError=false, ~confirmParam: ConfirmType.confirmParams, ~iframeId="") => { switch keys.clientSecret { | Some(clientSecret) => let paymentIntentID = clientSecret->Utils.getPaymentId - let headers = [("Content-Type", "application/json")] + let headers = [("Content-Type", "application/json"), ("Accept-Language", config.locale)] switch keys.sdkAuthorization->Utils.getNonEmptyOption { | Some(_) => () @@ -1039,6 +1048,7 @@ let usePaymentSync = (optLogger: option, paymentTyp ~iframeId, ~fetchMethod=#GET, ~setIsManualRetryEnabled, + ~setPaymentFailedErrorMessage, ~customPodUri, ~sdkHandleOneClickConfirmPayment=keys.sdkHandleOneClickConfirmPayment, ~counter=0, @@ -1086,9 +1096,11 @@ let useCompleteAuthorizeHandler = () => { let customPodUri = Recoil.useRecoilValueFromAtom(customPodUri) let setIsManualRetryEnabled = Recoil.useSetRecoilState(isManualRetryEnabled) + let setPaymentFailedErrorMessage = Recoil.useSetRecoilState(paymentFailedErrorMessage) let isCallbackUsedVal = Recoil.useRecoilValueFromAtom(isCompleteCallbackUsed) let redirectionFlags = Recoil.useRecoilValueFromAtom(redirectionFlagsAtom) let keys = Recoil.useRecoilValueFromAtom(keys) + let {config} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) ( ~clientSecret: option, @@ -1116,6 +1128,8 @@ let useCompleteAuthorizeHandler = () => { ] } + finalHeaders->Array.push(("Accept-Language", config.locale)) + let sdkAuth = switch ( keys.sdkAuthorization->Utils.getNonEmptyOption, sdkAuthorization->Utils.getNonEmptyOption, @@ -1155,6 +1169,7 @@ let useCompleteAuthorizeHandler = () => { ~iframeId, ~fetchMethod=#POST, ~setIsManualRetryEnabled, + ~setPaymentFailedErrorMessage, ~customPodUri, ~sdkHandleOneClickConfirmPayment, ~counter=0, @@ -1237,6 +1252,8 @@ let usePaymentIntent = (optLogger, paymentType) => { let redirectionFlags = Recoil.useRecoilValueFromAtom(redirectionFlagsAtom) let setIsManualRetryEnabled = Recoil.useSetRecoilState(isManualRetryEnabled) + let setPaymentFailedErrorMessage = Recoil.useSetRecoilState(paymentFailedErrorMessage) + let {config} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) ( ~handleUserError=false, ~bodyArr: array<(string, JSON.t)>, @@ -1252,6 +1269,7 @@ let usePaymentIntent = (optLogger, paymentType) => { let headers = { let baseHeaders = [ ("X-Client-Source", paymentTypeFromUrl->CardThemeType.getPaymentModeToStrMapper), + ("Accept-Language", config.locale), ] switch keys.sdkAuthorization->Utils.getNonEmptyOption { | Some(sdkAuth) => baseHeaders->Array.push(("Authorization", sdkAuth)) @@ -1339,6 +1357,7 @@ let usePaymentIntent = (optLogger, paymentType) => { ~iframeId, ~fetchMethod=#POST, ~setIsManualRetryEnabled, + ~setPaymentFailedErrorMessage, ~customPodUri, ~sdkHandleOneClickConfirmPayment=keys.sdkHandleOneClickConfirmPayment, ~counter=0, @@ -1714,6 +1733,7 @@ let paymentIntentForPaymentSession = ( ~iframeId="", ~fetchMethod=#POST, ~setIsManualRetryEnabled={_ => ()}, + ~setPaymentFailedErrorMessage={_ => ()}, ~customPodUri, ~sdkHandleOneClickConfirmPayment=false, ~counter=0, @@ -1942,6 +1962,8 @@ let usePostSessionTokens = ( let redirectionFlags = Recoil.useRecoilValueFromAtom(RecoilAtoms.redirectionFlagsAtom) let setIsManualRetryEnabled = Recoil.useSetRecoilState(isManualRetryEnabled) + let setPaymentFailedErrorMessage = Recoil.useSetRecoilState(paymentFailedErrorMessage) + let {config} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) ( ~handleUserError=false, ~bodyArr: array<(string, JSON.t)>, @@ -1958,6 +1980,7 @@ let usePostSessionTokens = ( let headers = [ ("Content-Type", "application/json"), ("X-Client-Source", paymentTypeFromUrl->CardThemeType.getPaymentModeToStrMapper), + ("Accept-Language", config.locale), ] let body = [ @@ -2036,6 +2059,7 @@ let usePostSessionTokens = ( ~iframeId, ~fetchMethod=#POST, ~setIsManualRetryEnabled, + ~setPaymentFailedErrorMessage, ~customPodUri, ~sdkHandleOneClickConfirmPayment=keys.sdkHandleOneClickConfirmPayment, ~counter=0, diff --git a/src/Utilities/PaymentHelpersV2.res b/src/Utilities/PaymentHelpersV2.res index 26fe96dac..e821ab860 100644 --- a/src/Utilities/PaymentHelpersV2.res +++ b/src/Utilities/PaymentHelpersV2.res @@ -392,6 +392,7 @@ let useSaveCard = (optLogger: option, paymentType: let customPodUri = Recoil.useRecoilValueFromAtom(customPodUri) let isCallbackUsedVal = Recoil.useRecoilValueFromAtom(RecoilAtoms.isCompleteCallbackUsed) let redirectionFlags = Recoil.useRecoilValueFromAtom(redirectionFlagsAtom) + let {config} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) ( ~handleUserError=false, ~bodyArr: array<(string, JSON.t)>, @@ -404,6 +405,7 @@ let useSaveCard = (optLogger: option, paymentType: ("Content-Type", "application/json"), ("Authorization", `publishable-key=${keys.publishableKey},client-secret=${pmClientSecret}`), ("x-profile-id", keys.profileId), + ("Accept-Language", config.locale), ] let endpoint = ApiEndpoint.getApiEndPoint(~publishableKey=confirmParam.publishableKey) let uri = `${endpoint}/v1/payment-method-sessions/${pmSessionId}/confirm` @@ -455,6 +457,7 @@ let useUpdateCard = (optLogger: option, paymentType let customPodUri = Recoil.useRecoilValueFromAtom(customPodUri) let isCallbackUsedVal = Recoil.useRecoilValueFromAtom(RecoilAtoms.isCompleteCallbackUsed) let redirectionFlags = Recoil.useRecoilValueFromAtom(redirectionFlagsAtom) + let {config} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) ( ~handleUserError=false, ~bodyArr: array<(string, JSON.t)>, @@ -467,6 +470,7 @@ let useUpdateCard = (optLogger: option, paymentType ("Content-Type", "application/json"), ("Authorization", `publishable-key=${keys.publishableKey},client-secret=${pmClientSecret}`), ("x-profile-id", keys.profileId), + ("Accept-Language", config.locale), ] let endpoint = ApiEndpoint.getApiEndPoint(~publishableKey=confirmParam.publishableKey) let uri = `${endpoint}/v1/payment-method-sessions/${pmSessionId}/update-saved-payment-method` diff --git a/src/Utilities/RecoilAtoms.res b/src/Utilities/RecoilAtoms.res index c2ab07a50..1fe527ab1 100644 --- a/src/Utilities/RecoilAtoms.res +++ b/src/Utilities/RecoilAtoms.res @@ -63,6 +63,7 @@ let userGiftCardNumber = Recoil.atom("userGiftCardNumber", defaultFieldValues) let userGiftCardPin = Recoil.atom("userGiftCardPin", defaultFieldValues) let fieldsComplete = Recoil.atom("fieldsComplete", false) let isManualRetryEnabled = Recoil.atom("isManualRetryEnabled", false) +let paymentFailedErrorMessage = Recoil.atom("paymentFailedErrorMessage", "") let userCurrency = Recoil.atom("userCurrency", "") let cryptoCurrencyNetworks = Recoil.atom("cryptoCurrencyNetworks", "") let isShowOrPayUsing = Recoil.atom("isShowOrPayUsing", false)