Initial commit: dating app web frontend (Next.js)
Phone+OTP auth, staged onboarding, admin-approval-gated home screen, and the full discovery/match/chat UI (swipe deck with drag gestures, match modal, profile detail, filters, messages/chat) wired to the real backend, styled after a Figma dating-app UI kit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+457
@@ -0,0 +1,457 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
photos: string[];
|
||||
distance_km?: number;
|
||||
interests?: string[];
|
||||
}
|
||||
|
||||
export interface CandidateAnswer {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface CandidateDetail {
|
||||
id: number;
|
||||
first_name: string;
|
||||
age: number;
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- دوستیابی ----------------
|
||||
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { tokenStore } from "./token-store";
|
||||
import {
|
||||
UserProfile,
|
||||
OnboardingStatus,
|
||||
verifyOtp as apiVerifyOtp,
|
||||
getOnboardingStatus,
|
||||
getProfileMe,
|
||||
} from "./api";
|
||||
|
||||
type AuthStatus = "loading" | "guest" | "authenticated";
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: UserProfile | null;
|
||||
onboardingStatus: OnboardingStatus | null;
|
||||
login: (phone: string, otp: string) => Promise<UserProfile>;
|
||||
logout: () => void;
|
||||
refreshOnboardingStatus: () => Promise<OnboardingStatus | null>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [onboardingStatus, setOnboardingStatus] = useState<OnboardingStatus | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
const access = tokenStore.getAccess();
|
||||
if (!access) {
|
||||
setStatus("guest");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await getProfileMe();
|
||||
setUser(profile.user);
|
||||
setOnboardingStatus(profile.onboarding_status);
|
||||
setStatus("authenticated");
|
||||
} catch {
|
||||
tokenStore.clear();
|
||||
setStatus("guest");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
const login = useCallback(async (phone: string, otp: string) => {
|
||||
const data = await apiVerifyOtp(phone, otp);
|
||||
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
|
||||
setUser(data.user);
|
||||
setStatus("authenticated");
|
||||
// وضعیت کامل مراحل رو جدا میگیریم چون verify-otp فقط current_step/is_onboarded میده
|
||||
const fullStatus = await getOnboardingStatus();
|
||||
setOnboardingStatus(fullStatus);
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
tokenStore.clear();
|
||||
setUser(null);
|
||||
setOnboardingStatus(null);
|
||||
setStatus("guest");
|
||||
}, []);
|
||||
|
||||
const refreshOnboardingStatus = useCallback(async () => {
|
||||
try {
|
||||
const s = await getOnboardingStatus();
|
||||
setOnboardingStatus(s);
|
||||
return s;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
const profile = await getProfileMe();
|
||||
setUser(profile.user);
|
||||
setOnboardingStatus(profile.onboarding_status);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
status,
|
||||
user,
|
||||
onboardingStatus,
|
||||
login,
|
||||
logout,
|
||||
refreshOnboardingStatus,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth باید داخل AuthProvider استفاده بشه");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "./auth-context";
|
||||
import { ApiError } from "./api";
|
||||
|
||||
export function useRequireAuth() {
|
||||
const router = useRouter();
|
||||
const { status, user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "guest") {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
return { status, user };
|
||||
}
|
||||
|
||||
export function formatApiError(err: unknown, fallback = "یه مشکلی پیش اومد؛ دوباره امتحان کن"): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (typeof err.errors === "string" && err.errors && err.errors !== err.message) {
|
||||
return `${err.message} — ${err.errors}`;
|
||||
}
|
||||
return err.message || fallback;
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface StepMeta {
|
||||
key: string;
|
||||
slug: string;
|
||||
order: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
// همون ترتیب و کلیدهایی که بکاند در models.OnboardingStepOrder برمیگردونه
|
||||
export const ONBOARDING_STEPS: StepMeta[] = [
|
||||
{ key: "basic_info", slug: "basic-info", order: 1, title: "خب اول بگو کی هستی", emoji: "👋" },
|
||||
{ key: "body_info", slug: "body-info", order: 2, title: "قد و وزنت چقدره؟", emoji: "📏" },
|
||||
{ key: "profile_questions", slug: "profile-questions", order: 3, title: "یکم از خودت بگو", emoji: "💬" },
|
||||
{ key: "education", slug: "education", order: 4, title: "تحصیلاتت چیه؟", emoji: "🎓" },
|
||||
{ key: "occupations", slug: "occupations", order: 5, title: "چیکار میکنی؟", emoji: "💼" },
|
||||
{
|
||||
key: "relationship_importance",
|
||||
slug: "relationship-importance",
|
||||
order: 6,
|
||||
title: "توی رابطه چی برات مهمه؟",
|
||||
emoji: "❤️",
|
||||
},
|
||||
{ key: "interests", slug: "interests", order: 7, title: "به چی علاقه داری؟", emoji: "✨" },
|
||||
{ key: "looking_for", slug: "looking-for", order: 8, title: "دنبال چی میگردی؟", emoji: "🔍" },
|
||||
];
|
||||
|
||||
export function stepBySlug(slug: string) {
|
||||
return ONBOARDING_STEPS.find((s) => s.slug === slug);
|
||||
}
|
||||
|
||||
export function stepByOrder(order: number) {
|
||||
return ONBOARDING_STEPS.find((s) => s.order === order);
|
||||
}
|
||||
|
||||
export function nextStepSlug(currentSlug: string): string | null {
|
||||
const current = stepBySlug(currentSlug);
|
||||
if (!current) return null;
|
||||
const next = ONBOARDING_STEPS.find((s) => s.order === current.order + 1);
|
||||
return next ? next.slug : null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
const ACCESS_KEY = "dating_app_access_token";
|
||||
const REFRESH_KEY = "dating_app_refresh_token";
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
export const tokenStore = {
|
||||
getAccess(): string | null {
|
||||
if (!isBrowser()) return null;
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
},
|
||||
getRefresh(): string | null {
|
||||
if (!isBrowser()) return null;
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
},
|
||||
set(access: string, refresh: string) {
|
||||
if (!isBrowser()) return;
|
||||
localStorage.setItem(ACCESS_KEY, access);
|
||||
localStorage.setItem(REFRESH_KEY, refresh);
|
||||
},
|
||||
clear() {
|
||||
if (!isBrowser()) return;
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
// فاصله رو بهصورت تقریبی گرد میکنه (دقیقاً مثل اپهای دوستیابی: ۲۰ کیلومتر، ۱۰۰ کیلومتر و...)
|
||||
export function formatDistance(km: number): string {
|
||||
if (km < 1) return "کمتر از 1 کیلومتر";
|
||||
if (km < 20) return `${Math.round(km)} کیلومتر`;
|
||||
const rounded = Math.round(km / 5) * 5;
|
||||
return `${rounded} کیلومتر`;
|
||||
}
|
||||
|
||||
export function calcAge(birthDate?: string): number | null {
|
||||
if (!birthDate) return null;
|
||||
const b = new Date(birthDate);
|
||||
if (Number.isNaN(b.getTime())) return null;
|
||||
const now = new Date();
|
||||
let age = now.getFullYear() - b.getFullYear();
|
||||
const m = now.getMonth() - b.getMonth();
|
||||
if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
Reference in New Issue
Block a user