batman
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
|
||||
interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function getTokens() {
|
||||
if (typeof window === 'undefined') return { accessToken: null };
|
||||
const accessToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('access_token='))
|
||||
?.split('=')[1];
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
function setTokens(accessToken: string, refreshToken: string) {
|
||||
document.cookie = `access_token=${accessToken}; path=/; max-age=${24 * 60 * 60}; SameSite=Lax`;
|
||||
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
document.cookie = 'access_token=; path=/; max-age=0';
|
||||
document.cookie = 'refresh_token=; path=/; max-age=0';
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refreshToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('refresh_token='))
|
||||
?.split('=')[1];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = any>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const { accessToken } = getTokens();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
let res = await fetch(`${API_URL}${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,
|
||||
});
|
||||
} else {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
}
|
||||
|
||||
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; user: any }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
logout: () => {
|
||||
const refreshToken = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('refresh_token='))
|
||||
?.split('=')[1];
|
||||
clearTokens();
|
||||
if (refreshToken) {
|
||||
api('/auth/logout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Tabs API
|
||||
export const tabsApi = {
|
||||
list: (venueId: string) =>
|
||||
api<{ tabs: any[] }>(`/venues/${venueId}/tabs`),
|
||||
};
|
||||
|
||||
// Devices API
|
||||
export const devicesApi = {
|
||||
list: (venueId: string) =>
|
||||
api<{ devices: any[] }>(`/venues/${venueId}/devices`),
|
||||
revoke: (venueId: string, deviceId: string) =>
|
||||
api(`/venues/${venueId}/devices/${deviceId}/revoke`, { method: 'POST' }),
|
||||
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
|
||||
};
|
||||
|
||||
// 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: any[]; total: number }>(
|
||||
`/venues/${venueId}/commands${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
create: (venueId: string, data: { tabId: string; type: string; expectedVersion: number; paymentMethod: string }) =>
|
||||
api<{ id: string; status: string; warning?: string }>(`/venues/${venueId}/commands`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
};
|
||||
|
||||
// Venues API
|
||||
export const venuesApi = {
|
||||
list: () => api<{ id: string; name: string }[]>('/venues'),
|
||||
get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`),
|
||||
};
|
||||
|
||||
export { setTokens, clearTokens };
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const THRESHOLDS = {
|
||||
ONLINE: 60_000,
|
||||
STALE: 300_000,
|
||||
OFFLINE: 600_000,
|
||||
} as const;
|
||||
|
||||
export type DeviceStatus = 'online' | 'recently_seen' | 'stale' | 'offline' | 'revoked';
|
||||
|
||||
export function getDeviceStatus(
|
||||
lastSeenAt: string | Date,
|
||||
revokedAt?: string | Date | null,
|
||||
): DeviceStatus {
|
||||
if (revokedAt) return 'revoked';
|
||||
|
||||
const lastSeen = new Date(lastSeenAt).getTime();
|
||||
const now = Date.now();
|
||||
const diff = now - lastSeen;
|
||||
|
||||
if (diff <= THRESHOLDS.ONLINE) return 'online';
|
||||
if (diff <= THRESHOLDS.STALE) return 'recently_seen';
|
||||
if (diff <= THRESHOLDS.OFFLINE) return 'stale';
|
||||
return 'offline';
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export function formatCents(cents: number, currency: string = 'EUR'): string {
|
||||
const euros = cents / 100;
|
||||
return new Intl.NumberFormat('nl-NL', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(euros);
|
||||
}
|
||||
|
||||
export function formatRelativeTime(dateString: string | Date): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString: string | Date): string {
|
||||
return new Date(dateString).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export { cn } from './cn';
|
||||
Reference in New Issue
Block a user