feat: implement API client, query client, storage, token parsing, and utility functions
This commit is contained in:
parent
16746e075c
commit
ffb4b1b2fe
|
|
@ -15,7 +15,7 @@ dist/
|
|||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
# lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { env } from '@/config/env'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import type { ApiError } from '@/types/crm'
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
|
||||
export interface RequestOptions<TBody = unknown> {
|
||||
path: string
|
||||
method?: HttpMethod
|
||||
params?: Record<string, string | number | boolean | Array<string | number | boolean> | undefined | null>
|
||||
body?: TBody
|
||||
headers?: HeadersInit
|
||||
signal?: AbortSignal
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number
|
||||
payload: ApiError | null
|
||||
|
||||
constructor(status: number, message: string, payload: ApiError | null = null) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.payload = payload
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE_URL = `${env.API_URL}${env.API_PREFIX}`
|
||||
|
||||
const buildUrl = (path: string, params?: RequestOptions['params']) => {
|
||||
const url = new URL(path.startsWith('http') ? path : `${API_BASE_URL}${path}`)
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) return
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => {
|
||||
if (entry === undefined || entry === null) return
|
||||
url.searchParams.append(key, String(entry))
|
||||
})
|
||||
} else {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const parseResponse = async <T>(response: Response): Promise<T> => {
|
||||
if (response.status === 204 || response.status === 205) {
|
||||
return undefined as T
|
||||
}
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
return (await response.json()) as T
|
||||
}
|
||||
return (await response.text()) as T
|
||||
}
|
||||
|
||||
const requestWithRefresh = async <T>(options: RequestOptions, retry = false): Promise<T> => {
|
||||
const { tokens, activeOrganizationId, refreshSession, logout } = useAuthStore.getState()
|
||||
const authEnabled = options.auth !== false
|
||||
const headers = new Headers(options.headers || {})
|
||||
|
||||
if (authEnabled && tokens?.accessToken) {
|
||||
headers.set('Authorization', `Bearer ${tokens.accessToken}`)
|
||||
if (activeOrganizationId) {
|
||||
headers.set(env.ORG_HEADER, String(activeOrganizationId))
|
||||
}
|
||||
}
|
||||
|
||||
if (options.body && !(options.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
headers.set('Accept', 'application/json')
|
||||
|
||||
const response = await fetch(buildUrl(options.path, options.params), {
|
||||
method: options.method ?? 'GET',
|
||||
body:
|
||||
options.body instanceof FormData
|
||||
? options.body
|
||||
: options.body
|
||||
? JSON.stringify(options.body)
|
||||
: undefined,
|
||||
headers,
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
if (response.status === 401 && authEnabled && !retry) {
|
||||
try {
|
||||
await refreshSession()
|
||||
} catch (error) {
|
||||
logout()
|
||||
throw error
|
||||
}
|
||||
return requestWithRefresh<T>(options, true)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let payload: ApiError | null = null
|
||||
try {
|
||||
payload = await response.clone().json()
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
throw new HttpError(response.status, payload?.detail ? String(payload.detail) : response.statusText, payload)
|
||||
}
|
||||
|
||||
return parseResponse<T>(response)
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
request: requestWithRefresh,
|
||||
get: <T>(path: string, options: Omit<RequestOptions, 'path' | 'method'> = {}) =>
|
||||
requestWithRefresh<T>({ path, ...options, method: 'GET' }),
|
||||
post: <T, TBody>(path: string, body: TBody, options: Omit<RequestOptions<TBody>, 'path' | 'method' | 'body'> = {}) =>
|
||||
requestWithRefresh<T>({ path, body, ...options, method: 'POST' }),
|
||||
patch: <T, TBody>(path: string, body: TBody, options: Omit<RequestOptions<TBody>, 'path' | 'method' | 'body'> = {}) =>
|
||||
requestWithRefresh<T>({ path, body, ...options, method: 'PATCH' }),
|
||||
delete: <T>(path: string, options: Omit<RequestOptions, 'path' | 'method'> = {}) =>
|
||||
requestWithRefresh<T>({ path, ...options, method: 'DELETE' }),
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
retry: (failureCount, error: unknown) => {
|
||||
if (error instanceof Response && error.status === 401) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 3
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
export const storage = {
|
||||
get<T>(key: string, fallback: T | null = null): T | null {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) return fallback
|
||||
return JSON.parse(raw) as T
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse storage item ${key}`, error)
|
||||
return fallback
|
||||
}
|
||||
},
|
||||
set<T>(key: string, value: T | null): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (value === null) {
|
||||
window.localStorage.removeItem(key)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { jwtDecode } from 'jwt-decode'
|
||||
|
||||
interface TokenPayload {
|
||||
sub?: string
|
||||
email?: string
|
||||
name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export const parseToken = (token: string) => {
|
||||
try {
|
||||
return jwtDecode<TokenPayload>(token)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse token', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const formatCurrency = (value?: string | number | null, currency = 'USD') => {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
const amount = typeof value === 'string' ? Number(value) : value
|
||||
if (Number.isNaN(amount)) return '—'
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export const formatDate = (value?: string | Date | null, options: Intl.DateTimeFormatOptions = {}) => {
|
||||
if (!value) return '—'
|
||||
const date = typeof value === 'string' ? new Date(value) : value
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
...options,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export const formatRelativeDate = (value?: string | Date | null) => {
|
||||
if (!value) return '—'
|
||||
const date = typeof value === 'string' ? new Date(value) : value
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
const diffMs = date.getTime() - Date.now()
|
||||
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
|
||||
return formatter.format(diffDays, 'day')
|
||||
}
|
||||
Loading…
Reference in New Issue