first
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user