159 lines
4.6 KiB
TypeScript
159 lines
4.6 KiB
TypeScript
// Thin fetch wrapper for the meditation API. The app is a static SPA, so every
|
|
// call runs in the browser and carries the bearer token from localStorage.
|
|
|
|
import {
|
|
APPRO_TOKEN_STORAGE_KEY,
|
|
MEDITATION_BASE_URL,
|
|
TOKEN_STORAGE_KEY,
|
|
} from "./config";
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
data: unknown;
|
|
constructor(message: string, status: number, data: unknown) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.data = data;
|
|
}
|
|
}
|
|
|
|
export function getToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
export function setToken(token: string) {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
|
}
|
|
|
|
export function clearToken() {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
export function getApproToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return window.localStorage.getItem(APPRO_TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
export function setApproToken(token: string) {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(APPRO_TOKEN_STORAGE_KEY, token);
|
|
}
|
|
|
|
export function clearApproToken() {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.removeItem(APPRO_TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
type Query = Record<string, string | number | boolean | undefined | null>;
|
|
|
|
interface RequestOptions {
|
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
// Plain object -> JSON body. FormData -> multipart (browser sets boundary).
|
|
body?: unknown;
|
|
query?: Query;
|
|
// Override/extend the base url (e.g. the approagency host for login).
|
|
baseUrl?: string;
|
|
// Skip attaching the bearer token (used by the login calls).
|
|
auth?: boolean;
|
|
// Extra headers to merge into the request.
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
function buildUrl(path: string, query?: Query, baseUrl = MEDITATION_BASE_URL) {
|
|
const url = new URL(
|
|
`${baseUrl}${path.startsWith("/") ? path : `/${path}`}`,
|
|
);
|
|
if (query) {
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
}
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
export async function apiFetch<T = unknown>(
|
|
path: string,
|
|
options: RequestOptions = {},
|
|
): Promise<T> {
|
|
const { method = "GET", body, query, baseUrl, auth = true, headers: extraHeaders } = options;
|
|
|
|
const headers: Record<string, string> = { Accept: "application/json" };
|
|
|
|
if (auth) {
|
|
const token = getToken();
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
|
|
if (extraHeaders) {
|
|
Object.assign(headers, extraHeaders);
|
|
}
|
|
|
|
let payload: BodyInit | undefined;
|
|
if (body instanceof FormData) {
|
|
payload = body; // browser sets multipart Content-Type + boundary
|
|
} else if (body !== undefined) {
|
|
headers["Content-Type"] = "application/json";
|
|
payload = JSON.stringify(body);
|
|
}
|
|
|
|
const res = await fetch(buildUrl(path, query, baseUrl), {
|
|
method,
|
|
headers,
|
|
body: payload,
|
|
});
|
|
|
|
const text = await res.text();
|
|
let data: unknown = null;
|
|
if (text) {
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
data = text;
|
|
}
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const message =
|
|
(data && typeof data === "object" && "message" in data
|
|
? String((data as { message: unknown }).message)
|
|
: null) ?? `خطای ارتباط با سرور (${res.status})`;
|
|
throw new ApiError(message, res.status, data);
|
|
}
|
|
|
|
return data as T;
|
|
}
|
|
|
|
// Many Laravel resource endpoints wrap the payload as { data: ... }. Unwrap it
|
|
// when present so callers always get the raw value.
|
|
export function unwrap<T = unknown>(res: unknown): T {
|
|
if (res && typeof res === "object" && "data" in res) {
|
|
return (res as { data: T }).data;
|
|
}
|
|
return res as T;
|
|
}
|
|
|
|
// Convenience builder for multipart bodies. Skips undefined/null and expands
|
|
// arrays to repeated `key[]` entries the way Laravel expects.
|
|
export function toFormData(fields: Record<string, unknown>): FormData {
|
|
const fd = new FormData();
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
if (value === undefined || value === null || value === "") continue;
|
|
if (value instanceof File || value instanceof Blob) {
|
|
fd.append(key, value);
|
|
} else if (Array.isArray(value)) {
|
|
for (const item of value) fd.append(`${key}[]`, String(item));
|
|
} else if (typeof value === "boolean") {
|
|
fd.append(key, value ? "1" : "0");
|
|
} else {
|
|
fd.append(key, String(value));
|
|
}
|
|
}
|
|
return fd;
|
|
}
|