feat: Chargebee card component

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-06-08 19:06:11 +03:00
parent 70a00fd89a
commit 5522b2f1f7
9 changed files with 184 additions and 56 deletions

View File

@ -8,12 +8,13 @@ interface ModalProps {
} }
function Modal({ open, children, onClose }: ModalProps): JSX.Element { function Modal({ open, children, onClose }: ModalProps): JSX.Element {
const handleClose = () => { const handleClose = (event: React.MouseEvent) => {
if (event.target !== event.currentTarget) return
onClose?.() onClose?.()
} }
if (!open) return <></> if (!open) return <></>
return ( return (
<div className='modal'> <div className='modal' onClick={handleClose}>
<div className='modal-content'> <div className='modal-content'>
<button className='modal-close-btn' onClick={handleClose}> <button className='modal-close-btn' onClick={handleClose}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@ -50,10 +50,10 @@
max-width: 100%; max-width: 100%;
min-height: 48px; min-height: 48px;
width: 100%; width: 100%;
color: #121620;
} }
.modal .main-btn:disabled { .modal .main-btn:disabled {
color: #121620;
background: #c7c7c7; background: #c7c7c7;
cursor: not-allowed; cursor: not-allowed;
opacity: 100%; opacity: 100%;
@ -61,5 +61,9 @@
.modal .main-btn svg { .modal .main-btn svg {
margin-right: 12px; margin-right: 12px;
fill: #fff;
}
.modal .main-btn:disabled svg {
fill: #121620; fill: #121620;
} }

View File

@ -15,11 +15,13 @@ import Title from '../Title'
import secure from './secure.png' import secure from './secure.png'
import './styles.css' import './styles.css'
import { useState } from 'react' import { useState } from 'react'
import Loader from '../Loader'
function PaymentPage(): JSX.Element { function PaymentPage(): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const { applePay } = usePayment() const { applePay } = usePayment()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const isLoading = applePay === null
const isApplePayAvailable = import.meta.env.PROD && applePay?.canMakePayments() const isApplePayAvailable = import.meta.env.PROD && applePay?.canMakePayments()
const email = useSelector(selectors.selectEmail) const email = useSelector(selectors.selectEmail)
@ -27,6 +29,8 @@ function PaymentPage(): JSX.Element {
<> <>
<UserHeader email={email} /> <UserHeader email={email} />
<section className='page'> <section className='page'>
{ isLoading ? <Loader /> : (
<>
<div className='page-header'> <div className='page-header'>
{ isApplePayAvailable ? <ApplePayBanner /> : <GooglePayBanner /> } { isApplePayAvailable ? <ApplePayBanner /> : <GooglePayBanner /> }
<img src={secure} alt='100% Secure' /> <img src={secure} alt='100% Secure' />
@ -39,6 +43,8 @@ function PaymentPage(): JSX.Element {
{t('will_be_charged', { strongText: <strong>{t('trial_price')}</strong> })} {t('will_be_charged', { strongText: <strong>{t('trial_price')}</strong> })}
</p> </p>
<CardModal open={open} onClose={() => setOpen(false)} /> <CardModal open={open} onClose={() => setOpen(false)} />
</>
)}
</section> </section>
</> </>
) )

View File

@ -1,33 +1,87 @@
import { useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState, ChangeEvent } from 'react'
import { import {
CardCVV, CardComponent, CardExpiry, CardNumber, Provider CardCVV, CardComponent, CardExpiry, CardNumber, Provider
} from '@chargebee/chargebee-js-react-wrapper' } from '@chargebee/chargebee-js-react-wrapper'
import ChargebeeComponents from '@chargebee/chargebee-js-react-wrapper/dist/components/ComponentGroup'
import { usePayment } from '../../../../payment' import { usePayment } from '../../../../payment'
import Modal from '../../../Modal' import Modal from '../../../Modal'
import Title from '../../../Title' import Title from '../../../Title'
import MainButton from '../../../MainButton' import MainButton from '../../../MainButton'
import Loader from '../../../Loader'
import visa from './visa.svg' import visa from './visa.svg'
import mastercard from './mastercard.svg' import mastercard from './mastercard.svg'
import amex from './amex.svg' import amex from './amex.svg'
import diners from './diners.svg' import diners from './diners.svg'
import discover from './discover.svg' import discover from './discover.svg'
import { cardStyles } from './styles'
interface CardModalProps { interface CardModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
} }
interface Field {
cardType: string | undefined
complete: boolean
empty: boolean
error: Error | undefined
field: 'number' | 'expiry' | 'cvv'
key: string | undefined
type: string
}
type Status = 'idle' | 'loading' | 'filling' | 'tokenizing' | 'ready' | 'success' | 'error'
const initCompletedFields = {
number: false,
expiry: false,
cvv: false,
}
type CompletedFields = typeof initCompletedFields
const isReady = (fields: CompletedFields) => Object.values(fields).every((complete: boolean) => complete)
export function CardModal({ open, onClose }: CardModalProps): JSX.Element { export function CardModal({ open, onClose }: CardModalProps): JSX.Element {
const cardRef = useRef(null) const cardRef = useRef<ChargebeeComponents>(null)
const [status, setStatus] = useState<Status>('idle')
const [fields, setFields] = useState(initCompletedFields)
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const locale = i18n.language const locale = i18n.language
const isInit = status === 'idle'
const isLoading = status === 'loading'
const isTokenizing = status === 'tokenizing'
const isDisabled = status !== 'ready'
const { chargebee } = usePayment() const { chargebee } = usePayment()
const payWithCard = () => { const handleReady = () => setStatus('filling')
// cardRef.current?.tokenize() const handleClose = () => {
setStatus('loading')
onClose()
} }
const handleChange = ({ field, complete, error }: ChangeEvent & Field) => {
setFields((state) => ({ ...state, [field]: complete && !error }))
}
const payWithCard = () => {
setStatus('tokenizing')
cardRef.current?.tokenize({})
.then(console.log)
.catch(console.error)
.finally(() => setStatus('success'))
}
useEffect(() => {
setStatus(isReady(fields) ? 'ready' : 'filling')
}, [fields])
useEffect(() => {
if (isInit) setStatus('loading')
}, [isInit])
return ( return (
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={handleClose}>
{ isLoading ? <div className='payment-loader'><Loader /></div> : null}
<div style={{ display: isLoading ? 'none' : 'block' }}>
<div className='payment-modal-header'> <div className='payment-modal-header'>
<Title variant='h3' className='mb-0'>{t('card')}</Title> <Title variant='h3' className='mb-0'>{t('card')}</Title>
<div className='payment-card-list'> <div className='payment-card-list'>
@ -39,20 +93,27 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element {
</div> </div>
</div> </div>
<Provider cbInstance={chargebee}> <Provider cbInstance={chargebee}>
<CardComponent ref={cardRef} locale={locale} icon={false} className='payment-chargebee'> <CardComponent ref={cardRef} locale={locale} styles={cardStyles} onReady={handleReady}>
<CardNumber className="payment-input" /> <div className="payment-input"><CardNumber onChange={handleChange} /></div>
<CardExpiry className="payment-input"/> <div className='payment-group'>
<CardCVV className="payment-input"/> <div className="payment-input"><CardExpiry onChange={handleChange} /></div>
<div className="payment-input"><CardCVV onChange={handleChange} /></div>
</div>
</CardComponent> </CardComponent>
</Provider> </Provider>
<p className='payment-inforamtion'>{t('charged_only')}</p> <p className='payment-inforamtion'>{t('charged_only')}</p>
<MainButton onClick={payWithCard} disabled> <MainButton color='blue' onClick={payWithCard} disabled={isDisabled}>
{ isTokenizing ? <Loader /> : (
<>
<svg width="13" height="16" viewBox="0 0 13 16" xmlns="http://www.w3.org/2000/svg"> <svg width="13" height="16" viewBox="0 0 13 16" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5556 6.24219H1.44444C0.6467 6.24219 0 6.97481 0 7.87855V13.6058C0 14.5096 0.6467 15.2422 1.44444 15.2422H11.5556C12.3533 15.2422 13 14.5096 13 13.6058V7.87855C13 6.97481 12.3533 6.24219 11.5556 6.24219Z"></path> <path d="M11.5556 6.24219H1.44444C0.6467 6.24219 0 6.97481 0 7.87855V13.6058C0 14.5096 0.6467 15.2422 1.44444 15.2422H11.5556C12.3533 15.2422 13 14.5096 13 13.6058V7.87855C13 6.97481 12.3533 6.24219 11.5556 6.24219Z"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M6.5 0.242188C4.29086 0.242188 2.5 2.03305 2.5 4.24219V8.24219H10.5V4.24219C10.5 2.03305 8.70914 0.242188 6.5 0.242188ZM6.5 1.24219C4.84315 1.24219 3.5 2.58533 3.5 4.24219V7.24219H9.5V4.24219C9.5 2.58533 8.15685 1.24219 6.5 1.24219Z"></path> <path fillRule="evenodd" clipRule="evenodd" d="M6.5 0.242188C4.29086 0.242188 2.5 2.03305 2.5 4.24219V8.24219H10.5V4.24219C10.5 2.03305 8.70914 0.242188 6.5 0.242188ZM6.5 1.24219C4.84315 1.24219 3.5 2.58533 3.5 4.24219V7.24219H9.5V4.24219C9.5 2.58533 8.15685 1.24219 6.5 1.24219Z"></path>
</svg> </svg>
Start 7-Day Trial {t('start_trial')}
</>
) }
</MainButton> </MainButton>
</div>
</Modal> </Modal>
) )
} }

View File

@ -0,0 +1,18 @@
export const cardStyles = {
base: {
color: '#121620',
lineHeight: '18px',
fontSize: '16px',
fontWeight: '400',
fontFamily: 'SF Pro Text, system-ui, sans-serif',
'::placeholder': {
color: '#8E8E93',
}
},
invalid: {
color: '#FF5758',
},
empty: {
fontWeight: '400',
},
}

View File

@ -62,3 +62,28 @@
flex-direction: column; flex-direction: column;
margin-bottom: 16px; margin-bottom: 16px;
} }
.payment-input {
padding-bottom: 4px;
border-radius: 10px;
border: 1px solid #c7c7c7 !important;
padding: 14px 12px;
width: 100%;
height: 48px;
text-rendering: optimizeLegibility;
margin-bottom: 12px;
}
.payment-group {
display: flex;
}
.payment-group > .payment-input:first-child {
margin-right: 12px;
}
.payment-loader {
display: flex;
justify-content: center;
align-items: center;
}

View File

@ -42,5 +42,6 @@ export default {
contact_us: "Contact us", contact_us: "Contact us",
will_be_charged: "You will be charged only <strongText>. We'll email your a reminder before your trial period ends.", will_be_charged: "You will be charged only <strongText>. We'll email your a reminder before your trial period ends.",
trial_price: "$1 for your 7-day trial", trial_price: "$1 for your 7-day trial",
start_trial: "Start 7-Day Trial",
}, },
} }

19
src/payment/types.ts Normal file
View File

@ -0,0 +1,19 @@
import { PaymentIntent } from '@chargebee/chargebee-js-types'
interface Handler {
mountPaymentButton: (id: string) => Promise<void>
handlePayment: () => Promise<PaymentIntent>
}
interface ApplePay extends Handler {
canMakePayments: () => boolean
}
interface GooglePay extends Handler {
getPaymentIntent: () => PaymentIntent
setPaymentIntent: (paymentIntent: PaymentIntent) => void
updatePaymentIntent: (paymentIntent: PaymentIntent) => void
}
export type ApplePayHandler = ApplePay | null
export type GooglePayHandler = GooglePay | null

View File

@ -1,18 +1,11 @@
import { useContext, useEffect, useState } from 'react' import { useContext, useEffect, useState } from 'react'
import { PaymentIntent } from '@chargebee/chargebee-js-types'
import { PaymentContext } from './PaymentContext' import { PaymentContext } from './PaymentContext'
import { ApplePayHandler, GooglePayHandler } from './types'
interface ApplePayHandler {
canMakePayments: () => boolean
setPaymentIntent: (paymentIntent: PaymentIntent) => void
mountPaymentButton: (id: string) => Promise<void>
handlePayment: () => Promise<PaymentIntent>
}
export const usePayment = () => { export const usePayment = () => {
const chargebee = useContext(PaymentContext) const chargebee = useContext(PaymentContext)
const [googlePay, setGooglePay] = useState(null) const [googlePay, setGooglePay] = useState<GooglePayHandler>(null)
const [applePay, setApplePay] = useState<ApplePayHandler | null>(null) const [applePay, setApplePay] = useState<ApplePayHandler>(null)
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([