// Browser requests stay on the POS origin. The Next.js route handler proxies // `/api/*` to the backend, so LAN clients never resolve their own localhost. const API = '/api'; async function apiFetch(path: string, init?: RequestInit): Promise { const hasBody = init?.body !== undefined && init.body !== null && init.body !== ''; const res = await fetch(`${API}${path}`, { ...init, credentials: 'include', headers: { ...(hasBody ? { 'Content-Type': 'application/json' } : {}), ...(init?.headers ?? {}), }, }); if (res.status === 401) { if (typeof window !== 'undefined') { window.location.href = '/login'; } throw Object.assign(new Error('Sesión caducada, identifícate de nuevo'), { status: 401, code: 'UNAUTHORIZED', }); } if (!res.ok) { const err = (await res.json().catch(() => ({ message: res.statusText }))) as { message?: string; error?: { message?: string; code?: string }; }; throw Object.assign(new Error(err.error?.message ?? err.message ?? 'Request failed'), { status: res.status, code: err.error?.code, }); } // Defensive: if a proxy/misconfiguration ever returns HTML on a successful // response, surface a readable message instead of a JSON parse crash. const contentType = res.headers.get('content-type') ?? ''; if (!contentType.includes('application/json')) { throw Object.assign(new Error('Respuesta inesperada del servidor (sesión o proxy)'), { status: res.status, code: 'INVALID_RESPONSE', }); } return res.json() as Promise; } export const posApi = { /** Get current terminal info. Requires x-terminal-id header set by middleware. */ me: () => apiFetch('/pos/terminals/me'), /** Bind terminal with code. */ bind: (bindingCode: string) => apiFetch<{ terminalId: string; storeId: string }>('/pos/terminals/bind', { method: 'POST', body: JSON.stringify({ bindingCode }), }), /** Get POS config (store + terminal + payment methods + session status). */ config: () => apiFetch('/pos/config'), /** Open the daily cash session for the bound terminal. */ openSession: (openingCashCents: number) => apiFetch<{ id: string; status: 'OPEN' }>('/pos/sessions', { method: 'POST', body: JSON.stringify({ openingCashCents }), }), /** Close the daily cash session. */ closeSession: (sessionId: string, actualCashCents: number, pin?: string) => apiFetch<{ id: string; status: 'CLOSED' }>(`/pos/sessions/${sessionId}/close`, { method: 'POST', body: JSON.stringify(pin ? { closingCashCents: actualCashCents, actualCashCents, pin } : { closingCashCents: actualCashCents, actualCashCents }), }), /** Load touch category navigation and eight terminal quick products. */ touchCatalog: () => apiFetch('/pos/catalog/touch'), /** List products by query. */ searchProducts: (q: string, storeId?: string, limit = 20) => apiFetch( `/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`, ), /** Get product by EAN. */ productByEan: (ean: string) => apiFetch(`/pos/products/by-ean/${encodeURIComponent(ean)}`), /** Get product by SKU. */ productBySku: (sku: string) => apiFetch(`/pos/products/by-sku/${encodeURIComponent(sku)}`), /** Search identity customers for sale association. */ searchCustomers: (q: string) => apiFetch(`/pos/customers/search?q=${encodeURIComponent(q)}`), /** Create an identity customer from the register. */ createCustomer: (data: { email: string; displayName?: string; phone?: string }) => apiFetch('/pos/customers', { method: 'POST', body: JSON.stringify(data) }), /** Atomically confirm a fully allocated sale. */ createSale: (data: unknown) => apiFetch('/pos/sales', { method: 'POST', body: JSON.stringify(data) }), /** Apply additional payments to a pending POS sale. */ payRest: (orderId: string, data: unknown) => apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/payments`, { method: 'POST', body: JSON.stringify(data), }), /** Apply a partial/full return to a POS sale. */ createReturn: (orderId: string, data: unknown) => apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/returns`, { method: 'POST', body: JSON.stringify(data), }), /** List order items for a sale (used by the return UI). */ listOrderItems: (orderId: string) => apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/items`), /** List POS sales for the session, optionally filtered by state. */ // FEAT-200: terminalId enables cross-day pending sales listSales: (params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string; terminalId?: string }) => { const qs = new URLSearchParams(); if (params?.state) qs.set('state', params.state); if (params?.sessionId) qs.set('sessionId', params.sessionId); if (params?.terminalId) qs.set('terminalId', params.terminalId); const tail = qs.toString(); return apiFetch(`/pos/sales${tail ? `?${tail}` : ''}`); }, /** Delete a pending parked sale and restore inventory. */ deleteSale: (orderId: string) => apiFetch(`/pos/sales/${encodeURIComponent(orderId)}`, { method: 'DELETE' }), /** Email the immutable generated receipt. */ emailReceipt: (orderId: string, email: string) => apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/receipt/email`, { method: 'POST', body: JSON.stringify({ email }), }), /** Print receipt for any sale (pending, completed, refunded). */ printReceipt: (orderId: string) => apiFetch<{ url: string }>(`/pos/sales/${encodeURIComponent(orderId)}/print`), /** Get full receipt for a sale (used for returns). */ getReceipt: (orderId: string) => apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/receipt`), }; export const authApi = { login: (email: string, password: string) => apiFetch('/backoffice/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), }), logout: () => apiFetch('/backoffice/auth/logout', { method: 'POST' }), };