feat: initial

This commit is contained in:
2026-06-03 03:08:57 +03:30
parent 3d9585ac5c
commit 6fa30eb29a
45 changed files with 7053 additions and 86 deletions
+133
View File
@@ -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;
}