Merge pull request #11530 from overleaf/ii-payment-page-migration-checkout-panel

[web] Payment page migration checkout panel

GitOrigin-RevId: 04edf9961e0032d6fe3631c255e49dc1d4c3e0ca
This commit is contained in:
ilkin-overleaf 2023-02-06 17:15:55 +02:00 committed by Copybot
parent 85fbded781
commit 7f8d2e37d5
18 changed files with 1076 additions and 1 deletions

View file

@ -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<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function AddressFirstLine({
errorFields,
value,
onChange,
}: AddressFirstLineProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
return (
<FormGroup
controlId="address-line-1"
className={classnames({
'has-error': !isValid || errorFields?.address1,
})}
>
<ControlLabel>
{t('address_line_1')}{' '}
<Tooltip
id="tooltip-address"
description={t('this_address_will_be_shown_on_the_invoice')}
overlayProps={{ placement: 'right' }}
>
<Icon
type="question-circle"
aria-label={t('this_address_will_be_shown_on_the_invoice')}
/>
</Tooltip>
</ControlLabel>
<input
id="address-line-1"
className="form-control"
name="address1"
data-recurly="address1"
type="text"
required
maxLength={255}
onBlur={validate}
onChange={callFnsInSequence(validate, onChange)}
value={value}
/>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default AddressFirstLine

View file

@ -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<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function AddressSecondLine({
errorFields,
value,
onChange,
}: AddressSecondLineProps) {
const { t } = useTranslation()
const [showAddressSecondLine, setShowAddressSecondLine] = useState(false)
if (showAddressSecondLine) {
return (
<FormGroup
controlId="address-line-2"
className={classnames({ 'has-error': errorFields?.address2 })}
>
<ControlLabel>{t('address_second_line_optional')}</ControlLabel>
<input
id="address-line-2"
className="form-control"
name="address2"
data-recurly="address2"
type="text"
required
maxLength={255}
onChange={onChange}
value={value}
/>
</FormGroup>
)
}
return (
<Button
bsStyle="link"
onClick={() => setShowAddressSecondLine(true)}
className="mb-2 p-0"
>
+ {t('add_another_address_line')}
</Button>
)
}
export default AddressSecondLine

View file

@ -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<boolean>()
const cardRef = useRef<HTMLDivElement>(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 (
<FormGroup
className={classnames(className, { 'has-error': showCardElementInvalid })}
>
<ControlLabel>{t('card_details')}</ControlLabel>
<div ref={cardRef} />
{showCardElementInvalid && (
<span className="input-feedback-message">
{t('card_details_are_not_valid')}
</span>
)}
</FormGroup>
)
}
export default CardElement

View file

@ -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 <h3>Checkout panel</h3>
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<HTMLFormElement>(null)
const cachedRecurlyBillingToken = useRef<TokenPayload>()
const elements = useRef<ElementsInstance | undefined>(recurly?.Elements())
const [isProcessing, setIsProcessing] = useState(false)
const [errorFields, setErrorFields] = useState<Record<string, boolean>>()
const [genericError, setGenericError] = useState('')
const [paymentMethod, setPaymentMethod] = useState('credit_card')
const [cardIsValid, setCardIsValid] = useState<boolean>()
const [formIsValid, setFormIsValid] = useState<boolean>()
const [threeDSecureActionTokenId, setThreeDSecureActionTokenId] =
useState<string>()
const isCreditCardPaymentMethod = paymentMethod === 'credit_card'
const isPayPalPaymentMethod = paymentMethod === 'paypal'
const isAddCompanyDetailsChecked = Boolean(
formRef.current?.querySelector<HTMLInputElement>(
'#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<typeof errorFields>(
(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<Pick<PricingFormState, typeof billingFields[number]>>)
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<PayPalInstance>()
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 (
<Alert bsStyle="danger">
<strong>{t('payment_provider_unreachable_error')}</strong>
</Alert>
)
}
const handlePaymentMethod = (e: React.ChangeEvent<HTMLInputElement>) => {
setPaymentMethod(e.target.value)
}
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
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 (
<>
<div className={classnames({ hidden: threeDSecureActionTokenId })}>
<PriceSwitchHeader
planCode={plan.planCode}
planCodes={[
'student-annual',
'student-monthly',
'student_free_trial_7_days',
]}
/>
<form
noValidate
onSubmit={handleSubmit}
onChange={handleFormValidation}
ref={formRef}
>
{genericError && (
<Alert bsStyle="warning" className="small">
<strong>{genericError}</strong>
</Alert>
)}
{couponError && (
<Alert bsStyle="warning" className="small">
<strong>{couponError}</strong>
</Alert>
)}
<PaymentMethodToggle
onChange={handlePaymentMethod}
paymentMethod={paymentMethod}
/>
{elements.current && (
<CardElement
className={classnames({ hidden: !isCreditCardPaymentMethod })}
elements={elements.current}
onChange={handleCardChange}
/>
)}
{isCreditCardPaymentMethod && (
<Row>
<Col xs={6}>
<FirstName
errorFields={errorFields}
value={pricingFormState.first_name}
onChange={e => handleChange(e, 'first_name')}
/>
</Col>
<Col xs={6}>
<LastName
errorFields={errorFields}
value={pricingFormState.last_name}
onChange={e => handleChange(e, 'last_name')}
/>
</Col>
</Row>
)}
<AddressFirstLine
errorFields={errorFields}
value={pricingFormState.address1}
onChange={e => handleChange(e, 'address1')}
/>
<AddressSecondLine
errorFields={errorFields}
value={pricingFormState.address2}
onChange={e => handleChange(e, 'address2')}
/>
<Row>
<Col xs={4}>
<PostalCode
errorFields={errorFields}
value={pricingFormState.postal_code}
onChange={e => handleChange(e, 'postal_code')}
/>
</Col>
<Col xs={8}>
<CountrySelect
errorFields={errorFields}
value={pricingFormState.country}
onChange={e => handleChange(e, 'country')}
/>
</Col>
</Row>
<CompanyDetails taxesCount={taxes.length} />
{showCouponField && (
<CouponCode
value={pricingFormState.coupon}
onChange={e => handleChange(e, 'coupon')}
/>
)}
{isPayPalPaymentMethod &&
t('proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay')}
<hr className="thin" />
<div className="payment-submit">
<SubmitButton
isProcessing={isProcessing}
isFormValid={isFormValid()}
>
{isCreditCardPaymentMethod &&
(trialLength ? t('upgrade_cc_btn') : t('upgrade_now'))}
{isPayPalPaymentMethod && t('proceed_to_paypal')}
</SubmitButton>
</div>
<TosAgreementNotice />
</form>
</div>
</>
)
}
export default CheckoutPanel

View file

@ -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<HTMLInputElement>) => {
setAddCompanyDetailsChecked(e.target.checked)
}
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
name: keyof PricingFormState
) => {
setPricingFormState(s => ({ ...s, [name]: e.target.value }))
}
const handleApplyVatNumber = (e: React.FocusEvent<HTMLInputElement>) => {
applyVatNumber(e.target.value)
}
return (
<>
<FormGroup>
<div className="checkbox">
<ControlLabel>
<input
type="checkbox"
id="add-company-details-checkbox"
onChange={handleAddCompanyDetails}
/>
{t('add_company_details')}
</ControlLabel>
</div>
</FormGroup>
{addCompanyDetailsChecked && (
<>
<FormGroup controlId="company-name">
<ControlLabel>{t('company_name')}</ControlLabel>
<input
id="company-name"
className="form-control"
name="companyName"
data-recurly="company"
type="text"
onChange={e => handleChange(e, 'company')}
value={pricingFormState.company}
/>
</FormGroup>
{props.taxesCount > 0 && (
<FormGroup controlId="vat-number">
<ControlLabel>{t('vat_number')}</ControlLabel>
<input
id="vat-number"
className="form-control"
name="vatNumber"
data-recurly="vat_number"
type="text"
onChange={e => handleChange(e, 'vat_number')}
onBlur={handleApplyVatNumber}
value={pricingFormState.vat_number}
/>
</FormGroup>
)}
</>
)}
</>
)
}
export default CompanyDetails

View file

@ -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<string, boolean> | undefined
value: PricingFormState['country']
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void
}
function CountrySelect(props: CountrySelectProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
const { updateCountry } = usePaymentContext()
const handleUpdateCountry = (e: React.ChangeEvent<HTMLSelectElement>) => {
updateCountry(e.target.value as PricingFormState['country'])
}
return (
<FormGroup
controlId="country"
className={classnames({
'has-error': !isValid || props.errorFields?.country,
})}
>
<ControlLabel>{t('country')}</ControlLabel>
<select
id="country"
className="form-control"
name="country"
data-recurly="country"
required
onBlur={validate}
onChange={callFnsInSequence(
validate,
props.onChange,
handleUpdateCountry
)}
value={props.value}
>
<option disabled value="">
{t('country')}
</option>
<option disabled value="-">
--------------
</option>
{countries.map(country => (
<option value={country.code} key={country.name}>
{country.name}
</option>
))}
</select>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default CountrySelect

View file

@ -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<HTMLInputElement>) => void
}
function CouponCode(props: CouponCodeProps) {
const { t } = useTranslation()
const { addCoupon } = usePaymentContext()
const handleApplyCoupon = (e: React.FocusEvent<HTMLInputElement>) => {
addCoupon(e.target.value)
}
return (
<FormGroup controlId="coupon-code">
<ControlLabel>{t('coupon_code')}</ControlLabel>
<input
id="coupon-code"
className="form-control"
data-recurly="coupon"
type="text"
onBlur={handleApplyCoupon}
onChange={props.onChange}
value={props.value}
/>
</FormGroup>
)
}
export default CouponCode

View file

@ -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<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function FirstName(props: FirstNameProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
return (
<FormGroup
controlId="first-name"
className={classnames({
'has-error': !isValid || props.errorFields?.first_name,
})}
>
<ControlLabel>{t('first_name')}</ControlLabel>
<input
id="first-name"
className="form-control"
name="firstName"
data-recurly="first_name"
type="text"
required
maxLength={255}
onBlur={validate}
onChange={callFnsInSequence(validate, props.onChange)}
value={props.value}
/>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default FirstName

View file

@ -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<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function LastName(props: LastNameProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
return (
<FormGroup
controlId="last-name"
className={classnames({
'has-error': !isValid || props.errorFields?.last_name,
})}
>
<ControlLabel>{t('last_name')}</ControlLabel>
<input
id="last-name"
className="form-control"
name="lastName"
data-recurly="last_name"
type="text"
required
maxLength={255}
onBlur={validate}
onChange={callFnsInSequence(validate, props.onChange)}
value={props.value}
/>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default LastName

View file

@ -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<HTMLInputElement>) => void
paymentMethod: string
}
function PaymentMethodToggle(props: PaymentMethodToggleProps) {
const { t } = useTranslation()
return (
<FormGroup className="payment-method-toggle">
<hr className="thin" />
<div className="radio">
<Col xs={8}>
<ControlLabel>
<input
type="radio"
name="payment_method"
value="credit_card"
onChange={props.onChange}
checked={props.paymentMethod === 'credit_card'}
/>
<strong>
{t('card_payment')}&nbsp;
<span className="hidden-xs">
<Icon type="cc-visa" /> <Icon type="cc-mastercard" />{' '}
<Icon type="cc-amex" />
</span>
</strong>
</ControlLabel>
</Col>
<Col xs={4}>
<ControlLabel>
<input
type="radio"
name="payment_method"
value="paypal"
onChange={props.onChange}
checked={props.paymentMethod === 'paypal'}
/>
<strong>
PayPal&nbsp;
<span className="hidden-xs">
<Icon type="cc-paypal" />
</span>
</strong>
</ControlLabel>
</Col>
</div>
</FormGroup>
)
}
export default PaymentMethodToggle

View file

@ -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<string, boolean> | undefined
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
function PostalCode(props: PostalCodeProps) {
const { t } = useTranslation()
const { validate, isValid } = useValidateField()
return (
<FormGroup
controlId="postal-code"
className={classnames({
'has-error': !isValid || props.errorFields?.postal_code,
})}
>
<ControlLabel>{t('postal_code')}</ControlLabel>
<input
id="postal-code"
className="form-control"
name="postalCode"
data-recurly="postal_code"
type="text"
required
maxLength={255}
onBlur={validate}
onChange={callFnsInSequence(validate, props.onChange)}
value={props.value}
/>
{!isValid && (
<span className="input-feedback-message">
{t('this_field_is_required')}
</span>
)}
</FormGroup>
)
}
export default PostalCode

View file

@ -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<Plan['planCode']>
}
function PriceSwitchHeader({ planCode, planCodes }: PriceSwitchHeaderProps) {
const { t } = useTranslation()
const showStudentDisclaimer = planCodes.includes(planCode)
return (
<div className="price-switch-header">
<Row>
<Col xs={9}>
<h2>{t('select_a_payment_method')}</h2>
</Col>
</Row>
{showStudentDisclaimer && (
<Row>
<Col xs={12}>
<p className="student-disclaimer">{t('student_disclaimer')}</p>
</Col>
</Row>
)}
</div>
)
}
export default PriceSwitchHeader

View file

@ -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 (
<Button
type="submit"
bsStyle="primary"
className="btn-block"
disabled={props.isProcessing || !props.isFormValid}
>
{props.isProcessing && (
<>
<Icon type="spinner" spin />
<span className="sr-only">{t('processing')}</span>
</>
)}{' '}
{props.children}
</Button>
)
}
export default SubmitButton

View file

@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next'
import { Alert } from 'react-bootstrap'
function ThreeDSecure() {
const { t } = useTranslation()
return (
<div className="three-d-secure-container--react">
<Alert bsStyle="info" className="small" aria-live="assertive">
<strong>{t('card_must_be_authenticated_by_3dsecure')}</strong>
</Alert>
<div className="three-d-secure-recurly-container">
{/* {threeDSecureFlowError && <>{threeDSecureFlowError.message}</>} */}
{/* <ThreeDSecureAction {...props} /> */}
</div>
</div>
)
}
export default ThreeDSecure

View file

@ -0,0 +1,17 @@
import { Trans } from 'react-i18next'
function TosAgreementNotice() {
return (
<p className="tos-agreement-notice">
<Trans
i18nKey="by_subscribing_you_agree_to_our_terms_of_service"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/legal#Terms" target="_blank" rel="noopener noreferrer" />,
]}
/>
</p>
)
}
export default TosAgreementNotice

View file

@ -0,0 +1,21 @@
import { useState } from 'react'
type Target = HTMLInputElement | HTMLSelectElement
function useValidateField<T extends { target: Target }>() {
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

View file

@ -0,0 +1,6 @@
export function callFnsInSequence<
Args,
Fn extends ((...args: Args[]) => void) | void
>(...fns: Fn[]) {
return (...args: Args[]) => fns.forEach(fn => fn?.(...args))
}

View file

@ -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
}