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,18 +8,19 @@ interface ModalProps {
}
function Modal({ open, children, onClose }: ModalProps): JSX.Element {
const handleClose = () => {
const handleClose = (event: React.MouseEvent) => {
if (event.target !== event.currentTarget) return
onClose?.()
}
if (!open) return <></>
return (
<div className='modal'>
<div className='modal' onClick={handleClose}>
<div className='modal-content'>
<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">
<path fillRule="evenodd" clipRule="evenodd" d="M12.7071 12.7071C12.3166 13.0976 11.6834 13.0976 11.2929 12.7071L6.29289 7.70711C5.90237 7.31658 5.90237 6.68342 6.29289 6.29289L11.2929 1.29289C11.6834 0.902369 12.3166 0.902369 12.7071 1.29289C13.0976 1.68342 13.0976 2.31658 12.7071 2.70711L8.41421 7L12.7071 11.2929C13.0976 11.6834 13.0976 12.3166 12.7071 12.7071Z" fill="#858DA5" stroke="#858DA5" strokeLinecap="round" strokeLinejoin="round"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M1.29289 12.7071C1.68342 13.0976 2.31658 13.0976 2.70711 12.7071L7.70711 7.70711C8.09763 7.31658 8.09763 6.68342 7.70711 6.29289L2.70711 1.29289C2.31658 0.902369 1.68342 0.902369 1.29289 1.29289C0.902369 1.68342 0.902369 2.31658 1.29289 2.70711L5.58579 7L1.29289 11.2929C0.902369 11.6834 0.902369 12.3166 1.29289 12.7071Z" fill="#858DA5" stroke="#858DA5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
<path fillRule="evenodd" clipRule="evenodd" d="M12.7071 12.7071C12.3166 13.0976 11.6834 13.0976 11.2929 12.7071L6.29289 7.70711C5.90237 7.31658 5.90237 6.68342 6.29289 6.29289L11.2929 1.29289C11.6834 0.902369 12.3166 0.902369 12.7071 1.29289C13.0976 1.68342 13.0976 2.31658 12.7071 2.70711L8.41421 7L12.7071 11.2929C13.0976 11.6834 13.0976 12.3166 12.7071 12.7071Z" fill="#858DA5" stroke="#858DA5" strokeLinecap="round" strokeLinejoin="round"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M1.29289 12.7071C1.68342 13.0976 2.31658 13.0976 2.70711 12.7071L7.70711 7.70711C8.09763 7.31658 8.09763 6.68342 7.70711 6.29289L2.70711 1.29289C2.31658 0.902369 1.68342 0.902369 1.29289 1.29289C0.902369 1.68342 0.902369 2.31658 1.29289 2.70711L5.58579 7L1.29289 11.2929C0.902369 11.6834 0.902369 12.3166 1.29289 12.7071Z" fill="#858DA5" stroke="#858DA5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</button>
{children}
</div>

View File

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

View File

@ -15,11 +15,13 @@ import Title from '../Title'
import secure from './secure.png'
import './styles.css'
import { useState } from 'react'
import Loader from '../Loader'
function PaymentPage(): JSX.Element {
const { t } = useTranslation()
const { applePay } = usePayment()
const [open, setOpen] = useState(false)
const isLoading = applePay === null
const isApplePayAvailable = import.meta.env.PROD && applePay?.canMakePayments()
const email = useSelector(selectors.selectEmail)
@ -27,18 +29,22 @@ function PaymentPage(): JSX.Element {
<>
<UserHeader email={email} />
<section className='page'>
<div className='page-header'>
{ isApplePayAvailable ? <ApplePayBanner /> : <GooglePayBanner /> }
<img src={secure} alt='100% Secure' />
</div>
<Title variant='h1' className='mb-45'>{t('choose_payment')}</Title>
{ isApplePayAvailable ? <ApplePayButton /> : <GooglePayButton /> }
<div className='payment-divider'>{t('or').toUpperCase()}</div>
<CardButton onClick={() => setOpen(true)} />
<p className='payment-warining'>
{t('will_be_charged', { strongText: <strong>{t('trial_price')}</strong> })}
</p>
<CardModal open={open} onClose={() => setOpen(false)} />
{ isLoading ? <Loader /> : (
<>
<div className='page-header'>
{ isApplePayAvailable ? <ApplePayBanner /> : <GooglePayBanner /> }
<img src={secure} alt='100% Secure' />
</div>
<Title variant='h1' className='mb-45'>{t('choose_payment')}</Title>
{ isApplePayAvailable ? <ApplePayButton /> : <GooglePayButton /> }
<div className='payment-divider'>{t('or').toUpperCase()}</div>
<CardButton onClick={() => setOpen(true)} />
<p className='payment-warining'>
{t('will_be_charged', { strongText: <strong>{t('trial_price')}</strong> })}
</p>
<CardModal open={open} onClose={() => setOpen(false)} />
</>
)}
</section>
</>
)

View File

@ -1,58 +1,119 @@
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState, ChangeEvent } from 'react'
import {
CardCVV, CardComponent, CardExpiry, CardNumber, Provider
} from '@chargebee/chargebee-js-react-wrapper'
import ChargebeeComponents from '@chargebee/chargebee-js-react-wrapper/dist/components/ComponentGroup'
import { usePayment } from '../../../../payment'
import Modal from '../../../Modal'
import Title from '../../../Title'
import MainButton from '../../../MainButton'
import Loader from '../../../Loader'
import visa from './visa.svg'
import mastercard from './mastercard.svg'
import amex from './amex.svg'
import diners from './diners.svg'
import discover from './discover.svg'
import { cardStyles } from './styles'
interface CardModalProps {
open: boolean
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 {
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 locale = i18n.language
const isInit = status === 'idle'
const isLoading = status === 'loading'
const isTokenizing = status === 'tokenizing'
const isDisabled = status !== 'ready'
const { chargebee } = usePayment()
const payWithCard = () => {
// cardRef.current?.tokenize()
const handleReady = () => setStatus('filling')
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 (
<Modal open={open} onClose={onClose}>
<div className='payment-modal-header'>
<Title variant='h3' className='mb-0'>{t('card')}</Title>
<div className='payment-card-list'>
<img src={visa} alt='Visa Card' />
<img src={mastercard} alt='Mastercard Card' />
<img src={amex} alt='Amex Card' />
<img src={diners} alt='Diners Card' />
<img src={discover} alt='Discover Card' />
<Modal open={open} onClose={handleClose}>
{ isLoading ? <div className='payment-loader'><Loader /></div> : null}
<div style={{ display: isLoading ? 'none' : 'block' }}>
<div className='payment-modal-header'>
<Title variant='h3' className='mb-0'>{t('card')}</Title>
<div className='payment-card-list'>
<img src={visa} alt='Visa Card' />
<img src={mastercard} alt='Mastercard Card' />
<img src={amex} alt='Amex Card' />
<img src={diners} alt='Diners Card' />
<img src={discover} alt='Discover Card' />
</div>
</div>
<Provider cbInstance={chargebee}>
<CardComponent ref={cardRef} locale={locale} styles={cardStyles} onReady={handleReady}>
<div className="payment-input"><CardNumber onChange={handleChange} /></div>
<div className='payment-group'>
<div className="payment-input"><CardExpiry onChange={handleChange} /></div>
<div className="payment-input"><CardCVV onChange={handleChange} /></div>
</div>
</CardComponent>
</Provider>
<p className='payment-inforamtion'>{t('charged_only')}</p>
<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">
<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>
</svg>
{t('start_trial')}
</>
) }
</MainButton>
</div>
<Provider cbInstance={chargebee}>
<CardComponent ref={cardRef} locale={locale} icon={false} className='payment-chargebee'>
<CardNumber className="payment-input" />
<CardExpiry className="payment-input"/>
<CardCVV className="payment-input"/>
</CardComponent>
</Provider>
<p className='payment-inforamtion'>{t('charged_only')}</p>
<MainButton onClick={payWithCard} disabled>
<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 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>
Start 7-Day Trial
</MainButton>
</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;
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",
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",
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 { PaymentIntent } from '@chargebee/chargebee-js-types'
import { PaymentContext } from './PaymentContext'
interface ApplePayHandler {
canMakePayments: () => boolean
setPaymentIntent: (paymentIntent: PaymentIntent) => void
mountPaymentButton: (id: string) => Promise<void>
handlePayment: () => Promise<PaymentIntent>
}
import { ApplePayHandler, GooglePayHandler } from './types'
export const usePayment = () => {
const chargebee = useContext(PaymentContext)
const [googlePay, setGooglePay] = useState(null)
const [applePay, setApplePay] = useState<ApplePayHandler | null>(null)
const [googlePay, setGooglePay] = useState<GooglePayHandler>(null)
const [applePay, setApplePay] = useState<ApplePayHandler>(null)
useEffect(() => {
Promise.all([