2026-09-15 23:24:34 +03:30
|
|
|
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<T> {
|
|
|
|
|
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;
|
2026-09-20 12:26:41 +03:30
|
|
|
bio?: string;
|
|
|
|
|
streak_count: number;
|
2026-09-26 18:10:02 +03:30
|
|
|
notify_matches: boolean;
|
|
|
|
|
notify_messages: boolean;
|
|
|
|
|
notify_likes: boolean;
|
2026-09-15 23:24:34 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-09-20 12:26:41 +03:30
|
|
|
bio?: string;
|
2026-09-15 23:24:34 +03:30
|
|
|
photos: string[];
|
|
|
|
|
distance_km?: number;
|
|
|
|
|
interests?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CandidateAnswer {
|
|
|
|
|
question: string;
|
|
|
|
|
answer: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CandidateDetail {
|
|
|
|
|
id: number;
|
|
|
|
|
first_name: string;
|
|
|
|
|
age: number;
|
2026-09-20 12:26:41 +03:30
|
|
|
bio?: string;
|
2026-09-15 23:24:34 +03:30
|
|
|
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_at?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SwipeResult {
|
|
|
|
|
matched: boolean;
|
|
|
|
|
match?: MatchView;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MessageView {
|
|
|
|
|
id: number;
|
|
|
|
|
match_id: number;
|
|
|
|
|
sender_id: number;
|
|
|
|
|
text: 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<boolean> | null = null;
|
|
|
|
|
|
|
|
|
|
async function rawRequest<T>(
|
|
|
|
|
path: string,
|
|
|
|
|
options: RequestInit = {},
|
|
|
|
|
auth = false
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
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<T> | 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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
|
|
|
try {
|
|
|
|
|
return await rawRequest<T>(path, options, true);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof ApiError && err.status === 401) {
|
|
|
|
|
const refreshed = await ensureRefreshed();
|
|
|
|
|
if (refreshed) {
|
|
|
|
|
return await rawRequest<T>(path, options, true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** مثل authRequest ولی برای آپلود فایل (بدون ستکردن Content-Type، چون FormData خودش boundary میسازه) */
|
|
|
|
|
async function authUpload<T>(path: string, formData: FormData): Promise<T> {
|
|
|
|
|
async function send(): Promise<T> {
|
|
|
|
|
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<T> | 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<boolean> {
|
|
|
|
|
const refresh = tokenStore.getRefresh();
|
|
|
|
|
if (!refresh) return false;
|
|
|
|
|
|
|
|
|
|
if (!refreshPromise) {
|
|
|
|
|
refreshPromise = rawRequest<TokenPair>("/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<null>("/auth/request-otp", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ phone }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function verifyOtp(phone: string, otp: string) {
|
|
|
|
|
return rawRequest<AuthResponseData>("/auth/verify-otp", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ phone, otp }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------- پروفایل ----------------
|
|
|
|
|
|
|
|
|
|
export function getProfileMe() {
|
|
|
|
|
return authRequest<FullProfile>("/profile/me");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------- آنبوردینگ ----------------
|
|
|
|
|
|
|
|
|
|
export function getOnboardingStatus() {
|
|
|
|
|
return authRequest<OnboardingStatus>("/onboarding/status");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getOptions(category: CategoryKey) {
|
|
|
|
|
return authRequest<Option[]>(`/onboarding/options/${category}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getAvailableQuestions() {
|
|
|
|
|
return authRequest<ProfileQuestion[]>("/onboarding/profile-questions");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function submitBasicInfo(payload: {
|
|
|
|
|
gender: "male" | "female";
|
|
|
|
|
first_name: string;
|
|
|
|
|
birth_date: string;
|
|
|
|
|
}) {
|
|
|
|
|
return authRequest<OnboardingStatus>("/onboarding/basic-info", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 09:22:32 +03:30
|
|
|
// خودِ عکسها با uploadPhoto/deletePhoto آپلود میشن؛ این فقط مرحله رو تایید/رد میکنه
|
|
|
|
|
export function confirmPhotosStep() {
|
|
|
|
|
return authRequest<OnboardingStatus>("/onboarding/photos", { method: "POST" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 23:24:34 +03:30
|
|
|
export function submitBodyInfo(payload: { height_cm: number; weight_kg: number }) {
|
|
|
|
|
return authRequest<OnboardingStatus>("/onboarding/body-info", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function submitProfileQuestions(
|
|
|
|
|
answers: { question_id: number; answer: string }[]
|
|
|
|
|
) {
|
|
|
|
|
return authRequest<OnboardingStatus>("/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<OnboardingStatus>("/onboarding/education", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function submitOccupations(optionIds: number[]) {
|
|
|
|
|
return authRequest<OnboardingStatus>("/onboarding/occupations", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ option_ids: optionIds }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function submitRelationshipImportance(optionIds: number[]) {
|
|
|
|
|
return authRequest<OnboardingStatus>("/onboarding/relationship-importance", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ option_ids: optionIds }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function submitInterests(optionIds: number[]) {
|
|
|
|
|
return authRequest<OnboardingStatus>("/onboarding/interests", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ option_ids: optionIds }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function submitLookingFor(optionIds: number[]) {
|
|
|
|
|
return authRequest<OnboardingStatus>("/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<Photo>("/profile/photos", formData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deletePhoto(id: number) {
|
|
|
|
|
return authRequest<null>(`/profile/photos/${id}`, { method: "DELETE" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function updateLocation(latitude: number, longitude: number) {
|
|
|
|
|
return authRequest<null>("/profile/location", {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: JSON.stringify({ latitude, longitude }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 12:26:41 +03:30
|
|
|
export function updateBio(bio: string) {
|
|
|
|
|
return authRequest<null>("/profile/bio", {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: JSON.stringify({ bio }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CheckInResult {
|
|
|
|
|
streak_count: number;
|
|
|
|
|
is_new_today: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function checkIn() {
|
|
|
|
|
return authRequest<CheckInResult>("/profile/checkin", { method: "POST" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 18:10:02 +03:30
|
|
|
export interface NotificationSettings {
|
|
|
|
|
notify_matches: boolean;
|
|
|
|
|
notify_messages: boolean;
|
|
|
|
|
notify_likes: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function updateNotificationSettings(settings: NotificationSettings) {
|
|
|
|
|
return authRequest<null>("/profile/notifications", {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: JSON.stringify(settings),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deleteAccount() {
|
|
|
|
|
return authRequest<null>("/profile/me", { method: "DELETE" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 23:24:34 +03:30
|
|
|
// ---------------- دوستیابی ----------------
|
|
|
|
|
|
|
|
|
|
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<DiscoverCandidate[]>(`/discover${qs ? `?${qs}` : ""}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getCandidateDetail(id: number) {
|
|
|
|
|
return authRequest<CandidateDetail>(`/discover/${id}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function swipe(targetId: number, action: SwipeActionType) {
|
|
|
|
|
return authRequest<SwipeResult>(`/discover/${targetId}/swipe`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ action }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listMatches() {
|
|
|
|
|
return authRequest<MatchView[]>("/matches");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listMessages(matchId: number) {
|
|
|
|
|
return authRequest<MessageView[]>(`/matches/${matchId}/messages`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sendMessage(matchId: number, text: string) {
|
|
|
|
|
return authRequest<MessageView>(`/matches/${matchId}/messages`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ text }),
|
|
|
|
|
});
|
|
|
|
|
}
|