mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
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:
parent
85fbded781
commit
7f8d2e37d5
18 changed files with 1076 additions and 1 deletions
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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')}
|
||||
<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
|
||||
<span className="hidden-xs">
|
||||
<Icon type="cc-paypal" />
|
||||
</span>
|
||||
</strong>
|
||||
</ControlLabel>
|
||||
</Col>
|
||||
</div>
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethodToggle
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
6
services/web/frontend/js/utils/functions.ts
Normal file
6
services/web/frontend/js/utils/functions.ts
Normal 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))
|
||||
}
|
23
services/web/types/recurly/elements.ts
Normal file
23
services/web/types/recurly/elements.ts
Normal 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
|
||||
}
|
Loading…
Reference in a new issue