@@ -1,24 +1,105 @@
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
|
||||
interface ApiError {
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function getTokens() {
|
||||
if (typeof window === 'undefined') return { accessToken: null };
|
||||
const accessToken = document.cookie
|
||||
export type Role = 'admin' | 'viewer';
|
||||
|
||||
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
export interface Tab {
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
version: number;
|
||||
totalInCents: number;
|
||||
itemCount: number;
|
||||
items: TabLine[] | null;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
deviceLastSeenAt: string;
|
||||
deviceRevokedAt: string | null;
|
||||
lastReportedAt: 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;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
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;
|
||||
email: string;
|
||||
amountInCents: number;
|
||||
itemCount: number;
|
||||
items: TabLine[] | null;
|
||||
}
|
||||
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
email: string;
|
||||
amountInCents: number;
|
||||
sentAt: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function getCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('access_token='))
|
||||
?.split('=')[1];
|
||||
return { accessToken };
|
||||
.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.
|
||||
document.cookie = `access_token=${accessToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||
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() {
|
||||
@@ -39,11 +120,7 @@ function refreshAccessToken(): Promise<string | null> {
|
||||
}
|
||||
|
||||
async function doRefreshAccessToken(): Promise<string | null> {
|
||||
const refreshToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('refresh_token='))
|
||||
?.split('=')[1];
|
||||
|
||||
const refreshToken = getCookie('refresh_token');
|
||||
if (!refreshToken) return null;
|
||||
|
||||
try {
|
||||
@@ -63,11 +140,19 @@ async function doRefreshAccessToken(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = any>(
|
||||
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 } = getTokens();
|
||||
const accessToken = getCookie('access_token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
@@ -77,25 +162,19 @@ export async function api<T = any>(
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
let res = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
let res = await request(endpoint, options, headers);
|
||||
|
||||
if (res.status === 401 && accessToken) {
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`;
|
||||
res = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
res = await request(endpoint, options, headers);
|
||||
} else {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Session expired');
|
||||
throw { code: 'session_expired', message: 'Session expired' } satisfies ApiError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,16 +193,13 @@ export async function api<T = any>(
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
api<{ accessToken: string; refreshToken: string; user: any }>('/auth/login', {
|
||||
api<{ accessToken: string; refreshToken: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
logout: () => {
|
||||
const refreshToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('refresh_token='))
|
||||
?.split('=')[1];
|
||||
const refreshToken = getCookie('refresh_token');
|
||||
clearTokens();
|
||||
if (refreshToken) {
|
||||
api('/auth/logout', {
|
||||
@@ -137,20 +213,20 @@ export const authApi = {
|
||||
// Tabs API
|
||||
export const tabsApi = {
|
||||
list: (venueId: string) =>
|
||||
api<{ tabs: any[] }>(`/venues/${venueId}/tabs`),
|
||||
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
|
||||
};
|
||||
|
||||
// Devices API
|
||||
export const devicesApi = {
|
||||
get: (venueId: string) =>
|
||||
api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | null }>(`/venues/${venueId}/devices`),
|
||||
api<{ device: Device | null }>(`/venues/${venueId}/devices`),
|
||||
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
|
||||
};
|
||||
|
||||
// Users API
|
||||
export const usersApi = {
|
||||
list: (venueId: string) =>
|
||||
api<{ users: any[] }>(`/venues/${venueId}/users`),
|
||||
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) }),
|
||||
};
|
||||
@@ -163,7 +239,7 @@ export const commandsApi = {
|
||||
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: any[]; total: number }>(
|
||||
return api<{ commands: Command[]; total: number }>(
|
||||
`/venues/${venueId}/commands${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
@@ -176,13 +252,12 @@ export const commandsApi = {
|
||||
|
||||
// Venues API
|
||||
export const venuesApi = {
|
||||
list: () => api<{ id: string; name: string }[]>('/venues'),
|
||||
get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`),
|
||||
};
|
||||
|
||||
// Planned Tabs API
|
||||
export const plannedTabsApi = {
|
||||
list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`),
|
||||
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) =>
|
||||
@@ -199,11 +274,11 @@ export const plannedTabsApi = {
|
||||
// Reminders API
|
||||
export const remindersApi = {
|
||||
candidates: (venueId: string) =>
|
||||
api<{ candidates: any[] }>(`/venues/${venueId}/reminders/candidates`),
|
||||
api<{ candidates: ReminderCandidate[] }>(`/venues/${venueId}/reminders/candidates`),
|
||||
send: (venueId: string) =>
|
||||
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }),
|
||||
history: (venueId: string) =>
|
||||
api<{ reminders: any[] }>(`/venues/${venueId}/reminders`),
|
||||
api<{ reminders: Reminder[] }>(`/venues/${venueId}/reminders`),
|
||||
sendTest: (venueId: string, plannedTabId: string) =>
|
||||
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
|
||||
};
|
||||
@@ -211,5 +286,5 @@ export const remindersApi = {
|
||||
export { setTokens, clearTokens, refreshAccessToken };
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return getTokens().accessToken ?? null;
|
||||
}
|
||||
return getCookie('access_token');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user