refact: error handling
This commit is contained in:
parent
a98faabafb
commit
2bc71feb3e
@ -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)
|
||||||
|
|||||||
@ -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
59
src/api/errors.ts
Normal 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'
|
||||||
|
}
|
||||||
@ -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'
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user