Swap tokens

This commit is contained in:
2025-07-06 14:00:44 +07:00
parent 8096db495a
commit c7dec76809
21 changed files with 1124 additions and 173 deletions

View File

@@ -14,7 +14,21 @@ const Modal: React.FC<IModalProps> = ({
return (
<div className="container mx-auto">
{showModal ? (
<form onSubmit={onSubmit}>
onSubmit ? (
<form onSubmit={onSubmit}>
<div className="modal modal-bottom sm:modal-middle modal-open">
<div className="modal-box !max-w-4xl !w-11/12">
<ModalHeader
titleHeader={titleHeader}
onClose={onClose}
onSubmit={onSubmit}
showModal={showModal}
/>
{children}
</div>
</div>
</form>
) : (
<div className="modal modal-bottom sm:modal-middle modal-open">
<div className="modal-box !max-w-4xl !w-11/12">
<ModalHeader
@@ -26,7 +40,7 @@ const Modal: React.FC<IModalProps> = ({
{children}
</div>
</div>
</form>
)
) : null}
</div>
)

View File

@@ -247,6 +247,49 @@ export class AccountClient extends AuthorizedApiBase {
}
return Promise.resolve<GmxClaimableSummary>(null as any);
}
account_SwapGmxTokens(name: string, request: SwapTokensRequest): Promise<SwapInfos> {
let url_ = this.baseUrl + "/Account/{name}/gmx-swap";
if (name === undefined || name === null)
throw new Error("The parameter 'name' must be defined.");
url_ = url_.replace("{name}", encodeURIComponent("" + name));
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(request);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.transformOptions(options_).then(transformedOptions_ => {
return this.http.fetch(url_, transformedOptions_);
}).then((_response: Response) => {
return this.processAccount_SwapGmxTokens(_response);
});
}
protected processAccount_SwapGmxTokens(response: Response): Promise<SwapInfos> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SwapInfos;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
console.log(_responseText)
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<SwapInfos>(null as any);
}
}
export class BacktestClient extends AuthorizedApiBase {
@@ -2871,68 +2914,22 @@ export interface RebateStatsData {
discountFactor?: number;
}
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
export interface SwapInfos {
success?: boolean;
hash?: string | null;
message?: string | null;
error?: string | null;
errorType?: string | null;
suggestion?: string | null;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
export interface SwapTokensRequest {
fromTicker: Ticker;
toTicker: Ticker;
amount: number;
orderType?: string | null;
triggerRatio?: number | null;
allowedSlippage?: number;
}
export enum Ticker {
@@ -3044,6 +3041,70 @@ export enum Ticker {
Unknown = "Unknown",
}
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
}
export interface RiskManagement {
adverseProbabilityThreshold: number;
favorableProbabilityThreshold: number;

View File

@@ -83,68 +83,22 @@ export interface RebateStatsData {
discountFactor?: number;
}
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
export interface SwapInfos {
success?: boolean;
hash?: string | null;
message?: string | null;
error?: string | null;
errorType?: string | null;
suggestion?: string | null;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
export interface SwapTokensRequest {
fromTicker: Ticker;
toTicker: Ticker;
amount: number;
orderType?: string | null;
triggerRatio?: number | null;
allowedSlippage?: number;
}
export enum Ticker {
@@ -256,6 +210,70 @@ export enum Ticker {
Unknown = "Unknown",
}
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
}
export interface RiskManagement {
adverseProbabilityThreshold: number;
favorableProbabilityThreshold: number;

View File

@@ -299,6 +299,7 @@ export type ICardPositionFlipped = {
export type IAccountRowDetail = {
balances: Balance[]
showTotal?: boolean
account?: Account
}
export type IGridTile = {

View File

@@ -0,0 +1,42 @@
import {useCallback, useState} from 'react'
import {extractErrorMessage} from '../utils/apiErrorHandler'
interface UseApiErrorReturn {
error: string | null
setError: (error: string | null) => void
clearError: () => void
handleError: (err: unknown) => string
handleApiErrorWithToast: (err: unknown, toast: any) => void
}
/**
* Custom hook for handling API errors consistently across components
* @returns Error handling utilities
*/
export function useApiError(): UseApiErrorReturn {
const [error, setError] = useState<string | null>(null)
const clearError = useCallback(() => {
setError(null)
}, [])
const handleError = useCallback((err: unknown): string => {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
return errorMessage
}, [])
const handleApiErrorWithToast = useCallback((err: unknown, toast: any) => {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
toast.update('error', `Error: ${errorMessage}`)
}, [])
return {
error,
setError,
clearError,
handleError,
handleApiErrorWithToast
}
}

View File

@@ -0,0 +1,263 @@
import React, {useState} from 'react'
import type {SubmitHandler} from 'react-hook-form'
import {useForm} from 'react-hook-form'
import {Account, AccountClient, Ticker,} from '../../../generated/ManagingApi'
import Modal from '../../../components/mollecules/Modal/Modal'
import useApiUrlStore from '../../../app/store/apiStore'
import {FormInput, Toast} from '../../../components/mollecules'
import {useApiError} from '../../../hooks/useApiError'
interface SwapModalProps {
isOpen: boolean
onClose: () => void
account: Account
fromTicker: Ticker
availableAmount: number
}
interface SwapFormInput {
fromTicker: Ticker
toTicker: Ticker
amount: number
orderType: string
triggerRatio?: number
allowedSlippage: number
}
const SwapModal: React.FC<SwapModalProps> = ({
isOpen,
onClose,
account,
fromTicker,
availableAmount,
}) => {
const [isLoading, setIsLoading] = useState(false)
const { error, setError, handleApiErrorWithToast } = useApiError()
const { apiUrl } = useApiUrlStore()
const client = new AccountClient({}, apiUrl)
const [selectedToTicker, setSelectedToTicker] = useState<Ticker>(Ticker.USDC)
const [selectedOrderType, setSelectedOrderType] = useState<string>('market')
const { register, handleSubmit, watch, setValue } = useForm<SwapFormInput>({
defaultValues: {
fromTicker: fromTicker,
toTicker: Ticker.USDC,
amount: availableAmount * 0.1, // Start with 10% of available amount
orderType: 'market',
allowedSlippage: 0.5,
}
})
const watchedAmount = watch('amount')
function setSelectedToTickerEvent(e: React.ChangeEvent<HTMLSelectElement>) {
setSelectedToTicker(e.target.value as Ticker)
}
function setSelectedOrderTypeEvent(e: React.ChangeEvent<HTMLSelectElement>) {
setSelectedOrderType(e.target.value)
}
const onSubmit: SubmitHandler<SwapFormInput> = async (form) => {
const t = new Toast(`Swapping ${form.amount} ${form.fromTicker} to ${form.toTicker} on ${account.name}`)
setIsLoading(true)
setError(null)
try {
const result = await client.account_SwapGmxTokens(
account.name,
{
fromTicker: form.fromTicker,
toTicker: form.toTicker,
amount: form.amount,
orderType: form.orderType,
triggerRatio: form.triggerRatio,
allowedSlippage: form.allowedSlippage,
}
)
if (result.success) {
t.update('success', `Swap successful! Hash: ${result.hash}`)
onClose()
} else {
console.log(result)
const errorMessage = result.error || result.message || 'Swap failed'
setError(errorMessage)
t.update('error', `Swap failed: ${errorMessage}`)
}
} catch (err) {
handleApiErrorWithToast(err, t)
} finally {
setIsLoading(false)
}
}
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault()
handleSubmit(onSubmit)(e)
}
const modalContent = (
<>
{isLoading ? (
<div className="text-center py-4">
<span className="loading loading-spinner loading-md"></span>
<p>Processing swap...</p>
</div>
) : error ? (
<div className="alert alert-error mb-4">
<p>{error}</p>
</div>
) : (
<>
<div className="mb-4">
<p className="mb-2">
<strong>Account:</strong> {account.name}
</p>
<p className="mb-2">
<strong>From:</strong> {fromTicker}
</p>
</div>
<form onSubmit={handleFormSubmit}>
<div className="space-y-4 mb-4">
<FormInput label="To Ticker" htmlFor="toTicker">
<select
className="select select-bordered w-full"
{...register('toTicker', {
onChange(e) {
setSelectedToTickerEvent(e)
},
value: selectedToTicker,
})}
>
{Object.values(Ticker)
.filter(ticker => ticker !== fromTicker) // Exclude the from ticker
.map((ticker) => (
<option key={ticker} value={ticker}>
{ticker}
</option>
))}
</select>
</FormInput>
<FormInput label="Amount" htmlFor="amount">
<div className="w-full">
<input
type="number"
step="any"
placeholder="Enter amount to swap"
className="input input-bordered w-full mb-2"
{...register('amount', {
valueAsNumber: true,
min: 0.0001,
max: availableAmount,
required: true
})}
/>
<div className="w-full">
<input
type="range"
min="0"
max={availableAmount}
step={availableAmount / 100}
className="range range-primary w-full"
value={watchedAmount || 0}
onChange={(e) => {
const value = parseFloat(e.target.value)
setValue('amount', value)
}}
/>
<div className="text-center text-xs text-gray-500 mt-1">
{watchedAmount && availableAmount > 0 ? (
<span>{((watchedAmount / availableAmount) * 100).toFixed(1)}% of available balance</span>
) : (
<span>0% of available balance</span>
)}
</div>
</div>
</div>
</FormInput>
<FormInput label="Order Type" htmlFor="orderType">
<select
className="select select-bordered w-full"
{...register('orderType', {
onChange(e) {
setSelectedOrderTypeEvent(e)
},
value: selectedOrderType,
})}
>
<option value="market">Market</option>
<option value="limit">Limit</option>
<option value="stop">Stop</option>
</select>
</FormInput>
<FormInput label="Allowed Slippage (%)" htmlFor="allowedSlippage">
<input
type="number"
step="0.1"
placeholder="0.5"
className="input input-bordered w-full"
{...register('allowedSlippage', {
valueAsNumber: true,
min: 0.1,
max: 10,
value: 0.5
})}
/>
</FormInput>
{selectedOrderType === 'limit' && (
<FormInput label="Trigger Ratio" htmlFor="triggerRatio">
<input
type="number"
step="any"
placeholder="Enter trigger ratio"
className="input input-bordered w-full"
{...register('triggerRatio', { valueAsNumber: true })}
/>
</FormInput>
)}
<button
type="submit"
className="btn btn-primary w-full mt-2"
disabled={isLoading || !watchedAmount || watchedAmount <= 0}
>
{isLoading ? (
<span className="loading loading-spinner"></span>
) : (
`Swap ${watchedAmount || 0} ${fromTicker} to ${selectedToTicker}`
)}
</button>
</div>
</form>
<div className="mt-4">
<div className="alert alert-info">
<p className="text-sm">
<strong>Note:</strong> Ensure account has sufficient balance for the swap.
</p>
</div>
</div>
</>
)}
</>
)
return (
<Modal
showModal={isOpen}
onClose={onClose}
titleHeader="Swap Tokens on GMX"
>
{modalContent}
</Modal>
)
}
export default SwapModal

View File

@@ -1,53 +1,118 @@
import React from 'react'
import React, {useState} from 'react'
import {FiRefreshCw} from 'react-icons/fi'
import { SelectColumnFilter, Table } from '../../../components/mollecules'
import { IAccountRowDetail } from '../../../global/type'
import {SelectColumnFilter, Table} from '../../../components/mollecules'
import type {IAccountRowDetail} from '../../../global/type.tsx'
import type {Account, Balance} from '../../../generated/ManagingApi'
import {Ticker} from '../../../generated/ManagingApi'
import SwapModal from './SwapModal'
const columns = [
{
Header: 'Chain',
accessor: 'chain.name',
disableFilters: true,
disableSortBy: true,
},
{
Filter: SelectColumnFilter,
Header: 'Assets',
accessor: 'tokenName',
disableFilters: true,
disableSortBy: true,
},
{
Cell: ({ cell }: any) => (
<>
<div className="tooltip" data-tip={cell.row.tokenName}>
{cell.row.values.amount.toFixed(4)}
</div>
</>
),
Header: 'Quantity',
accessor: 'amount',
disableFilters: true,
},
{
Cell: ({ cell }: any) => <>{cell.row.values.value.toFixed(2)} $</>,
Header: 'USD',
accessor: 'value',
disableFilters: true,
disableSortBy: true,
},
{
Cell: ({ cell }: any) => <> {cell.row.values.price} $</>,
Header: 'Price',
accessor: 'price',
disableFilters: true,
},
]
interface IAccountRowDetailProps extends IAccountRowDetail {
account: Account
}
const AccountRowDetails: React.FC<IAccountRowDetail> = ({
const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
balances,
showTotal,
account,
}) => {
const [swapModalState, setSwapModalState] = useState<{
isOpen: boolean
fromTicker: Ticker | null
availableAmount: number
}>({
isOpen: false,
fromTicker: null,
availableAmount: 0,
})
const handleSwapClick = (balance: Balance) => {
if (balance.tokenName && balance.amount) {
// Convert tokenName to Ticker enum
const ticker = balance.tokenName.toUpperCase() as Ticker
if (Object.values(Ticker).includes(ticker)) {
setSwapModalState({
isOpen: true,
fromTicker: ticker,
availableAmount: balance.amount,
})
}
}
}
const closeSwapModal = () => {
setSwapModalState({
isOpen: false,
fromTicker: null,
availableAmount: 0,
})
}
const columns = [
{
Header: 'Chain',
accessor: 'chain.name',
disableFilters: true,
disableSortBy: true,
},
{
Filter: SelectColumnFilter,
Header: 'Assets',
accessor: 'tokenName',
disableFilters: true,
disableSortBy: true,
},
{
Cell: ({ cell }: any) => (
<>
<div className="tooltip" data-tip={cell.row.tokenName}>
{cell.row.values.amount.toFixed(4)}
</div>
</>
),
Header: 'Quantity',
accessor: 'amount',
disableFilters: true,
},
{
Cell: ({ cell }: any) => <>{cell.row.values.value.toFixed(2)} $</>,
Header: 'USD',
accessor: 'value',
disableFilters: true,
disableSortBy: true,
},
{
Cell: ({ cell }: any) => <> {cell.row.values.price} $</>,
Header: 'Price',
accessor: 'price',
disableFilters: true,
},
{
Cell: ({ cell }: any) => {
const balance = cell.row.original as Balance
return (
<div className="flex justify-center">
{balance.tokenName && balance.amount && balance.amount > 0 && Object.values(Ticker).includes(balance.tokenName.toUpperCase() as Ticker) && (
<button
className="btn btn-xs btn-outline btn-info"
onClick={() => handleSwapClick(balance)}
title={`Swap ${balance.tokenName}`}
>
<FiRefreshCw className="h-3 w-3" />
<span className="ml-1">Swap</span>
</button>
)}
</div>
)
},
Header: 'Actions',
accessor: 'actions',
disableFilters: true,
disableSortBy: true,
},
]
return (
<>
<Table
@@ -56,6 +121,16 @@ const AccountRowDetails: React.FC<IAccountRowDetail> = ({
showTotal={showTotal}
showPagination={false}
/>
{swapModalState.isOpen && swapModalState.fromTicker && (
<SwapModal
isOpen={swapModalState.isOpen}
onClose={closeSwapModal}
account={account}
fromTicker={swapModalState.fromTicker}
availableAmount={swapModalState.availableAmount}
/>
)}
</>
)
}

View File

@@ -196,6 +196,7 @@ const AccountTable: React.FC<IAccountList> = ({ list, isFetching }) => {
<AccountRowDetails
balances={balances}
showTotal={true}
account={row.original}
></AccountRowDetails>
) : (
<div>No balances</div>

View File

@@ -0,0 +1,161 @@
# API Error Handling Utilities
This module provides utilities for handling API errors consistently across the application.
## Functions
### `extractErrorMessage(err: unknown): string`
Extracts meaningful error messages from API exceptions and other errors.
**Parameters:**
- `err` - The error object caught from API calls
**Returns:**
- A user-friendly error message string
**Usage:**
```typescript
import { extractErrorMessage } from '../utils/apiErrorHandler'
try {
const result = await apiCall()
} catch (err) {
const errorMessage = extractErrorMessage(err)
console.log(errorMessage) // "Insufficient balance for swap"
}
```
### `handleApiError(err: unknown)`
Handles API errors and returns a standardized error object with additional context.
**Parameters:**
- `err` - The error object caught from API calls
**Returns:**
```typescript
{
message: string
isApiException: boolean
status?: number
response?: string
}
```
**Usage:**
```typescript
import { handleApiError } from '../utils/apiErrorHandler'
try {
const result = await apiCall()
} catch (err) {
const errorInfo = handleApiError(err)
console.log(errorInfo.message) // "Insufficient balance for swap"
console.log(errorInfo.status) // 400
console.log(errorInfo.isApiException) // true
}
```
## Custom Hook
### `useApiError()`
A React hook that provides error handling utilities for API calls.
**Returns:**
```typescript
{
error: string | null
setError: (error: string | null) => void
clearError: () => void
handleError: (err: unknown) => string
handleApiErrorWithToast: (err: unknown, toast: any) => void
}
```
**Usage:**
```typescript
import { useApiError } from '../hooks/useApiError'
function MyComponent() {
const { error, setError, clearError, handleError, handleApiErrorWithToast } = useApiError()
const handleApiCall = async () => {
try {
const result = await apiCall()
clearError() // Clear any previous errors
} catch (err) {
// Option 1: Simple error handling
const errorMessage = handleError(err)
// Option 2: Error handling with toast
handleApiErrorWithToast(err, toast)
}
}
return (
<div>
{error && <div className="alert alert-error">{error}</div>}
<button onClick={handleApiCall}>Make API Call</button>
</div>
)
}
```
## Error Types Handled
1. **ApiException** - Generated client exceptions with server response details
2. **Error** - Standard JavaScript Error objects
3. **Unknown** - Any other error types
## Response Parsing
The utilities attempt to parse JSON responses in this order:
1. `message` - Standard error message field
2. `error` - Alternative error field
3. `detail` - ASP.NET Core validation error field
4. Raw response text if JSON parsing fails
5. Exception message as final fallback
## Examples
### Basic Usage
```typescript
try {
const result = await client.someApiCall()
} catch (err) {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
}
```
### With Toast Notifications
```typescript
const { handleApiErrorWithToast } = useApiError()
try {
const result = await client.someApiCall()
toast.update('success', 'Operation successful!')
} catch (err) {
handleApiErrorWithToast(err, toast)
}
```
### With Custom Error Handling
```typescript
const { handleError } = useApiError()
try {
const result = await client.someApiCall()
} catch (err) {
const errorMessage = handleError(err)
// Custom logic based on error type
if (errorMessage.includes('insufficient')) {
showBalanceWarning()
} else if (errorMessage.includes('network')) {
showRetryButton()
}
}
```

View File

@@ -0,0 +1,50 @@
/**
* Extracts meaningful error messages from API exceptions and other errors
* @param err - The error object caught from API calls
* @returns A user-friendly error message
*/
export function extractErrorMessage(err: unknown): string {
// Handle ApiException specifically to extract the actual error message
if (err && typeof err === 'object' && 'isApiException' in err) {
const apiException = err as any
try {
// Try to parse the response as JSON to get the actual error message
const responseData = JSON.parse(apiException.response)
return responseData.message || responseData.error || responseData.detail || apiException.message
} catch {
// If parsing fails, use the response text directly
return apiException.response || apiException.message
}
} else if (err instanceof Error) {
return err.message
}
return 'An unknown error occurred'
}
/**
* Handles API errors and returns a standardized error object
* @param err - The error object caught from API calls
* @returns An object containing the error message and additional context
*/
export function handleApiError(err: unknown): {
message: string
isApiException: boolean
status?: number
response?: string
} {
if (err && typeof err === 'object' && 'isApiException' in err) {
const apiException = err as any
return {
message: extractErrorMessage(err),
isApiException: true,
status: apiException.status,
response: apiException.response
}
}
return {
message: extractErrorMessage(err),
isApiException: false
}
}