feat: add error handling during the card payment process

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-06-19 19:17:43 +03:00
parent 8229ab3292
commit 8a37644d1c
10 changed files with 126 additions and 27 deletions

View File

@ -14,36 +14,58 @@ export interface BaseDomainError {
base: ShortDomainError[]
}
export interface ErrorResponse {
export interface ErrorListResponse {
errors: BaseDomainError | FullDomainError[] | ShortDomainError[]
}
export interface ErrorPayload extends ErrorResponse {
export interface SingleErrorResponse {
error: ShortDomainError
}
export type ErrorResponse = ErrorListResponse | SingleErrorResponse
export type ErrorPayload = {
body: ErrorResponse
statusCode: number
}
export type MaybeError = ShortDomainError | FullDomainError | undefined
export const buildUnknownError = (statusCode: number) => `Unknown Error occurred from Server with status code ${statusCode}`
export class ApiError extends Error {
readonly body: ErrorResponse['errors']
readonly body: ErrorResponse
readonly statusCode: number
constructor(payload: ErrorPayload) {
super('Caught an error while fetching the API Server endpoint...')
this.name = 'ApiError'
this.body = payload.errors
this.body = payload.body
this.statusCode = payload.statusCode
}
}
export function isErrorResponse<R>(data: R | ErrorResponse): data is ErrorResponse {
return isSingleErrorResponse<R>(data) || isErrorListResponse<R>(data)
}
export function isSingleErrorResponse<R>(data: R | ErrorResponse): data is SingleErrorResponse {
return typeof data === 'object' && data !== null && 'error' in data
}
export function isErrorListResponse<R>(data: R | ErrorResponse): data is ErrorListResponse {
return typeof data === 'object' && data !== null && 'errors' in data
}
export function extractErrorMessage(error: ApiError): string {
const errors = Array.isArray(error.body) ? error.body : error.body.base
export function isShortDomainError(error: MaybeError): error is ShortDomainError {
return typeof error === 'string'
}
export function extractErrorMessage(apiError: ApiError): string {
const body = isSingleErrorResponse(apiError.body) ? [apiError.body.error] : apiError.body.errors
const errors = Array.isArray(body) ? body : body.base
const firstError = errors.at(0)
if (firstError === undefined) {
return `Unknown Error occurred from Server with status code ${error.statusCode}`
return buildUnknownError(apiError.statusCode)
}
if (isShortDomainError(firstError)) {
return firstError
@ -53,7 +75,3 @@ export function extractErrorMessage(error: ApiError): string {
}
return firstError.title
}
export function isShortDomainError(error: MaybeError): error is ShortDomainError {
return typeof error === 'string'
}

View File

@ -1,5 +1,5 @@
import { AuthToken } from './types'
import { ErrorResponse, isErrorResponse, ApiError } from './errors'
import { ErrorResponse, isErrorResponse, ApiError, buildUnknownError } from './errors'
export function createMethod<P, R>(createRequest: (payload: P) => Request) {
return async (payload: P): Promise<R> => {
@ -8,8 +8,13 @@ export function createMethod<P, R>(createRequest: (payload: P) => Request) {
const data: R | ErrorResponse = await response.json()
const statusCode = response.status
if (!response.ok) {
const body = isErrorResponse<R>(data) ? data : { error: buildUnknownError(statusCode) }
throw new ApiError({ body, statusCode })
}
if (isErrorResponse<R>(data)) {
throw new ApiError({ ...data, statusCode })
throw new ApiError({ body: data, statusCode })
}
return data

View File

@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next'
import MainButton from '../MainButton'
import Modal from '../Modal'
import Title from '../Title'
import Policy from '../Policy'
interface ModalProps {
open: boolean
onClose: () => void
}
function ErrorModal({ open, onClose }: ModalProps): JSX.Element {
const { t } = useTranslation()
const supportLink = <a href='https://aura.wit.life/' target='_blank' rel='noopener noreferrer'>{t('our_support')}</a>
return (
<Modal open={open} onClose={onClose}>
<div className='modal-body error-modal'>
<div className='ta-c mb-24'>
<svg fill="#000000" height="60px" width="60px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" viewBox="0 0 27.963 27.963" xmlSpace="preserve">
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g id="SVGRepo_tracerCarrier" strokeLinecap="round" strokeLinejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<g>
<g id="c129_exclamation">
<path d="M13.983,0C6.261,0,0.001,6.259,0.001,13.979c0,7.724,6.26,13.984,13.982,13.984s13.98-6.261,13.98-13.984 C27.963,6.259,21.705,0,13.983,0z M13.983,26.531c-6.933,0-12.55-5.62-12.55-12.553c0-6.93,5.617-12.548,12.55-12.548 c6.931,0,12.549,5.618,12.549,12.548C26.531,20.911,20.913,26.531,13.983,26.531z"></path>
<polygon points="15.579,17.158 16.191,4.579 11.804,4.579 12.414,17.158 "></polygon>
<path d="M13.998,18.546c-1.471,0-2.5,1.029-2.5,2.526c0,1.443,0.999,2.528,2.444,2.528h0.056c1.499,0,2.469-1.085,2.469-2.528 C16.441,19.575,15.468,18.546,13.998,18.546z"></path>
</g>
</g>
</g>
</svg>
</div>
<Title variant='h3'>{t('error_processing')}</Title>
<blockquote className='blockquote'>{t('please_try_again')}</blockquote>
<Policy className='mb-24 ta-l' sizing='medium'>{t('any_dificulties', { supportLink })}</Policy>
<MainButton color='blue' onClick={onClose}>{t('try_again')}</MainButton>
</div>
</Modal>
)
}
export default ErrorModal

View File

@ -13,6 +13,7 @@ import {
CardButton,
CardModal
} from './methods'
import ErrorModal from './ErrorModal'
import UserHeader from '../UserHeader'
import Title from '../Title'
import Loader from '../Loader'
@ -23,7 +24,8 @@ import './styles.css'
function PaymentPage(): JSX.Element {
const { t } = useTranslation()
const { applePay } = usePayment()
const [open, setOpen] = useState(false)
const [openCardModal, setOpenCardModal] = useState(false)
const [openErrorModal, setOpenErrorModal] = useState(false)
const dispatch = useDispatch()
const navigate = useNavigate()
const isLoading = applePay === null
@ -33,7 +35,10 @@ function PaymentPage(): JSX.Element {
dispatch(actions.status.update('subscribed'))
navigate(routes.client.wallpaper())
}, [dispatch, navigate])
const onError = useCallback((error: Error) => console.error(error), [])
const onError = useCallback((error: Error) => {
console.error(error)
setOpenErrorModal(true)
}, [])
return (
<>
@ -52,11 +57,17 @@ function PaymentPage(): JSX.Element {
:
<GooglePayButton onSuccess={onSuccess} onError={onError} /> }
<div className='payment-divider'>{t('or').toUpperCase()}</div>
<CardButton onClick={() => setOpen(true)} />
<CardButton onClick={() => setOpenCardModal(true)} />
<p className='payment-warining'>
{t('will_be_charged', { strongText: <strong>{t('trial_price')}</strong> })}
</p>
<CardModal open={open} onClose={() => setOpen(false)} onSuccess={onSuccess} onError={onError} />
<CardModal
open={openCardModal}
onClose={() => setOpenCardModal(false)}
onSuccess={onSuccess}
onError={onError}
/>
<ErrorModal open={openErrorModal} onClose={() => setOpenErrorModal(false)} />
</>
)}
</section>

View File

@ -90,7 +90,7 @@ export function CardModal({ open, onClose, onSuccess, onError }: CardModalProps)
}
useEffect(() => {
if (status !== 'filling' && status !== 'ready') return
if (status !== 'filling' && status !== 'ready' && status !== 'error') return
setStatus(isReady(fields) ? 'ready' : 'filling')
}, [fields, status])

View File

@ -87,3 +87,15 @@
justify-content: center;
align-items: center;
}
.error-modal .title {
text-align: left;
}
.blockquote {
margin: 12px 0;
border-left: 3px solid #000;
padding-left: 12px;
line-height: 1.5;
font-weight: 500;
}

View File

@ -3,6 +3,7 @@ import './styles.css'
interface PolicyProps {
children: string
sizing?: 'small' | 'medium' | 'large'
className?: string
}
const sizes = {
@ -11,9 +12,9 @@ const sizes = {
large: 'policy--large',
}
function Policy({ children, sizing = 'small' }: PolicyProps): JSX.Element {
function Policy({ children, sizing = 'small', className = '' }: PolicyProps): JSX.Element {
return (
<div className={`policy ${sizes[sizing]}`}>
<div className={`policy ${sizes[sizing]} ${className}`}>
<p>{children}</p>
</div>
)

View File

@ -10,6 +10,8 @@ import UserHeader from '../UserHeader'
import CallToAction from '../CallToAction'
import routes from '../../routes'
const currency = Currency.USD
const locale = Locale.EN
const itemPriceId = 'aura-membership-2-week-USD'
const paymentItems = [
{
@ -24,9 +26,8 @@ function SubscriptionPage(): JSX.Element {
const navigate = useNavigate()
const email = useSelector(selectors.selectEmail)
const itemPrice = useSelector(selectors.selectPlanById(itemPriceId))
const currency = Currency.USD
const locale = Locale.EN
const handleClick = () => navigate(routes.client.paymentMethod())
const policyLink = <a href='https://aura.wit.life/' target='_blank' rel='noopener noreferrer'>{t('subscription_policy')}</a>
console.log({ itemPrice })
return (
<>
@ -36,11 +37,7 @@ function SubscriptionPage(): JSX.Element {
<Countdown start={10}/>
<PaymentTable items={paymentItems} currency={currency} locale={locale}/>
<MainButton onClick={handleClick}>{t('get_access')}</MainButton>
<Policy>
{t('subscription_text', {
policyLink: <a href='https://aura.wit.life/' target='_blank' rel='noopener noreferrer'>{t('subscription_policy')}</a>,
})}
</Policy>
<Policy>{t('subscription_text', { policyLink })}</Policy>
</section>
</>
)

View File

@ -110,3 +110,11 @@ a,button,div,input,select,textarea {
.pa {
position: absolute;
}
.ta-c {
text-align: center;
}
.ta-l {
text-align: left;
}

View File

@ -44,5 +44,10 @@ export default {
trial_price: "$1 for your 7-day trial",
start_trial: "Start 7-Day Trial",
analysis_background: "Analysis of personal background",
error_processing: "An error processing your order.",
please_try_again: "Please try again.",
any_dificulties: "If you have any difficulties with solving this problem, do not hesitate to contact <supportLink>",
our_support: "our support",
try_again: "Try again",
},
}