diff --git a/src/api/ApiContext.ts b/src/api/ApiContext.ts index 95c4d78..8ff6d74 100644 --- a/src/api/ApiContext.ts +++ b/src/api/ApiContext.ts @@ -1,37 +1,4 @@ import { createContext } from 'react' -import { createMethod } from './utils' -import { - User, - Auras, - Element, - Elements, - AuthTokens, - Assets, - AssetCategories, - DailyForecasts, - SubscriptionItems, - SubscriptionCheckout, - SubscriptionStatus, - SubscriptionReceipts, - PaymentIntents, -} from './resources' - -export interface ApiContextValue { - auth: ReturnType> - getElement: ReturnType> - getElements: ReturnType> - getUser: ReturnType> - updateUser: ReturnType> - getAssets: ReturnType> - getAssetCategories: ReturnType> - getDailyForecasts: ReturnType> - getAuras: ReturnType> - getSubscriptionItems: ReturnType> - getSubscriptionCheckout: ReturnType> - getSubscriptionStatus: ReturnType>, - getSubscriptionReceipt: ReturnType>, - createSubscriptionReceipt: ReturnType>, - createPaymentIntent: ReturnType> -} +import type { ApiContextValue } from './api' export const ApiContext = createContext({} as ApiContextValue) diff --git a/src/api/api.ts b/src/api/api.ts index c3f4d29..dcf726f 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,4 +1,3 @@ -import { ApiContextValue } from './ApiContext' import { createMethod } from './utils' import { User, @@ -34,6 +33,8 @@ const api = { createPaymentIntent: createMethod(PaymentIntents.createRequest) } +export type ApiContextValue = typeof api + export function createApi(): ApiContextValue { return api } diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 0000000..598cfa1 --- /dev/null +++ b/src/api/errors.ts @@ -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(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' +} diff --git a/src/api/index.ts b/src/api/index.ts index 6f66119..acd1c34 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,4 +3,5 @@ export * from './useApiCall' export * from './ApiContext' export * from './api' export * from './types' +export * from './errors' export * from './resources' diff --git a/src/api/types.ts b/src/api/types.ts index 4a60c5f..f18d0ad 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 interface AuthPayload { diff --git a/src/api/useApiCall.ts b/src/api/useApiCall.ts index afb8705..f82f06a 100644 --- a/src/api/useApiCall.ts +++ b/src/api/useApiCall.ts @@ -1,24 +1,34 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react' +import { ApiError } from './errors' interface HookResult { - isPending: boolean - error: Error | null data: T | null + error: ApiError | null + isPending: boolean + state: ApiCallState } type ApiMethod = () => Promise +type ApiCallState = 'idle' | 'pending' | 'success' | 'error' export function useApiCall(apiMethod: ApiMethod): HookResult { const [data, setData] = useState(null) - const [error, setError] = useState(null) - const [isPending, setIsPending] = useState(true) + const [error, setError] = useState(null) + const [state, setState] = useState('idle') + const isPending = state === 'pending' useEffect(() => { + setState('pending') apiMethod() - .then((data: T) => setData(data)) - .catch((error: Error) => setError(error)) - .finally(() => setIsPending(false)) + .then((data: T) => { + setData(data) + setState('success') + }) + .catch((error: ApiError) => { + setError(error) + setState('error') + }) }, [apiMethod]) - return { isPending, error, data } + return { isPending, error, data, state } } diff --git a/src/api/utils.ts b/src/api/utils.ts index e6ad7e7..8e1447d 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,25 +1,18 @@ -import { AuthToken } from "./types" -import { ErrorResponse } from "./types" - -class ApiError extends Error { - constructor(message: string) { - super(message) - this.name = this.constructor.name - } -} +import { AuthToken } from './types' +import { ErrorResponse, isErrorResponse, ApiError } from './errors' export function createMethod(createRequest: (payload: P) => Request) { return async (payload: P): Promise => { const request = createRequest(payload) 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(data)) { + throw new ApiError({ ...data, statusCode }) + } - const error = Array.isArray(data.errors) ? data.errors[0] : data.errors.base[0] - const errorMessage = typeof error === 'object' ? error.title : error - - throw new ApiError(errorMessage) + return data } } diff --git a/src/components/EmailEnterPage/index.tsx b/src/components/EmailEnterPage/index.tsx index 1169b81..7d07266 100644 --- a/src/components/EmailEnterPage/index.tsx +++ b/src/components/EmailEnterPage/index.tsx @@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux' import { actions, selectors } from '../../store' import { getClientTimezone } from '../../locales' import { useAuth } from '../../auth' -import { useApi } from '../../api' +import { useApi, ApiError, extractErrorMessage } from '../../api' import Title from '../Title' import Policy from '../Policy' import EmailInput from './EmailInput' @@ -24,7 +24,7 @@ function EmailEnterPage(): JSX.Element { const birthday = useSelector(selectors.selectBirthday) const [isDisabled, setIsDisabled] = useState(true) const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) + const [error, setError] = useState(null) const timezone = getClientTimezone() const locale = i18n.language const handleValidEmail = (email: string) => { @@ -52,7 +52,7 @@ function EmailEnterPage(): JSX.Element { dispatch(actions.subscriptionPlan.setAll(item_prices)) }) .then(() => navigate(routes.client.subscription())) - .catch((error: Error) => setError(error)) + .catch((error: ApiError) => setError(error)) .finally(() => setIsLoading(false)) } @@ -77,7 +77,7 @@ function EmailEnterPage(): JSX.Element { {isLoading ? : t('continue')} - + ) } diff --git a/src/components/ErrorText/index.tsx b/src/components/ErrorText/index.tsx index d2d2a51..642406d 100644 --- a/src/components/ErrorText/index.tsx +++ b/src/components/ErrorText/index.tsx @@ -1,8 +1,8 @@ import './styles.css' type ErrorTextProps = { - isShown: boolean message: string | null | undefined + isShown?: boolean size?: 'small' | 'medium' | 'large' } diff --git a/src/components/PaymentPage/methods/ApplePay/Button.tsx b/src/components/PaymentPage/methods/ApplePay/Button.tsx index 835243a..c2e9e60 100644 --- a/src/components/PaymentPage/methods/ApplePay/Button.tsx +++ b/src/components/PaymentPage/methods/ApplePay/Button.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useId } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { PaymentIntent } from '@chargebee/chargebee-js-types' -import { useApi, useApiCall } from '../../../../api' +import { useApi, useApiCall, extractErrorMessage } from '../../../../api' import { usePayment, ApplePayButtonOptions } from '../../../../payment' import { useAuth } from '../../../../auth' import Loader from '../../../Loader' @@ -49,7 +49,7 @@ export function ApplePayButton(): JSX.Element { return isPending ? : (
{ - error ? : null + error ? : null }
) } diff --git a/src/components/PaymentPage/methods/Card/Modal.tsx b/src/components/PaymentPage/methods/Card/Modal.tsx index 6d633c9..afae86e 100644 --- a/src/components/PaymentPage/methods/Card/Modal.tsx +++ b/src/components/PaymentPage/methods/Card/Modal.tsx @@ -40,7 +40,7 @@ interface ChargebeeTokenizeResult { vaultToken: string } -type Status = 'idle' | 'loading' | 'filling' | 'tokenizing' | 'ready' | 'success' | 'error' +type Status = 'idle' | 'loading' | 'filling' | 'subscribing' | 'ready' | 'success' | 'error' const initCompletedFields = { number: false, @@ -65,8 +65,6 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element { const locale = i18n.language const isInit = status === 'idle' const isLoading = status === 'loading' - const isTokenizing = status === 'tokenizing' - const isDisabled = status !== 'ready' const { chargebee } = usePayment() const handleReady = () => setStatus('filling') const handleClose = () => { @@ -77,20 +75,23 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element { setFields((state) => ({ ...state, [field]: complete && !error })) } const payWithCard = () => { - setStatus('tokenizing') + setStatus('subscribing') cardRef.current?.tokenize({}) .then((result: ChargebeeTokenizeResult) => { return api.createSubscriptionReceipt({ token, itemPriceId, gwToken: result.token }) }) - .then(() => console.log('Success payment by Card')) - .then(() => navigate(routes.client.wallpaper())) + .then(() => { + setStatus('success') + navigate(routes.client.wallpaper()) + console.log('Success payment by Card') + }) .catch(console.error) - .finally(() => setStatus('success')) } useEffect(() => { + if (status !== 'filling' && status !== 'ready') return setStatus(isReady(fields) ? 'ready' : 'filling') - }, [fields]) + }, [fields, status]) useEffect(() => { if (isInit) setStatus('loading') @@ -120,8 +121,8 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element {

{t('charged_only')}

- - { isTokenizing ? : ( + + { status === 'subscribing' ? : ( <> diff --git a/src/components/PaymentPage/methods/GooglePay/Button.tsx b/src/components/PaymentPage/methods/GooglePay/Button.tsx index e51c53e..921a0bd 100644 --- a/src/components/PaymentPage/methods/GooglePay/Button.tsx +++ b/src/components/PaymentPage/methods/GooglePay/Button.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useId } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { PaymentIntent } from '@chargebee/chargebee-js-types' -import { useApi, useApiCall } from '../../../../api' +import { extractErrorMessage, useApi, useApiCall } from '../../../../api' import { usePayment, GooglePayButtonOptions } from '../../../../payment' import { useAuth } from '../../../../auth' import Loader from '../../../Loader' @@ -48,7 +48,7 @@ export function GooglePayButton(): JSX.Element { return isPending ? : (
{ - error ? : null + error ? : null }
) }