export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; export interface ApiError { code: string; message: string; } export type Role = 'admin' | 'viewer'; export type TabLine = { productName: string; quantity: number; unitPriceInCents: number }; /** An open tab as the server holds it: every terminal of the venue shows the same. */ export interface Tab { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items: TabLine[] | null; lastChangedAt: string; plannedTabId: string | null; email: string | null; } export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired'; export interface Command { id: string; tabId: string; customerName: string | null; amountInCents: number | null; type: string; status: CommandStatus; paymentMethod: string; requestedBy: string; expectedVersion: number; createdAt: string; processedAt: string | null; result: string | null; /** Null for a tab the server settled itself */ deviceId: string | null; } export interface Device { id: string; name: string; lastSeenAt: string; createdAt: string; } export interface User { id: string; email: string; name: string; role: Role; createdAt: string; } export interface PlannedTab { id: string; customerName: string; email: string | null; position: number; createdAt: string; deliveredAt: string | null; } export interface ReminderCandidate { tabId: string; customerName: string; /** Null when the customer has no email address on file */ email: string | null; amountInCents: number; itemCount: number; items: TabLine[] | null; reminderCount: number; lastReminderAt: string | null; nextEligibleAt: string | null; /** Has an email and is past the cooldown */ eligible: boolean; } export interface Reminder { id: string; tabId: string; customerName: string; email: string; amountInCents: number; sentAt: string; status: string; } export type StockAdjustmentType = 'add' | 'set'; export type StockAdjustmentStatus = 'pending' | 'applied' | 'rejected' | 'cancelled'; export interface StockAdjustment { id: string; productId: string; productName: string; /** add: the stock changes by quantity (negative takes stock out); set: the stock is now quantity */ type: StockAdjustmentType; quantity: number; note: string | null; requestedBy: string; status: StockAdjustmentStatus; /** The terminal's stock right after applying it */ stockAfter: number | null; /** Why the terminal rejected it */ result: string | null; createdAt: string; processedAt: string | null; } export interface Product { productId: string; name: string; category: string | null; priceInCents: number; /** Null when stock is not tracked for this product; can be negative */ stock: number | null; lowStockThreshold: number | null; /** The latest count, on the dashboard or a terminal; null when never counted */ countedAt: string | null; /** When it was created or last changed */ reportedAt: string; /** Archived (deleted): hidden on the terminals */ archivedAt: string | null; } export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null }; export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string }; export function getCookie(name: string): string | null { if (typeof document === 'undefined') return null; return document.cookie .split('; ') .find((row) => row.startsWith(`${name}=`)) ?.split('=')[1] ?? null; } function setTokens(accessToken: string, refreshToken: string) { // The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an // expired JWT triggers a 401 which is transparently refreshed below. const attributes = `path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`; document.cookie = `access_token=${accessToken}; ${attributes}`; document.cookie = `refresh_token=${refreshToken}; ${attributes}`; } function clearTokens() { document.cookie = 'access_token=; path=/; max-age=0'; document.cookie = 'refresh_token=; path=/; max-age=0'; } // Refresh tokens rotate on use, so concurrent 401s must share a single refresh request let refreshInFlight: Promise | null = null; function refreshAccessToken(): Promise { if (!refreshInFlight) { refreshInFlight = doRefreshAccessToken().finally(() => { refreshInFlight = null; }); } return refreshInFlight; } async function doRefreshAccessToken(): Promise { const refreshToken = getCookie('refresh_token'); if (!refreshToken) return null; try { const res = await fetch(`${API_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }); if (!res.ok) return null; const data = await res.json(); setTokens(data.accessToken, data.refreshToken); return data.accessToken; } catch { return null; } } async function request(endpoint: string, options: RequestInit, headers: Record): Promise { try { return await fetch(`${API_URL}${endpoint}`, { ...options, headers }); } catch (error) { throw { code: 'network_error', message: String(error) } satisfies ApiError; } } export async function api( endpoint: string, options: RequestInit = {}, ): Promise { const accessToken = getCookie('access_token'); const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record), }; if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}`; } let res = await request(endpoint, options, headers); if (res.status === 401 && accessToken) { const newToken = await refreshAccessToken(); if (newToken) { headers['Authorization'] = `Bearer ${newToken}`; res = await request(endpoint, options, headers); } else { clearTokens(); if (typeof window !== 'undefined') { window.location.href = '/login'; } throw { code: 'session_expired', message: 'Session expired' } satisfies ApiError; } } if (!res.ok) { const errorBody = await res.json().catch(() => null); const error: ApiError = errorBody?.error || { code: 'unknown', message: `Request failed with status ${res.status}`, }; throw error; } return res.json(); } // Auth API export const authApi = { login: (email: string, password: string) => api<{ accessToken: string; refreshToken: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), }), logout: () => { const refreshToken = getCookie('refresh_token'); clearTokens(); if (refreshToken) { api('/auth/logout', { method: 'POST', body: JSON.stringify({ refreshToken }), }).catch(() => {}); } }, }; // Tabs API export const tabsApi = { list: (venueId: string) => api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`), }; // Devices API export const devicesApi = { list: (venueId: string) => api<{ devices: Device[] }>(`/venues/${venueId}/devices`), unpair: (venueId: string, deviceId: string) => api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }), generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'), }; // Users API export const usersApi = { list: (venueId: string) => api<{ users: User[] }>(`/venues/${venueId}/users`), create: (venueId: string, data: { email: string; password: string; name: string; role: string }) => api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }), }; // Commands API export const commandsApi = { list: (venueId: string, params?: { status?: string; limit?: number; offset?: number }) => { const searchParams = new URLSearchParams(); if (params?.status) searchParams.set('status', params.status); if (params?.limit) searchParams.set('limit', String(params.limit)); if (params?.offset) searchParams.set('offset', String(params.offset)); const qs = searchParams.toString(); return api<{ commands: Command[]; total: number }>( `/venues/${venueId}/commands${qs ? `?${qs}` : ''}`, ); }, create: (venueId: string, data: { tabId: string; type: string; expectedVersion: number; paymentMethod: string }) => api<{ id: string; status: string; amountInCents: number }>(`/venues/${venueId}/commands`, { method: 'POST', body: JSON.stringify(data), }), }; // Venues API export const venuesApi = { get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`), }; // Planned Tabs API export const plannedTabsApi = { list: (venueId: string) => api<{ plannedTabs: PlannedTab[] }>(`/venues/${venueId}/planned-tabs`), create: (venueId: string, customerName: string, email?: string) => api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }), update: (venueId: string, id: string, customerName: string, email?: string) => api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName, email }) }), remove: (venueId: string, id: string) => api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }), import: (venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') => api<{ created: number; updated: number; unchanged: number; removed: number }>(`/venues/${venueId}/planned-tabs/import`, { method: 'POST', body: JSON.stringify({ entries, mode }), }), }; // Reminders API export const remindersApi = { candidates: (venueId: string) => api<{ candidates: ReminderCandidate[]; cooldownDays: number }>(`/venues/${venueId}/reminders/candidates`), send: (venueId: string, tabIds: string[]) => api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST', body: JSON.stringify({ tabIds }), }), history: (venueId: string, customerName?: string) => api<{ reminders: Reminder[] }>( `/venues/${venueId}/reminders${customerName ? `?customerName=${encodeURIComponent(customerName)}` : ''}`, ), sendTest: (venueId: string, plannedTabId: string) => api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }), }; // Stock API export const stockApi = { list: (venueId: string) => api<{ products: Product[]; reportedAt: string | null }>(`/venues/${venueId}/stock`), history: (venueId: string, params?: { productId?: string; limit?: number; offset?: number }) => { const searchParams = new URLSearchParams(); if (params?.productId) searchParams.set('productId', params.productId); if (params?.limit) searchParams.set('limit', String(params.limit)); if (params?.offset) searchParams.set('offset', String(params.offset)); const qs = searchParams.toString(); return api<{ adjustments: StockAdjustment[]; total: number }>(`/venues/${venueId}/stock/adjustments${qs ? `?${qs}` : ''}`); }, adjust: (venueId: string, adjustments: NewStockAdjustment[]) => api<{ adjustments: StockAdjustment[] }>(`/venues/${venueId}/stock/adjustments`, { method: 'POST', body: JSON.stringify({ adjustments }), }), /** [stock] is the starting count; null: not tracked */ createProduct: (venueId: string, product: ProductFields & { stock: number | null }) => api(`/venues/${venueId}/stock/products`, { method: 'POST', body: JSON.stringify(product), }), /** Only the fields that are sent change; archived: true deletes (hides) the product */ updateProduct: (venueId: string, productId: string, changes: Partial & { archived?: boolean }) => api(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, { method: 'PATCH', body: JSON.stringify(changes), }), }; export { setTokens, clearTokens, refreshAccessToken }; export function getAccessToken(): string | null { return getCookie('access_token'); }