This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
// Central fetch wrapper: the access token is kept in memory only (never
// localStorage/sessionStorage, per the auth design), and a 401 triggers a
// single silent refresh-and-retry via the httpOnly refresh cookie.
let accessToken: string | null = null;
export function setAccessToken(token: string | null): void {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
const NO_RETRY_PATHS = ["/api/auth/admin/refresh", "/api/auth/admin/login"];
async function rawFetch(path: string, init: RequestInit): Promise<Response> {
const headers = new Headers(init.headers);
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return fetch(path, { ...init, headers, credentials: "include" });
}
let refreshInFlight: Promise<boolean> | null = null;
async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
const res = await fetch("/api/auth/admin/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) return false;
const data = (await res.json()) as { access_token: string };
setAccessToken(data.access_token);
return true;
} catch {
return false;
} finally {
refreshInFlight = null;
}
})();
}
return refreshInFlight;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let res = await rawFetch(path, init);
if (res.status === 401 && !NO_RETRY_PATHS.includes(path)) {
const refreshed = await refreshAccessToken();
if (refreshed) {
res = await rawFetch(path, init);
}
}
if (!res.ok) {
let message = `Request failed with status ${res.status}`;
try {
const body = (await res.json()) as { error?: string };
if (body?.error) message = body.error;
} catch {
// response had no JSON body
}
throw new ApiError(message, res.status);
}
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
}
export function apiUpload<T>(path: string, formData: FormData): Promise<T> {
return apiFetch<T>(path, { method: "POST", body: formData });
}
+20
View File
@@ -0,0 +1,20 @@
// Shared helper for admin lists that support manual reordering (categories,
// products, ...): swap the position of the item at `index` with its
// neighbor, then persist both via the given per-item PATCH call.
export async function swapPosition<T extends { id: string; position: number }>(
items: T[],
index: number,
direction: "up" | "down",
patchPosition: (id: string, position: number) => Promise<unknown>,
): Promise<void> {
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= items.length) return;
const current = items[index];
const neighbor = items[swapIndex];
await Promise.all([
patchPosition(current.id, neighbor.position),
patchPosition(neighbor.id, current.position),
]);
}
+100
View File
@@ -0,0 +1,100 @@
export interface User {
id: string;
email: string;
role: string;
is_active?: boolean;
}
export interface SiteSettings {
name: string;
description: string;
slug: string;
}
export interface Category {
id: string;
name: string;
slug: string;
description: string;
position: number;
is_active: boolean;
}
export interface Unit {
id: string;
name: string;
symbol: string;
}
export interface Media {
id: string;
filename: string;
url: string;
mime_type: string;
size_bytes: number;
alt_text: string;
}
export interface Product {
id: string;
category_id?: string | null;
name: string;
slug: string;
short_description: string;
description: string;
is_active: boolean;
is_featured: boolean;
primary_media_id?: string | null;
position: number;
}
export interface PriceTier {
id: string;
product_id: string;
unit_id: string;
quantity: number;
price_cents: number;
position: number;
}
export interface TelegramSettings {
enabled: boolean;
bot_token_configured: boolean;
chat_id: string;
notify_new_order: boolean;
notify_status_change: boolean;
}
export interface OrderItem {
product_id?: string;
product_name: string;
unit_symbol: string;
tier_quantity: number;
multiplier: number;
unit_price_cents: number;
total_cents: number;
}
export interface Order {
id: string;
customer_name: string;
customer_email: string;
customer_phone: string;
status: string;
total_cents: number;
notes: string;
items?: OrderItem[];
}
export const ORDER_STATUSES = [
"pending",
"confirmed",
"preparing",
"shipped",
"completed",
"cancelled",
] as const;
export function formatCents(cents: number): string {
return (cents / 100).toFixed(2);
}