feat: implement API client, query client, storage, token parsing, and utility functions
Build and deploy / build (push) Successful in 45s Details
Build and deploy / deploy (push) Successful in 19s Details

This commit is contained in:
Artem Kashaev 2025-12-01 15:27:19 +05:00
parent 16746e075c
commit ffb4b1b2fe
6 changed files with 221 additions and 1 deletions

2
.gitignore vendored
View File

@ -15,7 +15,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
# lib/
lib64/
parts/
sdist/

View File

@ -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' }),
}

View File

@ -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,
},
},
})

View File

@ -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))
},
}

17
frontend/src/lib/token.ts Normal file
View File

@ -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
}
}

39
frontend/src/lib/utils.ts Normal file
View File

@ -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')
}