From ffb4b1b2feb91e86cfd521174b926d59c2fe02cf Mon Sep 17 00:00:00 2001 From: Artem Kashaev Date: Mon, 1 Dec 2025 15:27:19 +0500 Subject: [PATCH] feat: implement API client, query client, storage, token parsing, and utility functions --- .gitignore | 2 +- frontend/src/lib/api-client.ts | 122 +++++++++++++++++++++++++++++++ frontend/src/lib/query-client.ts | 21 ++++++ frontend/src/lib/storage.ts | 21 ++++++ frontend/src/lib/token.ts | 17 +++++ frontend/src/lib/utils.ts | 39 ++++++++++ 6 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/api-client.ts create mode 100644 frontend/src/lib/query-client.ts create mode 100644 frontend/src/lib/storage.ts create mode 100644 frontend/src/lib/token.ts create mode 100644 frontend/src/lib/utils.ts diff --git a/.gitignore b/.gitignore index 8a0dbaf..ff8a48d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ dist/ downloads/ eggs/ .eggs/ -lib/ +# lib/ lib64/ parts/ sdist/ diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts new file mode 100644 index 0000000..cd5ce55 --- /dev/null +++ b/frontend/src/lib/api-client.ts @@ -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 { + path: string + method?: HttpMethod + params?: Record | 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 (response: Response): Promise => { + 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 (options: RequestOptions, retry = false): Promise => { + 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(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(response) +} + +export const apiClient = { + request: requestWithRefresh, + get: (path: string, options: Omit = {}) => + requestWithRefresh({ path, ...options, method: 'GET' }), + post: (path: string, body: TBody, options: Omit, 'path' | 'method' | 'body'> = {}) => + requestWithRefresh({ path, body, ...options, method: 'POST' }), + patch: (path: string, body: TBody, options: Omit, 'path' | 'method' | 'body'> = {}) => + requestWithRefresh({ path, body, ...options, method: 'PATCH' }), + delete: (path: string, options: Omit = {}) => + requestWithRefresh({ path, ...options, method: 'DELETE' }), +} diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts new file mode 100644 index 0000000..887723c --- /dev/null +++ b/frontend/src/lib/query-client.ts @@ -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, + }, + }, + }) diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..e15012f --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,21 @@ +export const storage = { + get(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(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)) + }, +} diff --git a/frontend/src/lib/token.ts b/frontend/src/lib/token.ts new file mode 100644 index 0000000..7f77114 --- /dev/null +++ b/frontend/src/lib/token.ts @@ -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(token) + } catch (error) { + console.error('Failed to parse token', error) + return null + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..380e496 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -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') +}