forked from cartsnitch/cartsnitch
d0185fd93d
Must-fix: - Exclude JWT token from Zustand persist (partialize) to prevent localStorage XSS exfiltration — token now lives in memory only - Wire all pages through TanStack Query hooks (usePurchases, useProduct, useProducts, usePriceHistory, useCoupons, usePriceAlerts) with proper loading skeletons and error states - Add mock interceptor in api.ts (VITE_MOCK_API=true) so mock data flows through the same fetch path — single flag to switch to live API Should-fix: - Wire theme toggle to DOM (dark class on <html>) - Fix AccountLinking form inputs (controlled with value/onChange) - Remove unused err in catch blocks (Login, Register) - Bump remaining min-h-10 touch targets to min-h-12 (48px) Build: 128KB initial JS, Recharts 498KB lazy chunk. 5/5 tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
101 lines
3.0 KiB
TypeScript
101 lines
3.0 KiB
TypeScript
import { useAuthStore } from '../stores/auth.ts'
|
|
import {
|
|
mockPurchases,
|
|
mockProducts,
|
|
mockCoupons,
|
|
mockAlerts,
|
|
getMockPriceHistory,
|
|
} from './mock-data.ts'
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
|
|
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
|
|
|
|
// Mock response lookup table
|
|
const mockRoutes: Record<string, (path: string) => unknown> = {
|
|
'/purchases': () => mockPurchases,
|
|
'/products': () => mockProducts,
|
|
'/coupons': () => mockCoupons,
|
|
'/price-alerts': () => mockAlerts,
|
|
}
|
|
|
|
function matchMockRoute<T>(path: string): T | null {
|
|
// Exact match
|
|
if (mockRoutes[path]) return mockRoutes[path](path) as T
|
|
|
|
// /purchases/:id
|
|
const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
|
|
if (purchaseMatch) {
|
|
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
|
|
return (purchase ?? null) as T
|
|
}
|
|
|
|
// /products/:id/price-history
|
|
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
|
|
if (priceHistoryMatch) {
|
|
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
|
}
|
|
|
|
// /products?q=search or /products/:id
|
|
const productMatch = path.match(/^\/products\/(.+)$/)
|
|
if (productMatch) {
|
|
const product = mockProducts.find((p) => p.id === productMatch[1])
|
|
return (product ?? null) as T
|
|
}
|
|
|
|
const productsSearch = path.match(/^\/products\?q=(.+)$/)
|
|
if (productsSearch) {
|
|
const q = decodeURIComponent(productsSearch[1]).toLowerCase()
|
|
return mockProducts.filter(
|
|
(p) =>
|
|
p.name.toLowerCase().includes(q) ||
|
|
p.brand.toLowerCase().includes(q) ||
|
|
p.category.toLowerCase().includes(q),
|
|
) as T
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
|
// Mock interceptor: return mock data without hitting the network
|
|
if (USE_MOCK && (!options?.method || options.method === 'GET')) {
|
|
const mockResult = matchMockRoute<T>(path)
|
|
if (mockResult !== null) {
|
|
// Simulate network delay for realistic loading states
|
|
await new Promise((r) => setTimeout(r, 300))
|
|
return mockResult
|
|
}
|
|
}
|
|
|
|
const token = useAuthStore.getState().token
|
|
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...options?.headers,
|
|
},
|
|
})
|
|
|
|
if (res.status === 401) {
|
|
useAuthStore.getState().logout()
|
|
throw new Error('Unauthorized')
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`API error: ${res.status}`)
|
|
}
|
|
|
|
return res.json() as Promise<T>
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string) => apiFetch<T>(path),
|
|
post: <T>(path: string, body: unknown) =>
|
|
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
|
put: <T>(path: string, body: unknown) =>
|
|
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
|
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
|
}
|