feat: initial
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
// 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 { 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 } = options;
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
|
||||
if (auth) {
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user