import { tokenStore } from "./token-store"; export const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1"; // آدرس ریشه‌ی بک‌اند (بدون /api/v1) — برای ساختن URL کامل فایل‌های استاتیک (عکس‌ها) export const API_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, ""); // url ذخیره‌شده توی دیتابیس یه مسیر نسبیه (مثلاً /uploads/photos/3/xxx.jpg) export function photoUrl(path: string): string { if (path.startsWith("http")) return path; return `${API_ORIGIN}${path}`; } // ---------------- انواع پاسخ بک‌اند ---------------- interface ApiEnvelope { success: boolean; message?: string; data?: T; errors?: unknown; } export class ApiError extends Error { errors?: unknown; status: number; constructor(message: string, status: number, errors?: unknown) { super(message); this.status = status; this.errors = errors; } } export type ApprovalStatus = "pending" | "approved" | "rejected"; export interface UserProfile { id: number; phone: string; first_name?: string; gender?: "male" | "female"; birth_date?: string; height_cm?: number; weight_kg?: number; current_step: number; is_onboarded: boolean; approval_status: ApprovalStatus; rejection_reason?: string; is_admin: boolean; bio?: string; streak_count: number; notify_matches: boolean; notify_messages: boolean; notify_likes: boolean; public_key?: string; } export interface TokenPair { access_token: string; refresh_token: string; } export interface AuthResponseData { tokens: TokenPair; user: UserProfile; is_new_user: boolean; onboarding_done: boolean; } export interface StepView { step: number; key: string; done: boolean; } export interface OnboardingStatus { current_step: number; current_step_key: string; is_onboarded: boolean; approval_status: ApprovalStatus; rejection_reason?: string; steps: StepView[]; } export interface Option { id: number; category_id: number; title: string; sort_order: number; is_active: boolean; } export interface ProfileQuestion { id: number; text: string; sort_order: number; is_active: boolean; } export interface SelectionSummary { category_key: string; option_ids: number[]; titles: string[]; } export interface ProfileAnswer { id: number; user_id: number; question_id: number; answer: string; question?: ProfileQuestion; } export interface Photo { id: number; user_id: number; url: string; sort_order: number; } export interface FullProfile { user: UserProfile; onboarding_status: OnboardingStatus; selections: SelectionSummary[]; profile_answers: ProfileAnswer[]; photos: Photo[]; } // ---------------- دوستیابی ---------------- export interface DiscoverCandidate { id: number; first_name: string; age: number; bio?: string; photos: string[]; distance_km?: number; interests?: string[]; } export interface CandidateAnswer { question: string; answer: string; } export interface CandidateDetail { id: number; first_name: string; age: number; bio?: string; photos: string[]; distance_km?: number; height_cm?: number; weight_kg?: number; education?: string; field_of_study?: string; occupations?: string[]; interests?: string[]; important_in_relationship?: string[]; looking_for?: string; profile_answers?: CandidateAnswer[]; } export type SwipeActionType = "like" | "pass" | "superlike"; export interface MatchView { id: number; user_id: number; first_name: string; photos: string[]; created_at: string; last_message?: string; last_message_cipher_text?: string; last_message_nonce?: string; last_at?: string; /** کلید عمومی X25519 طرف مقابل (base64)، برای رمزنگاری سرتاسری. اگه طرف مقابل هنوز کلید ثبت نکرده باشه خالیه. */ partner_public_key?: string; } export interface SwipeResult { matched: boolean; match?: MatchView; } export interface MessageView { id: number; match_id: number; sender_id: number; /** فقط برای پیام‌های قدیمیِ قبل از رمزنگاری سرتاسری پر می‌شه */ text?: string; cipher_text?: string; nonce?: string; created_at: string; is_mine: boolean; } export type CategoryKey = | "education_level" | "field_of_study" | "occupation" | "important_in_relationship" | "interest" | "looking_for"; // ---------------- هسته‌ی fetch ---------------- let refreshPromise: Promise | null = null; async function rawRequest( path: string, options: RequestInit = {}, auth = false ): Promise { const headers = new Headers(options.headers); headers.set("Content-Type", "application/json"); if (auth) { const token = tokenStore.getAccess(); if (token) headers.set("Authorization", `Bearer ${token}`); } const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); let body: ApiEnvelope | null = null; try { body = await res.json(); } catch { // بدنه خالی (مثلاً بعضی پاسخ‌های موفق بدون data) } if (!res.ok) { throw new ApiError( body?.message ?? "خطای غیرمنتظره از سرور", res.status, body?.errors ); } return (body?.data as T) ?? (undefined as T); } /** * درخواست احراز هویت‌دار؛ اگه توکن access منقضی شده باشه (۴۰۱)، * یک‌بار تلاش می‌کنه با refresh token توکن جدید بگیره و دوباره درخواست رو بفرسته. */ async function authRequest(path: string, options: RequestInit = {}): Promise { try { return await rawRequest(path, options, true); } catch (err) { if (err instanceof ApiError && err.status === 401) { const refreshed = await ensureRefreshed(); if (refreshed) { return await rawRequest(path, options, true); } } throw err; } } /** مثل authRequest ولی برای آپلود فایل (بدون ست‌کردن Content-Type، چون FormData خودش boundary می‌سازه) */ async function authUpload(path: string, formData: FormData): Promise { async function send(): Promise { const headers = new Headers(); const token = tokenStore.getAccess(); if (token) headers.set("Authorization", `Bearer ${token}`); const res = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers }); let body: ApiEnvelope | null = null; try { body = await res.json(); } catch { // بدنه خالی } if (!res.ok) { throw new ApiError(body?.message ?? "خطای غیرمنتظره از سرور", res.status, body?.errors); } return (body?.data as T) ?? (undefined as T); } try { return await send(); } catch (err) { if (err instanceof ApiError && err.status === 401) { const refreshed = await ensureRefreshed(); if (refreshed) return await send(); } throw err; } } async function ensureRefreshed(): Promise { const refresh = tokenStore.getRefresh(); if (!refresh) return false; if (!refreshPromise) { refreshPromise = rawRequest("/auth/token/refresh", { method: "POST", body: JSON.stringify({ refresh_token: refresh }), }) .then((tokens) => { tokenStore.set(tokens.access_token, tokens.refresh_token); return true; }) .catch(() => { tokenStore.clear(); return false; }) .finally(() => { refreshPromise = null; }); } return refreshPromise; } // ---------------- احراز هویت ---------------- export function requestOtp(phone: string) { return rawRequest("/auth/request-otp", { method: "POST", body: JSON.stringify({ phone }), }); } export function verifyOtp(phone: string, otp: string) { return rawRequest("/auth/verify-otp", { method: "POST", body: JSON.stringify({ phone, otp }), }); } // ---------------- پروفایل ---------------- export function getProfileMe() { return authRequest("/profile/me"); } // ---------------- آنبوردینگ ---------------- export function getOnboardingStatus() { return authRequest("/onboarding/status"); } export function getOptions(category: CategoryKey) { return authRequest(`/onboarding/options/${category}`); } export function getAvailableQuestions() { return authRequest("/onboarding/profile-questions"); } export function submitBasicInfo(payload: { gender: "male" | "female"; first_name: string; birth_date: string; }) { return authRequest("/onboarding/basic-info", { method: "POST", body: JSON.stringify(payload), }); } // خودِ عکس‌ها با uploadPhoto/deletePhoto آپلود می‌شن؛ این فقط مرحله رو تایید/رد می‌کنه export function confirmPhotosStep() { return authRequest("/onboarding/photos", { method: "POST" }); } export function submitBodyInfo(payload: { height_cm: number; weight_kg: number }) { return authRequest("/onboarding/body-info", { method: "POST", body: JSON.stringify(payload), }); } export function submitProfileQuestions( answers: { question_id: number; answer: string }[] ) { return authRequest("/onboarding/profile-questions", { method: "POST", body: JSON.stringify({ answers }), }); } export function submitEducation(payload: { education_level_option_id: number; field_of_study_option_id: number; }) { return authRequest("/onboarding/education", { method: "POST", body: JSON.stringify(payload), }); } export function submitOccupations(optionIds: number[]) { return authRequest("/onboarding/occupations", { method: "POST", body: JSON.stringify({ option_ids: optionIds }), }); } export function submitRelationshipImportance(optionIds: number[]) { return authRequest("/onboarding/relationship-importance", { method: "POST", body: JSON.stringify({ option_ids: optionIds }), }); } export function submitInterests(optionIds: number[]) { return authRequest("/onboarding/interests", { method: "POST", body: JSON.stringify({ option_ids: optionIds }), }); } export function submitLookingFor(optionIds: number[]) { return authRequest("/onboarding/looking-for", { method: "POST", body: JSON.stringify({ option_ids: optionIds }), }); } // ---------------- عکس‌ها و موقعیت مکانی ---------------- export function uploadPhoto(file: File) { const formData = new FormData(); formData.append("photo", file); return authUpload("/profile/photos", formData); } export function deletePhoto(id: number) { return authRequest(`/profile/photos/${id}`, { method: "DELETE" }); } export function updateLocation(latitude: number, longitude: number) { return authRequest("/profile/location", { method: "PATCH", body: JSON.stringify({ latitude, longitude }), }); } export function updateBio(bio: string) { return authRequest("/profile/bio", { method: "PATCH", body: JSON.stringify({ bio }), }); } export interface CheckInResult { streak_count: number; is_new_today: boolean; } export function checkIn() { return authRequest("/profile/checkin", { method: "POST" }); } export interface NotificationSettings { notify_matches: boolean; notify_messages: boolean; notify_likes: boolean; } export function updateNotificationSettings(settings: NotificationSettings) { return authRequest("/profile/notifications", { method: "PATCH", body: JSON.stringify(settings), }); } export function deleteAccount() { return authRequest("/profile/me", { method: "DELETE" }); } // ---------------- دوستیابی ---------------- export function getDiscoverFeed(params?: { maxDistanceKm?: number; minAge?: number; maxAge?: number; }) { const query = new URLSearchParams(); if (params?.maxDistanceKm) query.set("max_distance_km", String(params.maxDistanceKm)); if (params?.minAge) query.set("min_age", String(params.minAge)); if (params?.maxAge) query.set("max_age", String(params.maxAge)); const qs = query.toString(); return authRequest(`/discover${qs ? `?${qs}` : ""}`); } export function getCandidateDetail(id: number) { return authRequest(`/discover/${id}`); } export function swipe(targetId: number, action: SwipeActionType) { return authRequest(`/discover/${targetId}/swipe`, { method: "POST", body: JSON.stringify({ action }), }); } export function listMatches() { return authRequest("/matches"); } export function listMessages(matchId: number) { return authRequest(`/matches/${matchId}/messages`); } // cipherText/nonce قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل // رمزنگاری شدن (src/lib/e2ee.ts)؛ متن خام پیام هیچ‌وقت به این تابع نمی‌رسه. export function sendMessage(matchId: number, cipherText: string, nonce: string) { return authRequest(`/matches/${matchId}/messages`, { method: "POST", body: JSON.stringify({ cipher_text: cipherText, nonce }), }); } export function updatePublicKey(publicKey: string) { return authRequest("/profile/public-key", { method: "PUT", body: JSON.stringify({ public_key: publicKey }), }); }