feat: Chargebee card component
This commit is contained in:
parent
70a00fd89a
commit
5522b2f1f7
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/components/PaymentPage/methods/Card/styles.ts
Normal file
18
src/components/PaymentPage/methods/Card/styles.ts
Normal 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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
19
src/payment/types.ts
Normal 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
|
||||||
@ -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([
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user