Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 267 additions & 0 deletions src/pages/widgets/CardExpiryWidget.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
open ReactNative
open Style
open Validation

// Card Expiry Widget
// A standalone widget for collecting card expiry date in "MM / YY" format

let maxExpiryLength = 7
let widgetBaseHeight = 120
let minMonth = 1
let maxMonth = 12

let singleDigitMonthThreshold = 1

let yearPrefix = "20"

type expiryResponse = {
isValid: bool,
expiryMonth: string,
expiryYear: string,
}

let buildExpiryResponse = (~isValid, ~expiryMonth="", ~expiryYear=""): expiryResponse => {
isValid,
expiryMonth,
expiryYear,
}

let responseToJson = (response: expiryResponse) => {
[
("isValid", response.isValid->Js.Json.boolean),
("expiryMonth", response.expiryMonth->Js.Json.string),
("expiryYear", response.expiryYear->Js.Json.string),
]
->Dict.fromArray
->JSON.Encode.object
}

let validateAndSendExpiry = (
~expiry: string,
~setExpiryError: string => unit,
~setIsValid: bool => unit,
~localeObject: LocaleDataType.localeStrings,
) => {
if expiry == "" {
setExpiryError(localeObject.cardExpiryDateEmptyText)
setIsValid(false)
let response = buildExpiryResponse(~isValid=false)
HyperModule.sendMessageToNative(response->responseToJson->Js.Json.stringify)
false
} else if !checkCardExpiry(expiry) {
setExpiryError(localeObject.inValidExpiryErrorText)
setIsValid(false)
let response = buildExpiryResponse(~isValid=false)
HyperModule.sendMessageToNative(response->responseToJson->Js.Json.stringify)
false
} else {
let (month, year) = splitExpiryDates(expiry)
let yearWithPrefix = yearPrefix ++ year
setExpiryError("")
setIsValid(true)
let response = buildExpiryResponse(~isValid=true, ~expiryMonth=month, ~expiryYear=yearWithPrefix)
HyperModule.sendMessageToNative(response->responseToJson->Js.Json.stringify)
true
}
}

module CardExpiryInput = {
@react.component
let make = (
~cardExpiry: string,
~setCardExpiry: string => unit,
~isValid: bool,
~setIsValid: bool => unit,
~expiryError: string,
~setExpiryError: string => unit,
~onFocus=() => (),
~onBlur=() => (),
) => {
let localeObject = GetLocale.useGetLocalObj()
let {component, dangerColor, borderRadius, borderWidth} = ThemebasedStyle.useThemeBasedStyle()
let expiryRef = React.useRef(Nullable.null)

// Format expiry as "MM / YY" during input
let formatExpiryInput = val => {
let clearValue = val->clearSpaces
let expiryVal = clearValue->toInt

// FIX: Use singleDigitMonthThreshold (1) instead of 2
// This ensures "1" becomes "01 / " not just "1"
let formatted = if (
expiryVal >= singleDigitMonthThreshold &&
expiryVal <= 9 &&
clearValue->String.length == 1
) {
`0${clearValue} / `
} else if clearValue->String.length == 2 && expiryVal > maxMonth {
// Handle case where user types "13" - convert to "01 / 3"
let val = clearValue->String.split("")
`0${val->Array.get(0)->Option.getOr("")} / ${val->Array.get(1)->Option.getOr("")}`
} else {
clearValue
}

if clearValue->String.length >= 3 {
`${formatted->String.slice(~start=0, ~end=2)} / ${formatted->String.slice(
~start=2,
~end=4,
)}`
} else {
formatted
}
}

// Handle input change with auto-formatting
let handleExpiryChange = text => {
let formatted = formatExpiryInput(text)

// Only allow max maxExpiryLength characters ("MM / YY")
let limited = if formatted->String.length > maxExpiryLength {
formatted->String.substring(~start=0, ~end=maxExpiryLength)
} else {
formatted
}

setCardExpiry(limited)

// Clear error when user starts typing
if expiryError != "" {
setExpiryError("")
}
}

// Validate expiry on blur and send data to native
let handleExpiryBlur = _ => {
ignore(
validateAndSendExpiry(
~expiry=cardExpiry->String.trim,
~setExpiryError,
~setIsValid,
~localeObject,
),
)
onBlur()
}

<View style={s({width: 100.->pct})}>
<CustomInput
name={TestUtils.expiryInputTestId}
reference={Some(expiryRef)}
state=cardExpiry
setState={handleExpiryChange}
placeholder=localeObject.expiryPlaceholder
keyboardType=#"number-pad"
enableCrossIcon=false
isValid={isValid || cardExpiry->String.length == 0}
maxLength=Some(maxExpiryLength)
borderTopLeftRadius=borderRadius
borderTopRightRadius=borderRadius
borderBottomLeftRadius=borderRadius
borderBottomRightRadius=borderRadius
borderTopWidth=borderWidth
borderBottomWidth=borderWidth
borderLeftWidth=borderWidth
borderRightWidth=borderWidth
textColor={isValid || cardExpiry->String.length == 0 ? component.color : dangerColor}
onFocus={() => {
onFocus()
}}
onBlur={handleExpiryBlur}
animateLabel=localeObject.validThruText
/>
<UIUtils.RenderIf condition={expiryError != ""}>
<ErrorText text={Some(expiryError)} />
</UIUtils.RenderIf>
</View>
}
}

// Main CardExpiryWidget Component
@react.component
let make = () => {
let (nativeProp, setNativeProp) = React.useContext(NativePropContext.nativePropContext)
let (cardExpiry, setCardExpiry) = React.useState(_ => "")
let (isValid, setIsValid) = React.useState(_ => false)
let (expiryError, setExpiryError) = React.useState(_ => "")
let (isReady, setIsReady) = React.useState(_ => false)
let (confirm, setConfirm) = React.useState(_ => false)
let localeObject = GetLocale.useGetLocalObj()

// Send widget ready message to native
React.useEffect1(() => {
if !isReady {
NativeEventListener.sendReadyMessage("cardExpiry")
setIsReady(_ => true)
}
None
}, [isReady])

// Setup widget event listener for confirm/validate events
React.useEffect1(() => {
let handleWidgetEvent = (response: NativeEventListener.widgetResponse) => {
setNativeProp({
...nativeProp,
publishableKey: response.publishableKey,
clientSecret: response.clientSecret,
hyperParams: {
...nativeProp.hyperParams,
confirm: response.confirm,
},
})

if response.confirm {
setConfirm(_ => true)
}
}

let cleanup = NativeEventListener.setupWidgetEventListener(
~onWidgetEvent=handleWidgetEvent,
~walletType=NONE,
)

Some(cleanup)
}, [])

// Handle confirm action - send collected data to native
React.useEffect1(() => {
if confirm {
ignore(
validateAndSendExpiry(
~expiry=cardExpiry,
~setExpiryError=err => setExpiryError(_ => err),
~setIsValid=valid => setIsValid(_ => valid),
~localeObject,
),
)
setConfirm(_ => false)
}
None
}, [confirm])

// Update widget height based on content
React.useEffect1(() => {
HyperModule.updateWidgetHeight(widgetBaseHeight)
None
}, [])

<View
style={s({
flex: 1.,
width: 100.->pct,
paddingHorizontal: 16.->dp,
paddingVertical: 12.->dp,
backgroundColor: "transparent",
justifyContent: #center,
alignItems: #center,
})}>
<CardExpiryInput
cardExpiry={cardExpiry}
setCardExpiry={text => setCardExpiry(_ => text)}
isValid={isValid}
setIsValid={valid => setIsValid(_ => valid)}
expiryError={expiryError}
setExpiryError={err => setExpiryError(_ => err)}
/>
</View>
}
2 changes: 2 additions & 0 deletions src/routes/NavigationRouter.res
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ let make = () => {
| CardWidget => <CardWidget />
| CustomWidget(walletType) => <CustomWidget walletType />
| ExpressCheckoutWidget => <ExpressCheckoutWidget />
| CardExpiryWidget => <CardExpiryWidget />
| CvcWidget => <CvcWidget />
| Headless
| NoView
| PaymentMethodsManagement => React.null
Expand Down
13 changes: 12 additions & 1 deletion src/types/SdkTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ type configurationType = {
enablePartialLoading: bool,
displayMergedSavedMethods: bool,
disableBranding: bool,
hideConfirmButton: bool,
}

type sdkState =
Expand All @@ -247,6 +248,8 @@ type sdkState =
| CardWidget
| CustomWidget(payment_method_type_wallet)
| ExpressCheckoutWidget
| CvcWidget
| CardExpiryWidget
| PaymentMethodsManagement
| Headless
| NoView
Expand Down Expand Up @@ -281,6 +284,8 @@ let sdkStateToStrMapper = sdkState => {
| CardWidget => "CARD_FORM"
| CustomWidget(str) => str->widgetToStrMapper
| ExpressCheckoutWidget => "EXPRESS_CHECKOUT_WIDGET"
| CvcWidget => "CVC_WIDGET"
| CardExpiryWidget => "CARD_EXPIRY_WIDGET"
| PaymentMethodsManagement => "PAYMENT_METHODS_MANAGEMENT"
| Headless => "HEADLESS"
| NoView => "NO_VIEW"
Expand Down Expand Up @@ -312,6 +317,7 @@ type nativeProp = {
customBackendUrl: option<string>,
customLogUrl: option<string>,
sessionId: string,
widgetId: string,
from: string,
configuration: configurationType,
env: GlobalVars.envType,
Expand Down Expand Up @@ -786,6 +792,7 @@ let parseConfigurationDict = (configObj, from) => {
paymentSheetHeaderText: getOptionString(configObj, "paymentSheetHeaderLabel"),
savedPaymentScreenHeaderText: getOptionString(configObj, "savedPaymentSheetHeaderLabel"),
displayDefaultSavedPaymentIcon: getBool(configObj, "displayDefaultSavedPaymentIcon", true),
hideConfirmButton: getBool(configObj, "hideConfirmButton", false),
enablePartialLoading: getBool(configObj, "enablePartialLoading", false),
// customer: switch customerDict {
// | Some(obj) =>
Expand Down Expand Up @@ -869,7 +876,8 @@ let nativeJsonToRecord = (jsonFromNative, rootTag) => {
ephemeralKey: getOptionString(dictfromNative, "ephemeralKey"),
customBackendUrl,
customLogUrl,
sessionId: "",
sessionId: getString(dictfromNative, "sessionId", ""),
widgetId: getString(dictfromNative, "widgetId", ""),
sdkState: switch getString(dictfromNative, "type", "") {
| "payment" => PaymentSheet
| "tabSheet" => TabSheet
Expand All @@ -881,8 +889,11 @@ let nativeJsonToRecord = (jsonFromNative, rootTag) => {
| "google_pay" => CustomWidget(GOOGLE_PAY)
| "paypal" => CustomWidget(PAYPAL)
| "card" => CardWidget
| "cardExpiry" => CardExpiryWidget
| "paymentMethodsManagement" => PaymentMethodsManagement
| "expressCheckout" => ExpressCheckoutWidget
| "cvcWidget" => CvcWidget
| "cardExpiry" => CardExpiryWidget
| "headless" => Headless
| _ => NoView
},
Expand Down
4 changes: 2 additions & 2 deletions src/utility/reusableCodeFromWeb/ErrorHooks.res
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ let useErrorWarningValidationOnLoad = () => {
| WidgetTabSheet =>
showErrorOrWarning(ErrorUtils.errorWarning.invalidPk, ())
| HostedCheckout => showErrorOrWarning(ErrorUtils.errorWarning.invalidPk, ())
| CardWidget | CustomWidget(_) | ExpressCheckoutWidget => ()
| CardWidget | CustomWidget(_) | ExpressCheckoutWidget | CvcWidget | CardExpiryWidget => ()
| Headless => showErrorOrWarning(ErrorUtils.errorWarning.invalidPk, ())
| NoView | PaymentMethodsManagement => ()
}
Expand All @@ -61,7 +61,7 @@ let useErrorWarningValidationOnLoad = () => {
| WidgetTabSheet =>
showErrorOrWarning(ErrorUtils.errorWarning.invalidFormat, ~dynamicStr, ())
| HostedCheckout => showErrorOrWarning(ErrorUtils.errorWarning.invalidFormat, ~dynamicStr, ())
| CardWidget | CustomWidget(_) | ExpressCheckoutWidget => ()
| CardWidget | CustomWidget(_) | ExpressCheckoutWidget | CvcWidget | CardExpiryWidget => ()
| Headless => showErrorOrWarning(ErrorUtils.errorWarning.invalidFormat, ~dynamicStr, ())
| NoView | PaymentMethodsManagement => ()
}
Expand Down
Loading