refact: error handling

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-06-18 11:38:57 +03:00
parent a98faabafb
commit 2bc71feb3e
12 changed files with 110 additions and 95 deletions

View File

@ -1,37 +1,4 @@
import { createContext } from 'react' import { createContext } from 'react'
import { createMethod } from './utils' import type { ApiContextValue } from './api'
import {
User,
Auras,
Element,
Elements,
AuthTokens,
Assets,
AssetCategories,
DailyForecasts,
SubscriptionItems,
SubscriptionCheckout,
SubscriptionStatus,
SubscriptionReceipts,
PaymentIntents,
} from './resources'
export interface ApiContextValue {
auth: ReturnType<typeof createMethod<AuthTokens.Payload, AuthTokens.Response>>
getElement: ReturnType<typeof createMethod<Element.Payload, Element.Response>>
getElements: ReturnType<typeof createMethod<Elements.Payload, Elements.Response>>
getUser: ReturnType<typeof createMethod<User.GetPayload, User.Response>>
updateUser: ReturnType<typeof createMethod<User.PatchPayload, User.Response>>
getAssets: ReturnType<typeof createMethod<Assets.Payload, Assets.Response>>
getAssetCategories: ReturnType<typeof createMethod<AssetCategories.Payload, AssetCategories.Response>>
getDailyForecasts: ReturnType<typeof createMethod<DailyForecasts.Payload, DailyForecasts.Response>>
getAuras: ReturnType<typeof createMethod<Auras.Payload, Auras.Response>>
getSubscriptionItems: ReturnType<typeof createMethod<SubscriptionItems.Payload, SubscriptionItems.Response>>
getSubscriptionCheckout: ReturnType<typeof createMethod<SubscriptionCheckout.Payload, SubscriptionCheckout.Response>>
getSubscriptionStatus: ReturnType<typeof createMethod<SubscriptionStatus.Payload, SubscriptionStatus.Response>>,
getSubscriptionReceipt: ReturnType<typeof createMethod<SubscriptionReceipts.GetPayload, SubscriptionReceipts.Response>>,
createSubscriptionReceipt: ReturnType<typeof createMethod<SubscriptionReceipts.Payload, SubscriptionReceipts.Response>>,
createPaymentIntent: ReturnType<typeof createMethod<PaymentIntents.Payload, PaymentIntents.Response>>
}
export const ApiContext = createContext<ApiContextValue>({} as ApiContextValue) export const ApiContext = createContext<ApiContextValue>({} as ApiContextValue)

View File

@ -1,4 +1,3 @@
import { ApiContextValue } from './ApiContext'
import { createMethod } from './utils' import { createMethod } from './utils'
import { import {
User, User,
@ -34,6 +33,8 @@ const api = {
createPaymentIntent: createMethod<PaymentIntents.Payload, PaymentIntents.Response>(PaymentIntents.createRequest) createPaymentIntent: createMethod<PaymentIntents.Payload, PaymentIntents.Response>(PaymentIntents.createRequest)
} }
export type ApiContextValue = typeof api
export function createApi(): ApiContextValue { export function createApi(): ApiContextValue {
return api return api
} }

59
src/api/errors.ts Normal file
View File

@ -0,0 +1,59 @@
export interface FullDomainError {
title: string
message?: string
detail?: string
source?: {
pointer: string
parameter: string
}
}
export type ShortDomainError = FullDomainError['title']
export interface BaseDomainError {
base: ShortDomainError[]
}
export interface ErrorResponse {
errors: BaseDomainError | FullDomainError[] | ShortDomainError[]
}
export interface ErrorPayload extends ErrorResponse {
statusCode: number
}
export type MaybeError = ShortDomainError | FullDomainError | undefined
export class ApiError extends Error {
readonly body: ErrorResponse['errors']
readonly statusCode: number
constructor(payload: ErrorPayload) {
super('Caught an error while fetching the API Server endpoint...')
this.name = 'ApiError'
this.body = payload.errors
this.statusCode = payload.statusCode
}
}
export function isErrorResponse<R>(data: R | ErrorResponse): data is ErrorResponse {
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
const firstError = errors.at(0)
if (firstError === undefined) {
return `Unknown Error occurred from Server with status code ${error.statusCode}`
}
if (isShortDomainError(firstError)) {
return firstError
}
if ('message' in firstError && firstError.message) {
return firstError.message
}
return firstError.title
}
export function isShortDomainError(error: MaybeError): error is ShortDomainError {
return typeof error === 'string'
}

View File

@ -3,4 +3,5 @@ export * from './useApiCall'
export * from './ApiContext' export * from './ApiContext'
export * from './api' export * from './api'
export * from './types' export * from './types'
export * from './errors'
export * from './resources' export * from './resources'

View File

@ -1,20 +1,3 @@
export interface AuthError {
title: string
detail: string
source: {
pointer: string
parameter: string
}
}
export interface ApiError {
base: string[]
}
export interface ErrorResponse {
errors: AuthError[] | ApiError | string[]
}
export type AuthToken = string export type AuthToken = string
export interface AuthPayload { export interface AuthPayload {

View File

@ -1,24 +1,34 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react'
import { ApiError } from './errors'
interface HookResult<T> { interface HookResult<T> {
isPending: boolean
error: Error | null
data: T | null data: T | null
error: ApiError | null
isPending: boolean
state: ApiCallState
} }
type ApiMethod<T> = () => Promise<T> type ApiMethod<T> = () => Promise<T>
type ApiCallState = 'idle' | 'pending' | 'success' | 'error'
export function useApiCall<T>(apiMethod: ApiMethod<T>): HookResult<T> { export function useApiCall<T>(apiMethod: ApiMethod<T>): HookResult<T> {
const [data, setData] = useState<T | null>(null) const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<ApiError | null>(null)
const [isPending, setIsPending] = useState<boolean>(true) const [state, setState] = useState<ApiCallState>('idle')
const isPending = state === 'pending'
useEffect(() => { useEffect(() => {
setState('pending')
apiMethod() apiMethod()
.then((data: T) => setData(data)) .then((data: T) => {
.catch((error: Error) => setError(error)) setData(data)
.finally(() => setIsPending(false)) setState('success')
})
.catch((error: ApiError) => {
setError(error)
setState('error')
})
}, [apiMethod]) }, [apiMethod])
return { isPending, error, data } return { isPending, error, data, state }
} }

View File

@ -1,25 +1,18 @@
import { AuthToken } from "./types" import { AuthToken } from './types'
import { ErrorResponse } from "./types" import { ErrorResponse, isErrorResponse, ApiError } from './errors'
class ApiError extends Error {
constructor(message: string) {
super(message)
this.name = this.constructor.name
}
}
export function createMethod<P, R>(createRequest: (payload: P) => Request) { export function createMethod<P, R>(createRequest: (payload: P) => Request) {
return async (payload: P): Promise<R> => { return async (payload: P): Promise<R> => {
const request = createRequest(payload) const request = createRequest(payload)
const response = await fetch(request) const response = await fetch(request)
const data: R & ErrorResponse = await response.json() const data: R | ErrorResponse = await response.json()
const statusCode = response.status
if (response.ok) return data if (isErrorResponse<R>(data)) {
throw new ApiError({ ...data, statusCode })
}
const error = Array.isArray(data.errors) ? data.errors[0] : data.errors.base[0] return data
const errorMessage = typeof error === 'object' ? error.title : error
throw new ApiError(errorMessage)
} }
} }

View File

@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux'
import { actions, selectors } from '../../store' import { actions, selectors } from '../../store'
import { getClientTimezone } from '../../locales' import { getClientTimezone } from '../../locales'
import { useAuth } from '../../auth' import { useAuth } from '../../auth'
import { useApi } from '../../api' import { useApi, ApiError, extractErrorMessage } from '../../api'
import Title from '../Title' import Title from '../Title'
import Policy from '../Policy' import Policy from '../Policy'
import EmailInput from './EmailInput' import EmailInput from './EmailInput'
@ -24,7 +24,7 @@ function EmailEnterPage(): JSX.Element {
const birthday = useSelector(selectors.selectBirthday) const birthday = useSelector(selectors.selectBirthday)
const [isDisabled, setIsDisabled] = useState(true) const [isDisabled, setIsDisabled] = useState(true)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<ApiError | null>(null)
const timezone = getClientTimezone() const timezone = getClientTimezone()
const locale = i18n.language const locale = i18n.language
const handleValidEmail = (email: string) => { const handleValidEmail = (email: string) => {
@ -52,7 +52,7 @@ function EmailEnterPage(): JSX.Element {
dispatch(actions.subscriptionPlan.setAll(item_prices)) dispatch(actions.subscriptionPlan.setAll(item_prices))
}) })
.then(() => navigate(routes.client.subscription())) .then(() => navigate(routes.client.subscription()))
.catch((error: Error) => setError(error)) .catch((error: ApiError) => setError(error))
.finally(() => setIsLoading(false)) .finally(() => setIsLoading(false))
} }
@ -77,7 +77,7 @@ function EmailEnterPage(): JSX.Element {
<MainButton onClick={handleClick} disabled={isDisabled}> <MainButton onClick={handleClick} disabled={isDisabled}>
{isLoading ? <Loader color={LoaderColor.White} /> : t('continue')} {isLoading ? <Loader color={LoaderColor.White} /> : t('continue')}
</MainButton> </MainButton>
<ErrorText size='medium' isShown={Boolean(error)} message={error?.message} /> <ErrorText size='medium' isShown={Boolean(error)} message={error ? extractErrorMessage(error) : null} />
</section> </section>
) )
} }

View File

@ -1,8 +1,8 @@
import './styles.css' import './styles.css'
type ErrorTextProps = { type ErrorTextProps = {
isShown: boolean
message: string | null | undefined message: string | null | undefined
isShown?: boolean
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
} }

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useId } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { PaymentIntent } from '@chargebee/chargebee-js-types' import { PaymentIntent } from '@chargebee/chargebee-js-types'
import { useApi, useApiCall } from '../../../../api' import { useApi, useApiCall, extractErrorMessage } from '../../../../api'
import { usePayment, ApplePayButtonOptions } from '../../../../payment' import { usePayment, ApplePayButtonOptions } from '../../../../payment'
import { useAuth } from '../../../../auth' import { useAuth } from '../../../../auth'
import Loader from '../../../Loader' import Loader from '../../../Loader'
@ -49,7 +49,7 @@ export function ApplePayButton(): JSX.Element {
return isPending ? <Loader /> : ( return isPending ? <Loader /> : (
<div id={buttonId} style={{ height: 60 }}>{ <div id={buttonId} style={{ height: 60 }}>{
error ? <ErrorText message={error.message} isShown={true} size='large'/> : null error ? <ErrorText message={extractErrorMessage(error)} isShown={true} size='large'/> : null
}</div> }</div>
) )
} }

View File

@ -40,7 +40,7 @@ interface ChargebeeTokenizeResult {
vaultToken: string vaultToken: string
} }
type Status = 'idle' | 'loading' | 'filling' | 'tokenizing' | 'ready' | 'success' | 'error' type Status = 'idle' | 'loading' | 'filling' | 'subscribing' | 'ready' | 'success' | 'error'
const initCompletedFields = { const initCompletedFields = {
number: false, number: false,
@ -65,8 +65,6 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element {
const locale = i18n.language const locale = i18n.language
const isInit = status === 'idle' const isInit = status === 'idle'
const isLoading = status === 'loading' const isLoading = status === 'loading'
const isTokenizing = status === 'tokenizing'
const isDisabled = status !== 'ready'
const { chargebee } = usePayment() const { chargebee } = usePayment()
const handleReady = () => setStatus('filling') const handleReady = () => setStatus('filling')
const handleClose = () => { const handleClose = () => {
@ -77,20 +75,23 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element {
setFields((state) => ({ ...state, [field]: complete && !error })) setFields((state) => ({ ...state, [field]: complete && !error }))
} }
const payWithCard = () => { const payWithCard = () => {
setStatus('tokenizing') setStatus('subscribing')
cardRef.current?.tokenize({}) cardRef.current?.tokenize({})
.then((result: ChargebeeTokenizeResult) => { .then((result: ChargebeeTokenizeResult) => {
return api.createSubscriptionReceipt({ token, itemPriceId, gwToken: result.token }) return api.createSubscriptionReceipt({ token, itemPriceId, gwToken: result.token })
}) })
.then(() => console.log('Success payment by Card')) .then(() => {
.then(() => navigate(routes.client.wallpaper())) setStatus('success')
navigate(routes.client.wallpaper())
console.log('Success payment by Card')
})
.catch(console.error) .catch(console.error)
.finally(() => setStatus('success'))
} }
useEffect(() => { useEffect(() => {
if (status !== 'filling' && status !== 'ready') return
setStatus(isReady(fields) ? 'ready' : 'filling') setStatus(isReady(fields) ? 'ready' : 'filling')
}, [fields]) }, [fields, status])
useEffect(() => { useEffect(() => {
if (isInit) setStatus('loading') if (isInit) setStatus('loading')
@ -120,8 +121,8 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element {
</CardComponent> </CardComponent>
</Provider> </Provider>
<p className='payment-inforamtion'>{t('charged_only')}</p> <p className='payment-inforamtion'>{t('charged_only')}</p>
<MainButton color='blue' onClick={payWithCard} disabled={isDisabled}> <MainButton color='blue' onClick={payWithCard} disabled={status !== 'ready'}>
{ isTokenizing ? <Loader /> : ( { status === 'subscribing' ? <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>

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useId } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { PaymentIntent } from '@chargebee/chargebee-js-types' import { PaymentIntent } from '@chargebee/chargebee-js-types'
import { useApi, useApiCall } from '../../../../api' import { extractErrorMessage, useApi, useApiCall } from '../../../../api'
import { usePayment, GooglePayButtonOptions } from '../../../../payment' import { usePayment, GooglePayButtonOptions } from '../../../../payment'
import { useAuth } from '../../../../auth' import { useAuth } from '../../../../auth'
import Loader from '../../../Loader' import Loader from '../../../Loader'
@ -48,7 +48,7 @@ export function GooglePayButton(): JSX.Element {
return isPending ? <Loader /> : ( return isPending ? <Loader /> : (
<div id={buttonId} style={{ height: 60 }}>{ <div id={buttonId} style={{ height: 60 }}>{
error ? <ErrorText message={error.message} isShown={true} size='large'/> : null error ? <ErrorText message={extractErrorMessage(error)} isShown={true} size='large'/> : null
}</div> }</div>
) )
} }