import axios, { type AxiosError, type InternalAxiosRequestConfig, } from "axios"; import type { ApiResponse, AuthTokenResponse } from "./types"; import { getOrCreateTerminalId } from "@/lib/terminal"; import { useAuthStore } from "@/lib/stores/auth.store"; import { isNetworkError, isOnlineOnly, newIdempotencyKey, OfflineUnavailableError, queueWrite, } from "@/lib/offline/offline-write"; const baseURL = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208"; export const api = axios.create({ baseURL, headers: { "Content-Type": "application/json" }, }); api.interceptors.request.use((config) => { if (typeof window !== "undefined") { const token = localStorage.getItem("meezi_access_token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } config.headers["X-Meezi-Terminal-Id"] = getOrCreateTerminalId(); } return config; }); /** * Shared in-flight refresh promise so that a burst of concurrent 401s triggers * exactly one POST /api/auth/refresh instead of one per failed request. */ let refreshPromise: Promise | null = null; async function refreshAccessToken(): Promise { if (typeof window === "undefined") return null; const refreshToken = localStorage.getItem("meezi_refresh_token"); if (!refreshToken) return null; try { // Bare axios call (not `api`) to avoid recursing through this interceptor. const { data } = await axios.post>( `${baseURL}/api/auth/refresh`, { refreshToken }, { headers: { "Content-Type": "application/json" } } ); if (!data.success || !data.data) return null; useAuthStore.getState().setAuth(data.data); return data.data.accessToken; } catch { return null; } } api.interceptors.response.use( (response) => response, async (error: AxiosError>) => { const status = error.response?.status; const original = error.config as | (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined; // Expired access token → try a one-time refresh, then replay the request. if ( status === 401 && original && !original._retry && typeof window !== "undefined" && !original.url?.includes("/api/auth/") ) { original._retry = true; refreshPromise ??= refreshAccessToken().finally(() => { refreshPromise = null; }); const newToken = await refreshPromise; if (newToken) { original.headers.Authorization = `Bearer ${newToken}`; return api(original); } } const apiError = error.response?.data?.error; if (apiError?.code) { return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status)); } if (status === 401 && typeof window !== "undefined") { const path = window.location.pathname; const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q"); const isAdmin = path.includes("/admin"); if (!isPublicGuest && !isAdmin) { localStorage.removeItem("meezi_access_token"); localStorage.removeItem("meezi_refresh_token"); localStorage.removeItem("meezi_auth"); const locale = path.split("/")[1] ?? "fa"; window.location.href = `/${locale}/login`; } } return Promise.reject(error); } ); export interface PagedMeta { total: number; page: number; pageSize: number; } export interface PagedApiResponse { success: boolean; data?: T[]; meta?: PagedMeta; error?: { code: string; message: string; field?: string }; } export async function apiGet(url: string): Promise { const { data } = await api.get>(url); if (!data.success || data.data === undefined) { throw new Error(data.error?.message ?? "Request failed"); } return data.data; } export async function apiGetPaged(url: string): Promise<{ items: T[]; meta: PagedMeta }> { const { data } = await api.get>(url); if (!data.success || data.data === undefined || !data.meta) { throw new Error(data.error?.message ?? "Request failed"); } return { items: data.data, meta: data.meta }; } export class ApiClientError extends Error { constructor( public readonly code: string, message: string, /** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */ public readonly payload?: unknown, /** HTTP status, when known — lets callers (e.g. the outbox) tell 5xx (retry) from 4xx (give up). */ public readonly status?: number ) { super(message); this.name = "ApiClientError"; } } /** Options for mutating requests. */ export interface WriteOptions { /** Reused as the `Idempotency-Key` header so the server de-duplicates retries. */ idempotencyKey?: string; /** * Offline behavior: * - undefined / "queue": auto-queue on offline/network failure and return an * optimistic value (unless the URL is online-only → throws). * - "reject": never queue — throw OfflineUnavailableError when offline. * - "manual": caller handles offline itself; never auto-queue (POS order submit). */ offline?: "queue" | "reject" | "manual"; } async function rawWrite( method: "POST" | "PUT" | "PATCH" | "DELETE", url: string, body: unknown, key: string ): Promise { const config = { headers: { "Idempotency-Key": key } }; let data: ApiResponse; switch (method) { case "POST": ({ data } = await api.post>(url, body, config)); break; case "PUT": ({ data } = await api.put>(url, body, config)); break; case "PATCH": ({ data } = await api.patch>(url, body, config)); break; case "DELETE": ({ data } = await api.delete>(url, config)); break; } if (method === "DELETE") { if (!data.success) { throw new ApiClientError(data.error?.code ?? "REQUEST_FAILED", data.error?.message ?? "Request failed"); } return undefined as T; } if (!data.success || data.data === undefined) { throw new ApiClientError( data.error?.code ?? "REQUEST_FAILED", data.error?.message ?? "Request failed", data.data ); } return data.data; } async function doWrite( method: "POST" | "PUT" | "PATCH" | "DELETE", url: string, body: unknown, opts?: WriteOptions ): Promise { const manual = opts?.offline === "manual"; const key = opts?.idempotencyKey ?? newIdempotencyKey(); const onlineOnly = opts?.offline === "reject" || isOnlineOnly(url); const offline = typeof navigator !== "undefined" && !navigator.onLine; // Already offline: queue (or reject online-only) without attempting the network. if (offline && !manual) { if (onlineOnly) throw new OfflineUnavailableError(); return (await queueWrite(method, url, body, key)) as T; } try { return await rawWrite(method, url, body, key); } catch (err) { // A genuine network failure (no response) → queue and return optimistic. // Real server/validation errors and online-only endpoints still throw. if (!manual && !onlineOnly && isNetworkError(err)) { return (await queueWrite(method, url, body, key)) as T; } throw err; } } export async function apiPost(url: string, body?: B, opts?: WriteOptions): Promise { return doWrite("POST", url, body, opts); } export async function apiPut(url: string, body?: B, opts?: WriteOptions): Promise { return doWrite("PUT", url, body, opts); } export async function apiPatch(url: string, body?: B, opts?: WriteOptions): Promise { return doWrite("PATCH", url, body, opts); } export async function apiDelete(url: string, opts?: WriteOptions): Promise { await doWrite("DELETE", url, undefined, opts); } /** GET binary response (QR PNG, Excel export, etc.) with auth headers. */ export async function apiGetBlob(path: string): Promise { const response = await api.get(path, { responseType: "blob" }); return response.data as Blob; } /** Public GET JSON (no auth required). */ export async function apiGetPublic(path: string): Promise { const { data } = await api.get>(path); if (!data.success || data.data === undefined) { throw new ApiClientError( data.error?.code ?? "REQUEST_FAILED", data.error?.message ?? "Request failed" ); } return data.data; } /** Public POST JSON (no auth required). */ export async function apiPostPublic(path: string, body?: B): Promise { const { data } = await api.post>(path, body); if (!data.success || data.data === undefined) { throw new ApiClientError( data.error?.code ?? "REQUEST_FAILED", data.error?.message ?? "Request failed" ); } return data.data; } export function openBlobInNewTab(blob: Blob): void { const url = URL.createObjectURL(blob); window.open(url, "_blank"); } export async function apiDownload(path: string, filename: string): Promise { const blob = await apiGetBlob(path); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = filename; anchor.click(); URL.revokeObjectURL(objectUrl); } export async function apiUpload(url: string, file: File): Promise { const form = new FormData(); form.append("file", file); const { data } = await api.post>(url, form, { headers: { "Content-Type": "multipart/form-data" }, }); if (!data.success || data.data === undefined) { throw new Error(data.error?.message ?? "Upload failed"); } return data.data; } export function resolveMediaUrl(path?: string | null): string | undefined { if (!path) return undefined; if (path.startsWith("http://") || path.startsWith("https://")) return path; const base = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080"; return `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`; }