374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
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<string | null> | null = null;
|
|
|
|
function refreshAccessToken(): Promise<string | null> {
|
|
if (!refreshInFlight) {
|
|
refreshInFlight = doRefreshAccessToken().finally(() => {
|
|
refreshInFlight = null;
|
|
});
|
|
}
|
|
return refreshInFlight;
|
|
}
|
|
|
|
async function doRefreshAccessToken(): Promise<string | null> {
|
|
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<string, string>): Promise<Response> {
|
|
try {
|
|
return await fetch(`${API_URL}${endpoint}`, { ...options, headers });
|
|
} catch (error) {
|
|
throw { code: 'network_error', message: String(error) } satisfies ApiError;
|
|
}
|
|
}
|
|
|
|
export async function api<T = unknown>(
|
|
endpoint: string,
|
|
options: RequestInit = {},
|
|
): Promise<T> {
|
|
const accessToken = getCookie('access_token');
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
|
|
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<Product>(`/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<ProductFields> & { archived?: boolean }) =>
|
|
api<Product>(`/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');
|
|
}
|