diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx new file mode 100644 index 0000000000..c4e48f7cf2 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel } from 'react-bootstrap' +import Tooltip from '../../../../../shared/components/tooltip' +import Icon from '../../../../../shared/components/icon' +import useValidateField from '../../../hooks/use-validate-field' +import classnames from 'classnames' +import { callFnsInSequence } from '../../../../../utils/functions' + +type AddressFirstLineProps = { + errorFields: Record | undefined + value: string + onChange: (e: React.ChangeEvent) => void +} + +function AddressFirstLine({ + errorFields, + value, + onChange, +}: AddressFirstLineProps) { + const { t } = useTranslation() + const { validate, isValid } = useValidateField() + + return ( + + + {t('address_line_1')}{' '} + + + + + + {!isValid && ( + + {t('this_field_is_required')} + + )} + + ) +} + +export default AddressFirstLine diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx new file mode 100644 index 0000000000..1072d604f7 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel, Button } from 'react-bootstrap' +import classnames from 'classnames' + +type AddressSecondLineProps = { + errorFields: Record | undefined + value: string + onChange: (e: React.ChangeEvent) => void +} + +function AddressSecondLine({ + errorFields, + value, + onChange, +}: AddressSecondLineProps) { + const { t } = useTranslation() + const [showAddressSecondLine, setShowAddressSecondLine] = useState(false) + + if (showAddressSecondLine) { + return ( + + {t('address_second_line_optional')} + + + ) + } + + return ( + + ) +} + +export default AddressSecondLine diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx new file mode 100644 index 0000000000..28eff1f4ed --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx @@ -0,0 +1,58 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel } from 'react-bootstrap' +import classnames from 'classnames' +import { CardElementChangeState } from '../../../../../../../types/recurly/elements' +import { ElementsInstance } from 'recurly__recurly-js' + +type CardElementProps = { + className?: string + elements: ElementsInstance + onChange: (state: CardElementChangeState) => void +} + +function CardElement({ className, elements, onChange }: CardElementProps) { + const { t } = useTranslation() + const [showCardElementInvalid, setShowCardElementInvalid] = + useState() + const cardRef = useRef(null) + + // Card initialization + useEffect(() => { + if (!cardRef.current) return + + const card = elements.CardElement({ + displayIcon: true, + inputType: 'mobileSelect', + style: { + fontColor: '#5d6879', + placeholder: {}, + invalid: { + fontColor: '#a93529', + }, + }, + }) + + card.attach(cardRef.current) + card.on('change', state => { + setShowCardElementInvalid(!state.focus && !state.empty && !state.valid) + onChange(state) + }) + }, [elements, onChange]) + + return ( + + {t('card_details')} +
+ {showCardElementInvalid && ( + + {t('card_details_are_not_valid')} + + )} + + ) +} + +export default CardElement diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx index 390bf234a6..f3271a6d66 100644 --- a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx +++ b/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx @@ -1,5 +1,375 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { usePaymentContext } from '../../../context/payment-context' +import { Row, Col, Alert } from 'react-bootstrap' +import PriceSwitchHeader from './price-switch-header' +import PaymentMethodToggle from './payment-method-toggle' +import CardElement from './card-element' +import FirstName from './first-name' +import LastName from './last-name' +import AddressFirstLine from './address-first-line' +import AddressSecondLine from './address-second-line' +import PostalCode from './postal-code' +import CountrySelect from './country-select' +import CompanyDetails from './company-details' +import CouponCode from './coupon-code' +import TosAgreementNotice from './tos-agreement-notice' +import SubmitButton from './submit-button' +import getMeta from '../../../../../utils/meta' +import { postJSON } from '../../../../../infrastructure/fetch-json' +import * as eventTracking from '../../../../../infrastructure/event-tracking' +import classnames from 'classnames' +import { + TokenPayload, + RecurlyError, + ElementsInstance, + PayPalInstance, +} from 'recurly__recurly-js' +import { PricingFormState } from '../../../context/types/payment-context-value' +import { CreateError } from '../../../../../../../types/subscription/api' +import { CardElementChangeState } from '../../../../../../../types/recurly/elements' + function CheckoutPanel() { - return

Checkout panel

+ const { t } = useTranslation() + const { + couponError, + plan, + pricingFormState, + pricing, + recurlyLoadError, + setPricingFormState, + trialLength, + taxes, + } = usePaymentContext() + const showCouponField: boolean = getMeta('ol-showCouponField') + const ITMCampaign: string = getMeta('ol-itm_campaign', '') + const ITMContent: string = getMeta('ol-itm_content', '') + const ITMReferrer: string = getMeta('ol-itm_referrer', '') + const formRef = useRef(null) + const cachedRecurlyBillingToken = useRef() + const elements = useRef(recurly?.Elements()) + const [isProcessing, setIsProcessing] = useState(false) + const [errorFields, setErrorFields] = useState>() + const [genericError, setGenericError] = useState('') + const [paymentMethod, setPaymentMethod] = useState('credit_card') + const [cardIsValid, setCardIsValid] = useState() + const [formIsValid, setFormIsValid] = useState() + const [threeDSecureActionTokenId, setThreeDSecureActionTokenId] = + useState() + + const isCreditCardPaymentMethod = paymentMethod === 'credit_card' + const isPayPalPaymentMethod = paymentMethod === 'paypal' + const isAddCompanyDetailsChecked = Boolean( + formRef.current?.querySelector( + '#add-company-details-checkbox' + )?.checked + ) + + const completeSubscription = useCallback( + async ( + err?: RecurlyError | null, + recurlyBillingToken?: TokenPayload, + threeDResultToken?: TokenPayload + ) => { + if (recurlyBillingToken) { + // temporary store the billing token as it might be needed when + // re-sending the request after SCA authentication + cachedRecurlyBillingToken.current = recurlyBillingToken + } + + setErrorFields(undefined) + + if (err) { + eventTracking.sendMB('payment-page-form-error', err) + eventTracking.send('subscription-funnel', 'subscription-error') + + setIsProcessing(false) + setGenericError(err.message) + + const errFields = err.fields?.reduce( + (prev, cur) => { + return { ...prev, [cur]: true } + }, + {} + ) + setErrorFields(errFields) + } else { + const billingFields = ['company', 'vat_number'] as const + const billingInfo = billingFields.reduce((prev, cur) => { + if (isPayPalPaymentMethod && isAddCompanyDetailsChecked) { + prev[cur] = pricingFormState[cur] + } + + return prev + }, {} as Partial>) + + const postData = { + _csrf: getMeta('ol-csrfToken'), + recurly_token_id: cachedRecurlyBillingToken.current?.id, + recurly_three_d_secure_action_result_token_id: threeDResultToken?.id, + subscriptionDetails: { + currencyCode: pricing.current?.items.currency, + plan_code: pricing.current?.items.plan?.code, + coupon_code: pricing.current?.items.coupon?.code ?? '', + first_name: pricingFormState.first_name, + last_name: pricingFormState.last_name, + isPaypal: isPayPalPaymentMethod, + address: { + address1: pricingFormState.address1, + address2: pricingFormState.address2, + country: pricingFormState.country, + state: pricingFormState.state, + zip: pricingFormState.postal_code, + }, + ITMCampaign, + ITMContent, + ITMReferrer, + ...(Object.keys(billingInfo).length && { + billing_info: billingInfo, + }), + }, + } + + eventTracking.sendMB('payment-page-form-submit', { + currencyCode: postData.subscriptionDetails.currencyCode, + plan_code: postData.subscriptionDetails.plan_code, + coupon_code: postData.subscriptionDetails.coupon_code, + isPaypal: postData.subscriptionDetails.isPaypal, + }) + eventTracking.send( + 'subscription-funnel', + 'subscription-form-submitted', + postData.subscriptionDetails.plan_code + ) + + try { + await postJSON(`/user/subscription/create`, { body: postData }) + + eventTracking.sendMB('payment-page-form-success') + eventTracking.send( + 'subscription-funnel', + 'subscription-submission-success', + plan.planCode + ) + window.location.assign('/user/subscription/thank-you') + } catch (error) { + setIsProcessing(false) + + const { data } = error as CreateError + const errorMessage: string = + data.message || t('something_went_wrong_processing_the_request') + setGenericError(errorMessage) + + if (data.threeDSecureActionTokenId) { + setThreeDSecureActionTokenId(data.threeDSecureActionTokenId) + } + } + } + }, + [ + ITMCampaign, + ITMContent, + ITMReferrer, + isAddCompanyDetailsChecked, + isPayPalPaymentMethod, + plan.planCode, + pricing, + pricingFormState, + t, + ] + ) + + const payPal = useRef() + + useEffect(() => { + payPal.current = recurly.PayPal({ + display: { displayName: plan.name }, + }) + + payPal.current.on('token', token => { + completeSubscription(null, token) + }) + payPal.current.on('error', err => { + completeSubscription(err) + }) + payPal.current.on('cancel', () => { + setIsProcessing(false) + }) + + const payPalCopy = payPal.current + + return () => { + payPalCopy.destroy() + } + }, [completeSubscription, plan.name]) + + const handleCardChange = useCallback((state: CardElementChangeState) => { + setCardIsValid(state.valid) + }, []) + + if (recurlyLoadError) { + return ( + + {t('payment_provider_unreachable_error')} + + ) + } + + const handlePaymentMethod = (e: React.ChangeEvent) => { + setPaymentMethod(e.target.value) + } + + const handleChange = ( + e: React.ChangeEvent, + name: keyof PricingFormState + ) => { + setPricingFormState(s => ({ ...s, [name]: e.target.value })) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!recurly || !elements.current) return + + setIsProcessing(true) + + if (isPayPalPaymentMethod) { + payPal.current?.start() + return + } + + const { + company: _1, + vat_number: _2, + ...tokenDataWithoutCompanyDetails + } = pricingFormState + + const tokenData = isAddCompanyDetailsChecked + ? { ...pricingFormState } + : tokenDataWithoutCompanyDetails + + recurly.token(elements.current, tokenData, completeSubscription) + } + + const handleFormValidation = () => { + setFormIsValid(Boolean(formRef.current?.checkValidity())) + } + + const isFormValid = (): boolean => { + if (isPayPalPaymentMethod) { + return pricingFormState.country !== '' + } else { + return Boolean(formIsValid && cardIsValid) + } + } + + return ( + <> +
+ +
+ {genericError && ( + + {genericError} + + )} + {couponError && ( + + {couponError} + + )} + + {elements.current && ( + + )} + {isCreditCardPaymentMethod && ( + + + handleChange(e, 'first_name')} + /> + + + handleChange(e, 'last_name')} + /> + + + )} + handleChange(e, 'address1')} + /> + handleChange(e, 'address2')} + /> + + + handleChange(e, 'postal_code')} + /> + + + handleChange(e, 'country')} + /> + + + + {showCouponField && ( + handleChange(e, 'coupon')} + /> + )} + {isPayPalPaymentMethod && + t('proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay')} +
+
+ + {isCreditCardPaymentMethod && + (trialLength ? t('upgrade_cc_btn') : t('upgrade_now'))} + {isPayPalPaymentMethod && t('proceed_to_paypal')} + +
+ + +
+ + ) } export default CheckoutPanel diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx new file mode 100644 index 0000000000..067476ad38 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel } from 'react-bootstrap' +import { usePaymentContext } from '../../../context/payment-context' +import { PricingFormState } from '../../../context/types/payment-context-value' + +type CompanyDetailsProps = { + taxesCount: number +} + +function CompanyDetails(props: CompanyDetailsProps) { + const { t } = useTranslation() + const [addCompanyDetailsChecked, setAddCompanyDetailsChecked] = + useState(false) + const { pricingFormState, setPricingFormState, applyVatNumber } = + usePaymentContext() + + const handleAddCompanyDetails = (e: React.ChangeEvent) => { + setAddCompanyDetailsChecked(e.target.checked) + } + + const handleChange = ( + e: React.ChangeEvent, + name: keyof PricingFormState + ) => { + setPricingFormState(s => ({ ...s, [name]: e.target.value })) + } + + const handleApplyVatNumber = (e: React.FocusEvent) => { + applyVatNumber(e.target.value) + } + + return ( + <> + +
+ + + {t('add_company_details')} + +
+
+ {addCompanyDetailsChecked && ( + <> + + {t('company_name')} + handleChange(e, 'company')} + value={pricingFormState.company} + /> + + {props.taxesCount > 0 && ( + + {t('vat_number')} + handleChange(e, 'vat_number')} + onBlur={handleApplyVatNumber} + value={pricingFormState.vat_number} + /> + + )} + + )} + + ) +} + +export default CompanyDetails diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx new file mode 100644 index 0000000000..358983e4fa --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel } from 'react-bootstrap' +import classnames from 'classnames' +import useValidateField from '../../../hooks/use-validate-field' +import countries from '../../../data/countries' +import { callFnsInSequence } from '../../../../../utils/functions' +import { PricingFormState } from '../../../context/types/payment-context-value' +import { usePaymentContext } from '../../../context/payment-context' + +type CountrySelectProps = { + errorFields: Record | undefined + value: PricingFormState['country'] + onChange: (e: React.ChangeEvent) => void +} + +function CountrySelect(props: CountrySelectProps) { + const { t } = useTranslation() + const { validate, isValid } = useValidateField() + const { updateCountry } = usePaymentContext() + + const handleUpdateCountry = (e: React.ChangeEvent) => { + updateCountry(e.target.value as PricingFormState['country']) + } + + return ( + + {t('country')} + + {!isValid && ( + + {t('this_field_is_required')} + + )} + + ) +} + +export default CountrySelect diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx new file mode 100644 index 0000000000..401bd1c404 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel } from 'react-bootstrap' +import { usePaymentContext } from '../../../context/payment-context' + +type CouponCodeProps = { + value: string + onChange: (e: React.ChangeEvent) => void +} + +function CouponCode(props: CouponCodeProps) { + const { t } = useTranslation() + const { addCoupon } = usePaymentContext() + + const handleApplyCoupon = (e: React.FocusEvent) => { + addCoupon(e.target.value) + } + + return ( + + {t('coupon_code')} + + + ) +} + +export default CouponCode diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx new file mode 100644 index 0000000000..7f2b7a5fb1 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel } from 'react-bootstrap' +import useValidateField from '../../../hooks/use-validate-field' +import classnames from 'classnames' +import { callFnsInSequence } from '../../../../../utils/functions' + +type FirstNameProps = { + errorFields: Record | undefined + value: string + onChange: (e: React.ChangeEvent) => void +} + +function FirstName(props: FirstNameProps) { + const { t } = useTranslation() + const { validate, isValid } = useValidateField() + + return ( + + {t('first_name')} + + {!isValid && ( + + {t('this_field_is_required')} + + )} + + ) +} + +export default FirstName diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx new file mode 100644 index 0000000000..38f7f2237c --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel } from 'react-bootstrap' +import useValidateField from '../../../hooks/use-validate-field' +import classnames from 'classnames' +import { callFnsInSequence } from '../../../../../utils/functions' + +type LastNameProps = { + errorFields: Record | undefined + value: string + onChange: (e: React.ChangeEvent) => void +} + +function LastName(props: LastNameProps) { + const { t } = useTranslation() + const { validate, isValid } = useValidateField() + + return ( + + {t('last_name')} + + {!isValid && ( + + {t('this_field_is_required')} + + )} + + ) +} + +export default LastName diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx new file mode 100644 index 0000000000..7e2e7244e1 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel, Col } from 'react-bootstrap' +import Icon from '../../../../../shared/components/icon' + +type PaymentMethodToggleProps = { + onChange: (e: React.ChangeEvent) => void + paymentMethod: string +} + +function PaymentMethodToggle(props: PaymentMethodToggleProps) { + const { t } = useTranslation() + + return ( + +
+
+ + + + + {t('card_payment')}  + + {' '} + + + + + + + + + + PayPal  + + + + + + +
+
+ ) +} + +export default PaymentMethodToggle diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx new file mode 100644 index 0000000000..a37a4e96d6 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { FormGroup, ControlLabel } from 'react-bootstrap' +import useValidateField from '../../../hooks/use-validate-field' +import classnames from 'classnames' +import { callFnsInSequence } from '../../../../../utils/functions' + +type PostalCodeProps = { + errorFields: Record | undefined + value: string + onChange: (e: React.ChangeEvent) => void +} + +function PostalCode(props: PostalCodeProps) { + const { t } = useTranslation() + const { validate, isValid } = useValidateField() + + return ( + + {t('postal_code')} + + {!isValid && ( + + {t('this_field_is_required')} + + )} + + ) +} + +export default PostalCode diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx new file mode 100644 index 0000000000..0513b84b6c --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next' +import { Row, Col } from 'react-bootstrap' +import { Plan } from '../../../../../../../types/subscription/plan' + +type PriceSwitchHeaderProps = { + planCode: Plan['planCode'] + planCodes: Array +} + +function PriceSwitchHeader({ planCode, planCodes }: PriceSwitchHeaderProps) { + const { t } = useTranslation() + const showStudentDisclaimer = planCodes.includes(planCode) + + return ( +
+ + +

{t('select_a_payment_method')}

+ +
+ {showStudentDisclaimer && ( + + +

{t('student_disclaimer')}

+ +
+ )} +
+ ) +} + +export default PriceSwitchHeader diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx new file mode 100644 index 0000000000..9bc2afc669 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next' +import { Button } from 'react-bootstrap' +import Icon from '../../../../../shared/components/icon' + +type CardSubmitButtonProps = { + isProcessing: boolean + isFormValid: boolean + children: React.ReactNode +} + +function SubmitButton(props: CardSubmitButtonProps) { + const { t } = useTranslation() + + return ( + + ) +} + +export default SubmitButton diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx new file mode 100644 index 0000000000..4f08a7ed8c --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next' +import { Alert } from 'react-bootstrap' + +function ThreeDSecure() { + const { t } = useTranslation() + + return ( +
+ + {t('card_must_be_authenticated_by_3dsecure')} + +
+ {/* {threeDSecureFlowError && <>{threeDSecureFlowError.message}} */} + {/* */} +
+
+ ) +} + +export default ThreeDSecure diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx new file mode 100644 index 0000000000..dd9ad38ecb --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx @@ -0,0 +1,17 @@ +import { Trans } from 'react-i18next' + +function TosAgreementNotice() { + return ( +

+ , + ]} + /> +

+ ) +} + +export default TosAgreementNotice diff --git a/services/web/frontend/js/features/subscription/hooks/use-validate-field.tsx b/services/web/frontend/js/features/subscription/hooks/use-validate-field.tsx new file mode 100644 index 0000000000..2c33612bc3 --- /dev/null +++ b/services/web/frontend/js/features/subscription/hooks/use-validate-field.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' + +type Target = HTMLInputElement | HTMLSelectElement + +function useValidateField() { + const [isValid, setIsValid] = useState(true) + + const validate = (e: T) => { + let isValid = e.target.checkValidity() + + if (e.target.required) { + isValid = isValid && Boolean(e.target.value.trim().length) + } + + setIsValid(isValid) + } + + return { validate, isValid } +} + +export default useValidateField diff --git a/services/web/frontend/js/utils/functions.ts b/services/web/frontend/js/utils/functions.ts new file mode 100644 index 0000000000..3cdbf0d6c6 --- /dev/null +++ b/services/web/frontend/js/utils/functions.ts @@ -0,0 +1,6 @@ +export function callFnsInSequence< + Args, + Fn extends ((...args: Args[]) => void) | void +>(...fns: Fn[]) { + return (...args: Args[]) => fns.forEach(fn => fn?.(...args)) +} diff --git a/services/web/types/recurly/elements.ts b/services/web/types/recurly/elements.ts new file mode 100644 index 0000000000..56a79c4a3d --- /dev/null +++ b/services/web/types/recurly/elements.ts @@ -0,0 +1,23 @@ +export interface CardElementChangeState { + brand: string + cvv: { + empty: boolean + focus: boolean + valid: boolean + } + empty: boolean + expiry: { + empty: boolean + focus: boolean + valid: boolean + } + firstSix: string + focus: boolean + lastFour: string + number: { + empty: boolean + focus: boolean + valid: boolean + } + valid: boolean +}