2026-06-01 18:23:31 +03:30
|
|
|
import axios, {
|
|
|
|
|
type AxiosError,
|
|
|
|
|
type InternalAxiosRequestConfig,
|
|
|
|
|
} from "axios";
|
|
|
|
|
import type { ApiResponse, AuthTokenResponse } from "./types";
|
2026-05-27 21:34:12 +03:30
|
|
|
import { getOrCreateTerminalId } from "@/lib/terminal";
|
2026-06-01 18:23:31 +03:30
|
|
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
2026-06-02 18:34:54 +03:30
|
|
|
import {
|
|
|
|
|
isNetworkError,
|
|
|
|
|
isOnlineOnly,
|
|
|
|
|
newIdempotencyKey,
|
|
|
|
|
OfflineUnavailableError,
|
|
|
|
|
queueWrite,
|
|
|
|
|
} from "@/lib/offline/offline-write";
|
2026-05-27 21:34:12 +03:30
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-01 18:23:31 +03:30
|
|
|
/**
|
|
|
|
|
* 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<string | null> | null = null;
|
|
|
|
|
|
|
|
|
|
async function refreshAccessToken(): Promise<string | null> {
|
|
|
|
|
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<ApiResponse<AuthTokenResponse>>(
|
|
|
|
|
`${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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 21:34:12 +03:30
|
|
|
api.interceptors.response.use(
|
|
|
|
|
(response) => response,
|
|
|
|
|
async (error: AxiosError<ApiResponse<unknown>>) => {
|
2026-06-01 18:23:31 +03:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 21:34:12 +03:30
|
|
|
const apiError = error.response?.data?.error;
|
|
|
|
|
if (apiError?.code) {
|
2026-06-02 18:19:29 +03:30
|
|
|
return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status));
|
2026-05-27 21:34:12 +03:30
|
|
|
}
|
2026-06-01 18:23:31 +03:30
|
|
|
if (status === 401 && typeof window !== "undefined") {
|
2026-05-27 21:34:12 +03:30
|
|
|
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<T> {
|
|
|
|
|
success: boolean;
|
|
|
|
|
data?: T[];
|
|
|
|
|
meta?: PagedMeta;
|
|
|
|
|
error?: { code: string; message: string; field?: string };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function apiGet<T>(url: string): Promise<T> {
|
|
|
|
|
const { data } = await api.get<ApiResponse<T>>(url);
|
|
|
|
|
if (!data.success || data.data === undefined) {
|
|
|
|
|
throw new Error(data.error?.message ?? "Request failed");
|
|
|
|
|
}
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function apiGetPaged<T>(url: string): Promise<{ items: T[]; meta: PagedMeta }> {
|
|
|
|
|
const { data } = await api.get<PagedApiResponse<T>>(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,
|
2026-05-29 17:14:46 +03:30
|
|
|
message: string,
|
|
|
|
|
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
|
2026-06-02 18:19:29 +03:30
|
|
|
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
|
2026-05-27 21:34:12 +03:30
|
|
|
) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "ApiClientError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:34:54 +03:30
|
|
|
/** Options for mutating requests. */
|
2026-06-02 18:19:29 +03:30
|
|
|
export interface WriteOptions {
|
2026-06-02 18:34:54 +03:30
|
|
|
/** Reused as the `Idempotency-Key` header so the server de-duplicates retries. */
|
2026-06-02 18:19:29 +03:30
|
|
|
idempotencyKey?: string;
|
2026-06-02 18:34:54 +03:30
|
|
|
/**
|
|
|
|
|
* 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";
|
2026-06-02 18:19:29 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:34:54 +03:30
|
|
|
async function rawWrite<T>(
|
|
|
|
|
method: "POST" | "PUT" | "PATCH" | "DELETE",
|
|
|
|
|
url: string,
|
|
|
|
|
body: unknown,
|
|
|
|
|
key: string
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const config = { headers: { "Idempotency-Key": key } };
|
|
|
|
|
let data: ApiResponse<T>;
|
|
|
|
|
switch (method) {
|
|
|
|
|
case "POST":
|
|
|
|
|
({ data } = await api.post<ApiResponse<T>>(url, body, config));
|
|
|
|
|
break;
|
|
|
|
|
case "PUT":
|
|
|
|
|
({ data } = await api.put<ApiResponse<T>>(url, body, config));
|
|
|
|
|
break;
|
|
|
|
|
case "PATCH":
|
|
|
|
|
({ data } = await api.patch<ApiResponse<T>>(url, body, config));
|
|
|
|
|
break;
|
|
|
|
|
case "DELETE":
|
|
|
|
|
({ data } = await api.delete<ApiResponse<T>>(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;
|
|
|
|
|
}
|
2026-05-27 21:34:12 +03:30
|
|
|
if (!data.success || data.data === undefined) {
|
2026-06-02 18:34:54 +03:30
|
|
|
throw new ApiClientError(
|
|
|
|
|
data.error?.code ?? "REQUEST_FAILED",
|
|
|
|
|
data.error?.message ?? "Request failed",
|
|
|
|
|
data.data
|
|
|
|
|
);
|
2026-05-27 21:34:12 +03:30
|
|
|
}
|
|
|
|
|
return data.data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:34:54 +03:30
|
|
|
async function doWrite<T>(
|
|
|
|
|
method: "POST" | "PUT" | "PATCH" | "DELETE",
|
|
|
|
|
url: string,
|
|
|
|
|
body: unknown,
|
|
|
|
|
opts?: WriteOptions
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
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;
|
2026-05-27 21:34:12 +03:30
|
|
|
}
|
2026-06-02 18:34:54 +03:30
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await rawWrite<T>(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<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
|
|
|
|
return doWrite<T>("POST", url, body, opts);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
|
|
|
|
return doWrite<T>("PUT", url, body, opts);
|
2026-05-27 21:34:12 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:19:29 +03:30
|
|
|
export async function apiPatch<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
2026-06-02 18:34:54 +03:30
|
|
|
return doWrite<T>("PATCH", url, body, opts);
|
2026-05-27 21:34:12 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:19:29 +03:30
|
|
|
export async function apiDelete(url: string, opts?: WriteOptions): Promise<void> {
|
2026-06-02 18:34:54 +03:30
|
|
|
await doWrite<void>("DELETE", url, undefined, opts);
|
2026-05-27 21:34:12 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */
|
|
|
|
|
export async function apiGetBlob(path: string): Promise<Blob> {
|
|
|
|
|
const response = await api.get(path, { responseType: "blob" });
|
|
|
|
|
return response.data as Blob;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Public GET JSON (no auth required). */
|
|
|
|
|
export async function apiGetPublic<T>(path: string): Promise<T> {
|
|
|
|
|
const { data } = await api.get<ApiResponse<T>>(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<T, B = unknown>(path: string, body?: B): Promise<T> {
|
|
|
|
|
const { data } = await api.post<ApiResponse<T>>(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<void> {
|
|
|
|
|
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<T>(url: string, file: File): Promise<T> {
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
form.append("file", file);
|
|
|
|
|
const { data } = await api.post<ApiResponse<T>>(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}`}`;
|
|
|
|
|
}
|