From c2686cf046a41176ba93afb0625d6adf5625ccdc Mon Sep 17 00:00:00 2001 From: Kashif Date: Tue, 16 Jul 2024 14:27:34 +0530 Subject: [PATCH] feat(payout-link): add input validations for payment methods in CollectWidget (#460) Co-authored-by: Pritish Budhiraja --- public/icons/orca.svg | 115 +++++- src/App.res | 8 +- src/CollectWidget.res | 431 ++++++++++++++++---- src/Components/InputField.res | 18 +- src/PaymentMethodCollectElement.res | 286 ++++++------- src/PaymentMethodCollectElementLazy.res | 1 - src/Types/PaymentMethodCollectTypes.res | 22 +- src/Utilities/PaymentMethodCollectUtils.res | 105 ++++- 8 files changed, 678 insertions(+), 308 deletions(-) delete mode 100644 src/PaymentMethodCollectElementLazy.res diff --git a/public/icons/orca.svg b/public/icons/orca.svg index e57670093..3edb66648 100644 --- a/public/icons/orca.svg +++ b/public/icons/orca.svg @@ -1,7 +1,8 @@ @@ -1705,8 +1706,6 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL @@ -2357,42 +2356,42 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + \ No newline at end of file diff --git a/src/App.res b/src/App.res index 0d2cbcc0a..966f6c432 100644 --- a/src/App.res +++ b/src/App.res @@ -3,7 +3,6 @@ let make = () => { let url = RescriptReactRouter.useUrl() let (integrateError, setIntegrateErrorError) = React.useState(() => false) let setLoggerState = Recoil.useSetRecoilState(RecoilAtoms.loggerAtom) - let {showLoader} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) let paymentMode = CardUtils.getQueryParamsDictforKey(url.search, "componentName") let paymentType = paymentMode->CardThemeType.getPaymentMode @@ -20,12 +19,7 @@ let make = () => { let renderFullscreen = switch paymentMode { | "paymentMethodCollect" => - - - }> - - + | _ => switch fullscreenMode { diff --git a/src/CollectWidget.res b/src/CollectWidget.res index c1caa039d..81d23dd6c 100644 --- a/src/CollectWidget.res +++ b/src/CollectWidget.res @@ -1,3 +1,4 @@ +open CardUtils open PaymentMethodCollectTypes open PaymentMethodCollectUtils @@ -7,6 +8,7 @@ let make = ( ~availablePaymentMethodTypes, ~primaryTheme, ~handleSubmit, + ~formLayout, ) => { // Component states let (selectedPaymentMethod, setSelectedPaymentMethod) = React.useState(_ => @@ -15,6 +17,10 @@ let make = ( let (selectedPaymentMethodType, setSelectedPaymentMethodType) = React.useState(_ => defaultSelectedPaymentMethodType ) + let ( + availablePaymentMethodTypesOrdered, + setAvailablePaymentMethodTypesOrdered, + ) = React.useState(_ => availablePaymentMethodTypes) let (fieldValidityDict, setFieldValidityDict): ( Dict.t>, (Dict.t> => Dict.t>) => unit, @@ -25,7 +31,31 @@ let make = ( ) = React.useState(_ => None) let (submitted, setSubmitted) = React.useState(_ => false) let (paymentMethodData, setPaymentMethodData) = React.useState(_ => Dict.make()) + + // Input DOM references let inputRef = React.useRef(Nullable.null) + let cardNumberRef = React.useRef(Nullable.null) + let cardExpRef = React.useRef(Nullable.null) + let cardHolderRef = React.useRef(Nullable.null) + let routingNumberRef = React.useRef(Nullable.null) + let achAccNumberRef = React.useRef(Nullable.null) + let bacsSortCodeRef = React.useRef(Nullable.null) + let bacsAccNumberRef = React.useRef(Nullable.null) + let ibanRef = React.useRef(Nullable.null) + let sepaBicRef = React.useRef(Nullable.null) + let bankNameRef = React.useRef(Nullable.null) + let bankCityRef = React.useRef(Nullable.null) + let countryCodeRef = React.useRef(Nullable.null) + + // Update formLayout and availablePaymentMethodTypesOrdered + React.useEffect1(() => { + switch formLayout { + | Tabs => setSelectedPaymentMethodType(_ => availablePaymentMethodTypes->Array.get(0)) + | _ => () + } + setAvailablePaymentMethodTypesOrdered(_ => availablePaymentMethodTypes) + None + }, [availablePaymentMethodTypes]) // Reset payment method type React.useEffect(() => { @@ -43,6 +73,12 @@ let make = ( setFieldValidityDict(_ => Dict.make()) } + // Reset form on PMT updation + React.useEffect(() => { + resetForm() + None + }, [selectedPaymentMethodType]) + let handleBackClick = () => { switch savedPMD { | Some(_) => setSavedPMD(_ => None) @@ -51,30 +87,76 @@ let make = ( | Some(Card(_)) => { setSelectedPaymentMethod(_ => None) setSelectedPaymentMethodType(_ => None) - resetForm() } | Some(_) => setSelectedPaymentMethodType(_ => None) | None => switch selectedPaymentMethod { - | Some(_) => { - setSelectedPaymentMethod(_ => None) - resetForm() - () - } + | Some(_) => setSelectedPaymentMethod(_ => None) | None => () } } } } - let setPaymentMethodDataValue = (key: paymentMethodDataField, value) => - setPaymentMethodData(_ => paymentMethodData->setValue(key->getPaymentMethodDataFieldKey, value)) - let getPaymentMethodDataValue = (key: paymentMethodDataField) => paymentMethodData ->getValue(key->getPaymentMethodDataFieldKey) ->Option.getOr("") + let setPaymentMethodDataValue = (key: paymentMethodDataField, value) => + setPaymentMethodData(_ => paymentMethodData->setValue(key->getPaymentMethodDataFieldKey, value)) + + let validateAndSetPaymentMethodDataValue = (key: paymentMethodDataField, event) => { + let value = ReactEvent.Form.target(event)["value"] + let inputType = ReactEvent.Form.target(event)["type"] + + let (isValid, updatedValue) = switch (key, inputType, value) { + // Empty string is valid (no error) + | (_, _, "") => (true, "") + | (CardExpDate, "number" | "tel", _) => { + let formattedExpiry = formatCardExpiryNumber(value) + if isExipryValid(formattedExpiry) { + handleInputFocus(~currentRef=cardExpRef, ~destinationRef=cardHolderRef) + } + (true, formattedExpiry) + } + | (CardNumber, "number" | "tel", _) => { + let cardType = getCardType(getPaymentMethodDataValue(CardBrand)) + let formattedCardNumber = formatCardNumber(value, cardType) + if cardValid(clearSpaces(formattedCardNumber), getCardStringFromType(cardType)) { + handleInputFocus(~currentRef=cardNumberRef, ~destinationRef=cardExpRef) + } + (true, formattedCardNumber) + } + | (SepaBic | SepaIban, "text", _) => (true, String.toUpperCase(value)) + + // Default number validation + | (_, "number" | "tel", _) => + try { + let bigIntValue = Js.BigInt.fromStringExn(value) + (true, Js.BigInt.toString(bigIntValue)) + } catch { + | _ => (false, value) + } + + // Default validation + | (_, _, _) => + getPaymentMethodDataFieldCharacterPattern(key) + // valid; in case there is no pattern setup + ->Option.mapOr((true, value), regex => + regex->RegExp.test(value) ? (true, value) : (false, value) + ) + } + + if isValid { + switch key { + | CardNumber => setPaymentMethodDataValue(CardBrand, getCardBrand(updatedValue)) + | _ => () + } + setPaymentMethodDataValue(key, updatedValue) + } + } + let setFieldValidity = (key: paymentMethodDataField, value) => { let fieldValidityCopy = fieldValidityDict->Dict.copy fieldValidityCopy->Dict.set(key->getPaymentMethodDataFieldKey, value) @@ -96,14 +178,14 @@ let make = ( // UI renders let renderBackButton = () => { - switch (selectedPaymentMethod, selectedPaymentMethodType) { - | (Some(_), _) => + switch selectedPaymentMethod { + | Some(_) => - | _ => React.null + | None => React.null } } @@ -129,7 +211,14 @@ let make = ( let renderContentSubHeader = () => switch savedPMD { - | Some(_) => React.string("Your payout method details") + | Some(pmd) => + switch pmd { + | (Card, _, _) => React.string("Your card details") + | (pm, pmt, _) => + let pmtLabelString = + pmt->getPaymentMethodTypeLabel ++ " " ++ pm->getPaymentMethodLabel->String.toLowerCase + React.string(`Your ${pmtLabelString}`) + } | None => switch selectedPaymentMethod { | Some(_) => React.null @@ -138,11 +227,11 @@ let make = ( } let renderInfoTemplate = (label, value, uniqueKey) => { - let labelClasses = "w-4/10 text-jp-gray-800 text-[14px] min-w-40 text-end" - let valueClasses = "w-6/10 text-[14px] min-w-40" + let labelClasses = "w-4/10 text-jp-gray-800 text-sm min-w-40 text-end" + let valueClasses = "w-6/10 text-sm min-w-40"
{React.string(label)}
-
{React.string("")}
+
{React.string("")}
{React.string(value)}
} @@ -151,13 +240,16 @@ let make = ( let (paymentMethod, paymentMethodType, fields) = pmd
-
- -
- {React.string(paymentMethodType->getPaymentMethodTypeLabel)} + {switch formLayout { + | Tabs => +
+ +
+ {React.string(`Review your ${paymentMethodType->getPaymentMethodTypeLabel} details`)} +
-
{React.string(paymentMethod->String.make)}
-
+ | Journey => React.null + }} {fields ->Array.mapWithIndex((field, i) => { let (field, value) = field @@ -166,37 +258,70 @@ let make = ( ->React.array}
- + className="flex flex-row items-center min-w-full m5 py-1.5 px-2.5 text-xs border border-solid border-blue-200 rounded bg-blue-50"> + {React.string( `Your funds will be deposited in the selected ${paymentMethod ->getPaymentMethodLabel - ->String.toLowerCase}`, + ->String.toLowerCase}.`, )}
- +
+ + +
} let renderInputTemplate = (field: paymentMethodDataField) => { let isValid = field->getFieldValidity - let labelClasses = `text-[14px] mt-[10px] ${isValid->Option.getOr(true) - ? "text-jp-gray-800" - : "text-red-950"}` - let inputClasses = `min-w-full border mt-[5px] px-[10px] py-[8px] rounded-lg ${isValid->Option.getOr( - true, - ) - ? "border-jp-gray-200" - : "border-red-950"}` + let labelClasses = "text-sm mt-2.5 text-jp-gray-800" + let inputClasses = "min-w-full border mt-1.5 px-2.5 py-2 rounded-md border-jp-gray-200" + let inputRef = switch field { + | CardNumber => cardNumberRef + | CardExpDate => cardExpRef + | CardHolderName => cardHolderRef + | ACHRoutingNumber => routingNumberRef + | ACHAccountNumber => achAccNumberRef + | BacsSortCode => bacsSortCodeRef + | BacsAccountNumber => bacsAccNumberRef + | SepaIban => ibanRef + | SepaBic => sepaBicRef + // Union + | BacsBankName + | ACHBankName + | SepaBankName => bankNameRef + | BacsBankCity + | ACHBankCity + | SepaBankCity => bankCityRef + | SepaCountryCode => countryCodeRef + | _ => inputRef + } + let pattern = + field + ->getPaymentMethodDataFieldCharacterPattern + ->Option.getOr(%re("/.*/")) + ->Js.Re.source + let value = field->getPaymentMethodDataValue + let (errorString, errorStringClasses) = switch isValid { + | Some(false) => (field->getPaymentMethodDataErrorString(value), "text-xs text-red-950") + | _ => ("", "") + } getPaymentMethodDataFieldKey} className=inputClasses @@ -204,14 +329,18 @@ let make = ( paymentType={PaymentMethodCollectElement} inputRef isFocus={true} - isValid + isValid={None} + errorString + errorStringClasses fieldName={field->getPaymentMethodDataFieldLabel} placeholder={field->getPaymentMethodDataFieldPlaceholder} maxLength={field->getPaymentMethodDataFieldMaxLength} - value={field->getPaymentMethodDataValue} - onChange={event => field->setPaymentMethodDataValue(ReactEvent.Form.target(event)["value"])} + value + onChange={event => field->validateAndSetPaymentMethodDataValue(event)} setIsValid={updatedValidityFn => field->setFieldValidity(updatedValidityFn())} onBlur={_ev => field->calculateAndSetValidity} + type_={field->getPaymentMethodDataFieldInputType} + pattern /> } @@ -220,92 +349,108 @@ let make = ( {switch pmt { | Card(_) =>
-
-
{CardNumber->renderInputTemplate}
-
{CardExpDate->renderInputTemplate}
-
+ {CardNumber->renderInputTemplate} +
{CardExpDate->renderInputTemplate}
{CardHolderName->renderInputTemplate}
| BankTransfer(bankTransferType) =>
{switch bankTransferType { | ACH => - + <> {ACHRoutingNumber->renderInputTemplate} {ACHAccountNumber->renderInputTemplate} - + | Bacs => - + <> {BacsSortCode->renderInputTemplate} {BacsAccountNumber->renderInputTemplate} - + | Sepa => - + <> {SepaIban->renderInputTemplate} {SepaBic->renderInputTemplate} - + }}
| Wallet(walletType) =>
{switch walletType { | Paypal => - + <> {PaypalMail->renderInputTemplate} {PaypalMobNumber->renderInputTemplate} - - | Venmo => {VenmoMobNumber->renderInputTemplate} + + | Venmo => VenmoMobNumber->renderInputTemplate | Pix => PixId->renderInputTemplate }}
}}
} let renderPMOptions = () => -
+
{availablePaymentMethods ->Array.mapWithIndex((pm, i) => { }) ->React.array}
let renderPMTOptions = () => { - let commonClasses = "text-start border border-solid border-jp-gray-200 px-[20px] py-[10px] rounded mt-[10px] hover:bg-jp-gray-50" + let commonClasses = "flex flex-row items-center border border-solid border-jp-gray-200 px-5 py-2.5 rounded mt-2.5 hover:bg-jp-gray-50" + let buttonTextClasses = "text-start ml-2.5"
{switch selectedPaymentMethod { | Some(Card) => React.null | Some(BankTransfer) => - availablePaymentMethodTypes.bankTransfer + availablePaymentMethodTypes + ->Array.filterMap(pmt => + switch pmt { + | BankTransfer(bank) => Some(bank) + | _ => None + } + ) ->Array.mapWithIndex((pmt, i) => ) ->React.array | Some(Wallet) => - availablePaymentMethodTypes.wallet + availablePaymentMethodTypes + ->Array.filterMap(pmt => + switch pmt { + | Wallet(wallet) => Some(wallet) + | _ => None + } + ) ->Array.mapWithIndex((pmt, i) => ) ->React.array @@ -314,22 +459,142 @@ let make = (
} -
-
-
{renderBackButton()}
-
{renderContentHeader()}
+ let renderJourneyScreen = () => { +
+
+
{renderBackButton()}
+
{renderContentHeader()}
+
+
{renderContentSubHeader()}
+
+ {switch savedPMD { + | Some(pmd) => renderFinalizeScreen(pmd) + | None => + switch selectedPaymentMethodType { + | Some(pmt) => renderInputs(pmt) + | None => renderPMTOptions() + } + }} +
-
{renderContentSubHeader()}
-
- {switch savedPMD { - | Some(pmd) => renderFinalizeScreen(pmd) - | None => - switch selectedPaymentMethodType { - | Some(pmt) => renderInputs(pmt) - | None => renderPMTOptions() + } + + let handleTabSelection = selectedPMT => { + if availablePaymentMethodTypes->Array.indexOf(selectedPMT) >= defaultOptionsLimitInTabLayout { + // Move the selected payment method at the last tab position + let ordList = availablePaymentMethodTypes->Array.reduceWithIndex([], (acc, pmt, i) => { + if i === defaultOptionsLimitInTabLayout - 1 { + acc->Array.push(selectedPMT) + } else if pmt !== selectedPMT { + acc->Array.push(pmt) } - }} + acc + }) + setAvailablePaymentMethodTypesOrdered(_ => ordList) + } + setSelectedPaymentMethodType(_ => Some(selectedPMT)) + } + + let renderTabScreen = (~limit=defaultOptionsLimitInTabLayout) => { + let activeStyles: JsxDOM.style = { + borderColor: primaryTheme, + borderWidth: "2px", + color: primaryTheme, + } + let defaultStyles: JsxDOM.style = { + borderColor: "#9A9FA8", + borderWidth: "1px", + color: primaryTheme, + } + // tabs +
+
+ { + let hiddenTabs = availablePaymentMethodTypesOrdered->Array.reduceWithIndex([], ( + options, + pmt, + i, + ) => { + if i >= limit { + options->Array.push( + , + ) + } + options + }) + let visibleTabs = availablePaymentMethodTypesOrdered->Array.reduceWithIndex([], ( + items, + pmt, + i, + ) => { + if i < limit { + items->Array.push( +
Int.toString} + onClick={_ => setSelectedPaymentMethodType(_ => Some(pmt))} + className="flex w-full items-center rounded border border-solid border-jp-gray-700 px-2.5 py-1.5 mr-2.5 cursor-pointer hover:bg-jp-gray-50" + style={selectedPaymentMethodType === Some(pmt) ? activeStyles : defaultStyles}> + {pmt->getPaymentMethodTypeIcon} +
{React.string(pmt->getPaymentMethodTypeLabel)}
+
, + ) + } + items + }) + switch savedPMD { + | Some(pmd) => renderFinalizeScreen(pmd) + | None => +
+
+ {visibleTabs->React.array} + {Array.length > limit}> +
+ + +
+
} +
+
+ {switch selectedPaymentMethodType { + | Some(pmt) => renderInputs(pmt) + | None => React.null + }} +
+
+ } + } +
+ } + +
+ {switch formLayout { + | Journey => renderJourneyScreen() + | Tabs => renderTabScreen() + }}
} let default = make diff --git a/src/Components/InputField.res b/src/Components/InputField.res index fc741d969..22da6e237 100644 --- a/src/Components/InputField.res +++ b/src/Components/InputField.res @@ -11,6 +11,7 @@ let make = ( ~onFocus=?, ~rightIcon=React.null, ~errorString=?, + ~errorStringClasses=?, ~fieldName="", ~type_="text", ~paymentType: CardThemeType.mode, @@ -142,12 +143,15 @@ let make = ( />
{rightIcon}
- {switch errorString { - | Some(val) => - String.length > 0}> -
{React.string(val)}
-
- | None => React.null - }} + { + let errorClases = errorStringClasses->Option.getOr("") + switch errorString { + | Some(val) => + String.length > 0}> +
{React.string(val)}
+
+ | None => React.null + } + }
} diff --git a/src/PaymentMethodCollectElement.res b/src/PaymentMethodCollectElement.res index ef114178e..b2037de00 100644 --- a/src/PaymentMethodCollectElement.res +++ b/src/PaymentMethodCollectElement.res @@ -4,6 +4,7 @@ open RecoilAtoms @react.component let make = (~integrateError, ~logger) => { + let {themeObj} = Recoil.useRecoilValueFromAtom(configAtom) let keys = Recoil.useRecoilValueFromAtom(keys) let options = Recoil.useRecoilValueFromAtom(paymentMethodCollectOptionAtom) @@ -14,118 +15,48 @@ let make = (~integrateError, ~logger) => { let (availablePaymentMethodTypes, setAvailablePaymentMethodTypes) = React.useState(_ => defaultAvailablePaymentMethodTypes ) - let (amount, setAmount) = React.useState(_ => options.amount) - let (currency, setCurrency) = React.useState(_ => options.currency) - let (flow, setFlow) = React.useState(_ => options.flow) let (loader, setLoader) = React.useState(_ => false) - let (returnUrl, setReturnUrl) = React.useState(_ => options.returnUrl) let (secondsUntilRedirect, setSecondsUntilRedirect) = React.useState(_ => None) - let (sessionExpiry, setSessionExpiry) = React.useState(_ => options.sessionExpiry) let (showStatus, setShowStatus) = React.useState(_ => false) let (statusInfo, setStatusInfo) = React.useState(_ => defaultStatusInfo) - let (payoutId, setPayoutId) = React.useState(_ => options.payoutId) - let (merchantLogo, setMerchantLogo) = React.useState(_ => options.logo) - let (merchantName, setMerchantName) = React.useState(_ => options.collectorName) - let (merchantTheme, setMerchantTheme) = React.useState(_ => options.theme) // Form a list of available payment methods React.useEffect(() => { - let availablePMT = { - card: [], - bankTransfer: [], - wallet: [], - } + let availablePM: array = [] + let availablePMT: array = [] let _ = options.enabledPaymentMethods->Array.map(pm => { switch pm { - | Card(cardType) => - if !(availablePMT.card->Array.includes(cardType)) { - availablePMT.card->Array.push(cardType) + | Card(_) => + if !(availablePM->Array.includes(Card)) { + availablePMT->Array.push(Card(Debit)) + availablePM->Array.push(Card) + } + | BankTransfer(_) => + if !(availablePM->Array.includes(BankTransfer)) { + availablePM->Array.push(BankTransfer) + } + if !(availablePMT->Array.includes(pm)) { + availablePMT->Array.push(pm) } - | BankTransfer(bankTransferType) => - if !(availablePMT.bankTransfer->Array.includes(bankTransferType)) { - availablePMT.bankTransfer->Array.push(bankTransferType) + | Wallet(_) => + if !(availablePM->Array.includes(Wallet)) { + availablePM->Array.push(Wallet) } - | Wallet(walletType) => - if !(availablePMT.wallet->Array.includes(walletType)) { - availablePMT.wallet->Array.push(walletType) + if !(availablePMT->Array.includes(pm)) { + availablePMT->Array.push(pm) } } }) - let availablePM: array = [] - if !(availablePM->Array.includes(BankTransfer)) && availablePMT.bankTransfer->Array.length > 0 { - availablePM->Array.push(BankTransfer) - } - if !(availablePM->Array.includes(Card)) && availablePMT.card->Array.length > 0 { - availablePM->Array.push(Card) - } - if !(availablePM->Array.includes(Wallet)) && availablePMT.wallet->Array.length > 0 { - availablePM->Array.push(Wallet) - } - setAvailablePaymentMethods(_ => availablePM) setAvailablePaymentMethodTypes(_ => availablePMT) None }, [options.enabledPaymentMethods]) - // Update amount - React.useEffect(() => { - setAmount(_ => options.amount) - None - }, [options.amount]) - - // Update currency - React.useEffect(() => { - setCurrency(_ => options.currency) - None - }, [options.currency]) - - // Update flow - React.useEffect(() => { - setFlow(_ => options.flow) - None - }, [options.flow]) - - // Update payoutId - React.useEffect(() => { - setPayoutId(_ => options.payoutId) - None - }, [options.payoutId]) - - // Update merchant's name - React.useEffect(() => { - setMerchantName(_ => options.collectorName) - None - }, [options.collectorName]) - - // Update merchant's logo - React.useEffect(() => { - setMerchantLogo(_ => options.logo) - None - }, [options.logo]) - - // Update merchant's primary theme - React.useEffect(() => { - setMerchantTheme(_ => options.theme) - None - }, [options.theme]) - - // Update returnUrl - React.useEffect(() => { - setReturnUrl(_ => options.returnUrl) - None - }, [options.returnUrl]) - - // Update sessionExpiry - React.useEffect(() => { - setSessionExpiry(_ => options.sessionExpiry) - None - }, [options.sessionExpiry]) - // Start a timer for redirecting to return_url React.useEffect(() => { - switch (returnUrl, showStatus) { + switch (options.returnUrl, showStatus) { | (Some(returnUrl), true) => { setSecondsUntilRedirect(_ => Some(5)) // Start a interval to update redirect text every second @@ -143,7 +74,7 @@ let make = (~integrateError, ~logger) => { clearInterval(interval) // Append query params and redirect let url = PaymentHelpers.urlSearch(returnUrl) - url.searchParams.set("payout_id", payoutId) + url.searchParams.set("payout_id", options.payoutId) url.searchParams.set("status", statusInfo.status->getPayoutStatusString) Utils.openUrl(url.href) }, 5010)->ignore @@ -155,12 +86,13 @@ let make = (~integrateError, ~logger) => { let handleSubmit = pmd => { setLoader(_ => true) + let flow = options.flow let pmdBody = flow->formBody(pmd) switch flow { | PayoutLinkInitiate => { let endpoint = ApiEndpoint.getApiEndPoint() - let uri = `${endpoint}/payouts/${payoutId}/confirm` + let uri = `${endpoint}/payouts/${options.payoutId}/confirm` // Create payment method open Promise PaymentHelpers.confirmPayout( @@ -187,7 +119,7 @@ let make = (~integrateError, ~logger) => { } | Some(ErrorResponse(err)) => { let updatedStatusInfo = { - payoutId, + payoutId: options.payoutId, status: Failed, message: "Failed to process your payout. Please check with your provider for more details.", code: Some(err.code), @@ -198,7 +130,7 @@ let make = (~integrateError, ~logger) => { } | None => { let updatedStatusInfo = { - payoutId, + payoutId: options.payoutId, status: Failed, message: "Failed to process your payout. Please check with your provider for more details.", code: None, @@ -213,7 +145,7 @@ let make = (~integrateError, ~logger) => { ->catch(err => { Console.error2("CRITICAL - Payouts confirm failed with unknown error", err) let updatedStatusInfo = { - payoutId, + payoutId: options.payoutId, status: Failed, message: "Failed to process your payout. Please check with your provider for more details.", code: None, @@ -258,16 +190,17 @@ let make = (~integrateError, ~logger) => { } let renderCollectWidget = () => -
-
+
+
{loader ?
: {React.null}}
@@ -276,7 +209,7 @@ let make = (~integrateError, ~logger) => { let status = statusInfo.status let imageSource = getPayoutImageSource(status) let readableStatus = getPayoutReadableStatus(status) - let statusInfoFields: array = [{key: "Ref Id", value: payoutId}] + let statusInfoFields: array = [{key: "Ref Id", value: options.payoutId}] statusInfo.code ->Option.flatMap(code => { @@ -297,28 +230,41 @@ let make = (~integrateError, ~logger) => { }) ->ignore -
+
+ className="flex flex-col items-center rounded-lg max-w-[500px] + xs:shadow-lg">
-
{React.string(merchantName)}
- o + className="flex flex-row justify-between items-center w-full px-10 py-5 border-b border-jp-gray-300"> +
{React.string(options.collectorName)}
+ o
- o -
{React.string(readableStatus)}
-
+ o +
{React.string(readableStatus)}
+
{React.string(statusInfo.message)}
-
-
+
+
{statusInfoFields ->Array.mapWithIndex((info, i) => { -
Int.toString} className={`flex flex-row items-center`}> -
+
Int.toString} className={`flex flex-row items-center mb-0.5`}> +
{React.string(info.key)}
-
+
{React.string(info.value)}
@@ -327,76 +273,88 @@ let make = (~integrateError, ~logger) => {
-
- {switch secondsUntilRedirect { - | Some(seconds) => - React.string("Redirecting in " ++ seconds->Int.toString ++ " seconds ...") - | None => React.null - }} -
+ {switch secondsUntilRedirect { + | Some(seconds) => +
+ {React.string("Redirecting in " ++ seconds->Int.toString ++ " seconds ...")} +
+ | None => React.null + }}
} if integrateError { } else { -
- {switch flow { - | PayoutLinkInitiate => - if showStatus { - renderPayoutStatus() - } else { - - // Merchant's info -
+
+ { + let merchantLogo = options.logo + let merchantName = options.collectorName + let merchantTheme = options.theme + switch options.flow { + | PayoutLinkInitiate => + if showStatus { + renderPayoutStatus() + } else { + <> + // Merchant's info
-
-
- {React.string(`${currency} ${amount}`)} -
- O -
-
-
- {React.string("Payout from ")} - {React.string(merchantName)} + className="flex flex-col w-full h-max items-center p-6 + lg:w-4/10 lg:px-12 lg:py-20 lg:h-screen lg:items-end" + style={backgroundColor: merchantTheme}> +
+
+
+ {React.string(`${options.currency} ${options.amount}`)} +
+
+ O +
-
-
{React.string("Ref Id")}
-
- {React.string(payoutId)} +
+
+ {React.string("Payout from ")} + {React.string(merchantName)}
+
+
{React.string("Ref Id")}
+
{React.string(options.payoutId)}
+
+
+
+ {React.string(`Link expires on: ${options.sessionExpiry}`)}
-
- {React.string(`Link expires on: ${sessionExpiry}`)} +
+ // Collect widget + {renderCollectWidget()} + + } + + | PayoutMethodCollect => + <> + // Merchant's info +
+
+ O +
+ {React.string(merchantName)}
// Collect widget {renderCollectWidget()} - + } - - | PayoutMethodCollect => - - // Merchant's info -
-
- O -
- {React.string(merchantName)} -
-
-
- // Collect widget - {renderCollectWidget()} -
- }} + }
} } diff --git a/src/PaymentMethodCollectElementLazy.res b/src/PaymentMethodCollectElementLazy.res deleted file mode 100644 index fe013f6ca..000000000 --- a/src/PaymentMethodCollectElementLazy.res +++ /dev/null @@ -1 +0,0 @@ -let make = React.lazy_(() => Js.import(PaymentMethodCollectElement.default)) diff --git a/src/Types/PaymentMethodCollectTypes.res b/src/Types/PaymentMethodCollectTypes.res index fc10fcd7d..5f860e697 100644 --- a/src/Types/PaymentMethodCollectTypes.res +++ b/src/Types/PaymentMethodCollectTypes.res @@ -11,17 +11,13 @@ type paymentMethodType = | BankTransfer(bankTransfer) | Wallet(wallet) -type paymentMethodTypes = { - card: array, - bankTransfer: array, - wallet: array, -} - type paymentMethodDataField = // Cards | CardNumber | CardExpDate | CardHolderName + // Card meta + | CardBrand // Banks | ACHRoutingNumber | ACHAccountNumber @@ -46,6 +42,8 @@ type paymentMethodDataField = type paymentMethodData = (paymentMethod, paymentMethodType, array<(paymentMethodDataField, string)>) +type formLayout = Journey | Tabs + type paymentMethodCollectFlow = PayoutLinkInitiate | PayoutMethodCollect type paymentMethodCollectOptions = { @@ -61,6 +59,7 @@ type paymentMethodCollectOptions = { currency: string, flow: paymentMethodCollectFlow, sessionExpiry: string, + formLayout: formLayout, } // API TYPES @@ -139,6 +138,17 @@ let decodeFlow = (dict, defaultPaymentMethodCollectFlow) => | None => defaultPaymentMethodCollectFlow } +let decodeFormLayout = (dict, decodeFormLayout) => + switch dict->Dict.get("formLayout") { + | Some(formLayout) => + switch formLayout->JSON.Decode.string { + | Some("journey") => Journey + | Some("tabs") => Tabs + | _ => decodeFormLayout + } + | None => decodeFormLayout + } + let decodeCard = (cardType: string): option => switch cardType { | "credit" => Some(Credit) diff --git a/src/Utilities/PaymentMethodCollectUtils.res b/src/Utilities/PaymentMethodCollectUtils.res index 2ab0df77e..734e00552 100644 --- a/src/Utilities/PaymentMethodCollectUtils.res +++ b/src/Utilities/PaymentMethodCollectUtils.res @@ -126,8 +126,8 @@ let getPaymentMethodTypeLabel = (paymentMethodType: paymentMethodType): string = switch paymentMethodType { | Card(cardType) => switch cardType { - | Credit => "Credit" - | Debit => "Debit" + | Credit + | Debit => "Card" } | BankTransfer(bankTransferType) => switch bankTransferType { @@ -149,6 +149,7 @@ let getPaymentMethodDataFieldKey = (key: paymentMethodDataField): string => | CardNumber => "card.cardNumber" | CardExpDate => "card.cardExp" | CardHolderName => "card.cardHolder" + | CardBrand => "card.brand" | ACHRoutingNumber => "ach.routing" | ACHAccountNumber => "ach.account" | ACHBankName => "ach.bankName" @@ -182,20 +183,17 @@ let getPaymentMethodDataFieldLabel = (key: paymentMethodDataField): string => | SepaBic => "Bank Identifier Code (BIC)" | PixId => "Pix ID" | PixBankAccountNumber => "Bank Account Number" - | PaypalMail => "Email" | PaypalMobNumber | VenmoMobNumber => "Phone Number" - | SepaCountryCode => "Country Code (Optional)" - | ACHBankName | BacsBankName | PixBankName | SepaBankName => "Bank Name (Optional)" - | ACHBankCity | BacsBankCity | SepaBankCity => "Bank City (Optional)" + | CardBrand => "Misc." } let getPaymentMethodDataFieldPlaceholder = (key: paymentMethodDataField): string => @@ -212,33 +210,60 @@ let getPaymentMethodDataFieldPlaceholder = (key: paymentMethodDataField): string | SepaCountryCode => "Country" | PixId => "**** 3251" | PixBankAccountNumber => "**** 1232" - | ACHBankName | BacsBankName | PixBankName | SepaBankName => "Bank Name" - | ACHBankCity | BacsBankCity | SepaBankCity => "Bank City" - | PaypalMail => "Your Email" | PaypalMobNumber | VenmoMobNumber => "Your Phone" + | CardBrand => "Misc." } let getPaymentMethodDataFieldMaxLength = (key: paymentMethodDataField): int => switch key { - | CardNumber => 18 + | CardNumber => 23 | CardExpDate => 7 | ACHRoutingNumber => 9 | ACHAccountNumber => 12 | BacsSortCode => 6 | BacsAccountNumber => 18 - | SepaIban => 34 | SepaBic => 8 + | SepaIban => 34 | _ => 32 } +let getPaymentMethodDataFieldCharacterPattern = (key: paymentMethodDataField): option => + switch key { + | ACHAccountNumber => Some(%re("/^\d{1,17}$/")) + | ACHRoutingNumber => Some(%re("/^\d{1,9}$/")) + | BacsAccountNumber => Some(%re("/^\d{1,18}$/")) + | BacsSortCode => Some(%re("/^\d{1,6}$/")) + | CardHolderName => Some(%re("/^([a-zA-Z]| ){1,32}$/")) + | CardNumber => Some(%re("/^\d{1,18}$/")) + | PaypalMail => Some(%re("/^[a-zA-Z0-9._%+-]*[a-zA-Z0-9._%+-]*@[a-zA-Z0-9.-]*$/")) + | PaypalMobNumber => Some(%re("/^[0-9]{1,12}$/")) + | SepaBic => Some(%re("/^([A-Z0-9]| ){1,8}$/")) + | SepaIban => Some(%re("/^([A-Z0-9]| ){1,34}$/")) + | _ => None + } + +let getPaymentMethodDataFieldInputType = (key: paymentMethodDataField): string => + switch key { + | ACHAccountNumber => "tel" + | ACHRoutingNumber => "tel" + | BacsAccountNumber => "tel" + | BacsSortCode => "tel" + | CardExpDate => "tel" + | CardNumber => "tel" + | PaypalMail => "email" + | PaypalMobNumber => "tel" + | VenmoMobNumber => "tel" + | _ => "text" + } + let getPayoutImageSource = (payoutStatus: payoutStatus): string => { switch payoutStatus { | Success => "https://live.hyperswitch.io/payment-link-assets/success.png" @@ -308,7 +333,48 @@ let getPayoutStatusMessage = (payoutStatus: payoutStatus): string => | RequiresVendorAccountCreation => "Failed to process your payout. Please check with your provider for more details." } +let getPaymentMethodDataErrorString = (key: paymentMethodDataField, value): string => { + let len = value->String.length + let notEmptyAndComplete = len <= 0 || len === key->getPaymentMethodDataFieldMaxLength + switch (key, notEmptyAndComplete) { + | (CardNumber, _) => "Card number is invalid." + | (CardExpDate, false) => "Your card's expiration date is incomplete." + | (CardExpDate, true) => "Your card's expiration year is in the past." + | (ACHRoutingNumber, false) => "Routing number is invalid." + | _ => "" + } +} + +let getPaymentMethodIcon = (paymentMethod: paymentMethod) => + switch paymentMethod { + | Card => + | BankTransfer => + | Wallet => + } + +let getBankTransferIcon = (bankTransfer: bankTransfer) => + switch bankTransfer { + | ACH => + | Bacs => + | Sepa => + } + +let getWalletIcon = (wallet: wallet) => + switch wallet { + | Paypal => + | Pix => + | Venmo => + } + +let getPaymentMethodTypeIcon = (paymentMethodType: paymentMethodType) => + switch paymentMethodType { + | Card(_) => Card->getPaymentMethodIcon + | BankTransfer(b) => b->getBankTransferIcon + | Wallet(w) => w->getWalletIcon + } + // Defaults +let defaultFormLayout: formLayout = Tabs let defaultPaymentMethodCollectFlow: paymentMethodCollectFlow = PayoutLinkInitiate let defaultAmount = "0.01" let defaultCurrency = "EUR" @@ -333,13 +399,11 @@ let defaultPaymentMethodCollectOptions = { currency: defaultCurrency, flow: defaultPaymentMethodCollectFlow, sessionExpiry: "", + formLayout: defaultFormLayout, } +let defaultOptionsLimitInTabLayout = 2 let defaultAvailablePaymentMethods: array = [] -let defaultAvailablePaymentMethodTypes = { - card: [], - bankTransfer: [], - wallet: [], -} +let defaultAvailablePaymentMethodTypes: array = [] let defaultSelectedPaymentMethod: option = None let defaultSelectedPaymentMethodType: option = None let defaultStatusInfo = { @@ -366,6 +430,7 @@ let itemToObjMapper = (dict, logger) => { "currency", "flow", "sessionExpiry", + "formLayout", ], dict, "options", @@ -387,6 +452,7 @@ let itemToObjMapper = (dict, logger) => { currency: getString(dict, "currency", defaultCurrency), flow: dict->decodeFlow(defaultPaymentMethodCollectFlow), sessionExpiry: getString(dict, "sessionExpiry", ""), + formLayout: dict->decodeFormLayout(defaultFormLayout), } } @@ -599,8 +665,8 @@ let formBody = (flow: paymentMethodCollectFlow, paymentMethodData: paymentMethod let split = value->String.split("/") switch (split->Array.get(0), split->Array.get(1)) { | (Some(month), Some(year)) => { - pmdApiFields->Array.push(("card_exp_month", month)) - pmdApiFields->Array.push(("card_exp_year", year)) + pmdApiFields->Array.push(("expiry_month", month)) + pmdApiFields->Array.push(("expiry_year", `20${year}`)) } | _ => () } @@ -622,6 +688,9 @@ let formBody = (flow: paymentMethodCollectFlow, paymentMethodData: paymentMethod // Wallets | PaypalMail => pmdApiFields->Array.push(("email", value)) | PaypalMobNumber | VenmoMobNumber => pmdApiFields->Array.push(("telephone_number", value)) + + // Misc. + | CardBrand => pmdApiFields->Array.push(("card_brand", value)) } })