140 lines
6.0 KiB
TypeScript
140 lines
6.0 KiB
TypeScript
// 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<T>(path: string, init?: RequestInit): Promise<T> {
|
|
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<T>;
|
|
}
|
|
|
|
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: <T>() => apiFetch<T>('/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: <T>() => apiFetch<T>('/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: <T>(data: { email: string; displayName?: string; phone?: string }) =>
|
|
apiFetch<T>('/pos/customers', { method: 'POST', body: JSON.stringify(data) }),
|
|
/** Atomically confirm a fully allocated sale. */
|
|
createSale: <T>(data: unknown) =>
|
|
apiFetch<T>('/pos/sales', { method: 'POST', body: JSON.stringify(data) }),
|
|
/** Apply additional payments to a pending POS sale. */
|
|
payRest: <T>(orderId: string, data: unknown) =>
|
|
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/payments`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
/** Apply a partial/full return to a POS sale. */
|
|
createReturn: <T>(orderId: string, data: unknown) =>
|
|
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/returns`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
/** List order items for a sale (used by the return UI). */
|
|
listOrderItems: <T>(orderId: string) =>
|
|
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/items`),
|
|
/** List POS sales for the session, optionally filtered by state. */
|
|
// FEAT-200: terminalId enables cross-day pending sales
|
|
listSales: <T>(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<T>(`/pos/sales${tail ? `?${tail}` : ''}`);
|
|
},
|
|
/** Delete a pending parked sale and restore inventory. */
|
|
deleteSale: <T>(orderId: string) =>
|
|
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}`, { method: 'DELETE' }),
|
|
/** Email the immutable generated receipt. */
|
|
emailReceipt: <T>(orderId: string, email: string) =>
|
|
apiFetch<T>(`/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: <T>(orderId: string) =>
|
|
apiFetch<T>(`/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' }),
|
|
};
|