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