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:
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronRight, MapPin, X, Heart, Star, User, Ruler, Weight, GraduationCap, Briefcase, Sparkles, Search } from "lucide-react";
|
||||
import { Tag } from "@/components/ui/Badge";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { CandidateDetail, getCandidateDetail, photoUrl, swipe } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { formatDistance } from "@/lib/utils";
|
||||
|
||||
function SectionCard({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-(--color-content-secondary)">
|
||||
{icon}
|
||||
<span className="text-[13px] font-bold">{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfileDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const id = Number(params.id);
|
||||
|
||||
const [profile, setProfile] = useState<CandidateDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [photoIndex, setPhotoIndex] = useState(0);
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getCandidateDetail(id)
|
||||
.then(setProfile)
|
||||
.catch((err) => setError(formatApiError(err, "این پروفایل پیدا نشد")));
|
||||
}, [id]);
|
||||
|
||||
async function handleSwipe(action: "like" | "pass" | "superlike") {
|
||||
setActing(true);
|
||||
try {
|
||||
const result = await swipe(id, action);
|
||||
if (result.matched && result.match) {
|
||||
router.push(`/messages/${result.match.id}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// اگه سواپ با خطا مواجه بشه، بازم کاربر رو برمیگردونیم به کشف
|
||||
}
|
||||
router.push("/home");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<ErrorBanner message={error} />
|
||||
<button
|
||||
onClick={() => router.push("/home")}
|
||||
className="text-[13px] font-bold text-(--color-brand-600)"
|
||||
>
|
||||
برگرد به کشف
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!profile) return <FullScreenLoader />;
|
||||
|
||||
function goTo(i: number) {
|
||||
if (!profile) return;
|
||||
setPhotoIndex(Math.max(0, Math.min(profile.photos.length - 1, i)));
|
||||
}
|
||||
|
||||
const photo = profile.photos[photoIndex];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<div className="relative w-full aspect-[4/5] bg-(--color-bg-tertiary)">
|
||||
{photo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={photoUrl(photo)}
|
||||
alt={profile.first_name}
|
||||
className="absolute inset-0 w-full h-full object-cover cursor-zoom-in"
|
||||
onClick={() => setViewerOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-(--color-content-disabled)">
|
||||
<User size={72} strokeWidth={1.2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.photos.length > 0 && (
|
||||
<div className="absolute inset-x-0 top-0 flex gap-1.5 p-3 pt-[calc(env(safe-area-inset-top)+8px)]">
|
||||
{profile.photos.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
className={`h-1 flex-1 rounded-(--radius-full) transition-colors ${
|
||||
i === photoIndex ? "bg-white" : "bg-white/35"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="absolute top-[calc(env(safe-area-inset-top)+16px)] right-4 w-9 h-9 rounded-full bg-black/30 backdrop-blur flex items-center justify-center"
|
||||
>
|
||||
<ChevronRight size={20} className="text-white" />
|
||||
</button>
|
||||
|
||||
{profile.photos.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
aria-label="عکس قبلی"
|
||||
onClick={() => goTo(photoIndex - 1)}
|
||||
className="absolute inset-y-0 right-0 w-1/3"
|
||||
/>
|
||||
<button
|
||||
aria-label="عکس بعدی"
|
||||
onClick={() => goTo(photoIndex + 1)}
|
||||
className="absolute inset-y-0 left-0 w-1/3"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute -bottom-8 inset-x-0 flex items-center justify-center gap-5">
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.85 }}
|
||||
disabled={acting}
|
||||
onClick={() => handleSwipe("pass")}
|
||||
className="w-14 h-14 rounded-full bg-white flex items-center justify-center shadow-(--shadow-float) text-(--color-status-error) disabled:opacity-50"
|
||||
>
|
||||
<X size={26} strokeWidth={2.5} />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.85 }}
|
||||
disabled={acting}
|
||||
onClick={() => handleSwipe("superlike")}
|
||||
className="w-9 h-9 rounded-full bg-white flex items-center justify-center shadow-(--shadow-float) text-(--color-pop-purple) disabled:opacity-50"
|
||||
>
|
||||
<Star size={16} strokeWidth={2.5} />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.85 }}
|
||||
disabled={acting}
|
||||
onClick={() => handleSwipe("like")}
|
||||
className="w-16 h-16 rounded-full bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-md) text-white disabled:opacity-50"
|
||||
>
|
||||
<Heart size={28} fill="white" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-5 pt-12 pb-8">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">
|
||||
{profile.first_name || "بدون نام"}, {profile.age}
|
||||
</h1>
|
||||
|
||||
{profile.distance_km != null && (
|
||||
<div className="flex items-center gap-1.5 -mt-2">
|
||||
<MapPin size={14} className="text-(--color-content-tertiary)" />
|
||||
<span className="text-[13px] text-(--color-content-secondary)">
|
||||
{formatDistance(profile.distance_km)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(profile.height_cm || profile.weight_kg) && (
|
||||
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
|
||||
<div className="flex gap-4 text-[14px]">
|
||||
{profile.height_cm && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Ruler size={14} className="text-(--color-brand-500)" />
|
||||
{profile.height_cm} سانتیمتر
|
||||
</span>
|
||||
)}
|
||||
{profile.weight_kg && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Weight size={14} className="text-(--color-brand-500)" />
|
||||
{profile.weight_kg} کیلوگرم
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{(profile.education || profile.field_of_study) && (
|
||||
<SectionCard icon={<GraduationCap size={15} />} title="تحصیلات">
|
||||
<p className="text-[14px] text-(--color-content-primary)">
|
||||
{profile.education} {profile.field_of_study ? `· ${profile.field_of_study}` : ""}
|
||||
</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!!profile.occupations?.length && (
|
||||
<SectionCard icon={<Briefcase size={15} />} title="فعالیتها">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.occupations.map((t) => (
|
||||
<Tag key={t} label={t} color="sky" />
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!!profile.important_in_relationship?.length && (
|
||||
<SectionCard icon={<Heart size={15} />} title="توی رابطه براش مهمه">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.important_in_relationship.map((t) => (
|
||||
<Tag key={t} label={t} color="pink" />
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!!profile.interests?.length && (
|
||||
<SectionCard icon={<Sparkles size={15} />} title="علاقهمندیها">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.interests.map((t) => (
|
||||
<Tag key={t} label={t} color="yellow" />
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{profile.looking_for && (
|
||||
<SectionCard icon={<Search size={15} />} title="دنبال چی میگرده">
|
||||
<p className="text-[14px] text-(--color-content-primary)">{profile.looking_for}</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!!profile.profile_answers?.length && (
|
||||
<SectionCard icon={<Sparkles size={15} />} title="یکم از خودش">
|
||||
<div className="flex flex-col gap-3">
|
||||
{profile.profile_answers.map((a, i) => (
|
||||
<div key={i}>
|
||||
<p className="text-[12px] font-bold text-(--color-content-tertiary) mb-0.5">
|
||||
{a.question}
|
||||
</p>
|
||||
<p className="text-[14px] text-(--color-content-primary)">{a.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{profile.photos.length > 1 && (
|
||||
<div>
|
||||
<h2 className="text-[13px] font-bold text-(--color-content-secondary) mb-2 px-1">گالری</h2>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{profile.photos.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setPhotoIndex(i);
|
||||
setViewerOpen(true);
|
||||
}}
|
||||
className="aspect-square rounded-(--radius-md) overflow-hidden"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoUrl(p)} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{viewerOpen && photo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 bg-black flex flex-col"
|
||||
>
|
||||
<button
|
||||
onClick={() => setViewerOpen(false)}
|
||||
className="absolute top-[calc(env(safe-area-inset-top)+16px)] left-4 z-10 w-9 h-9 rounded-full bg-white/10 flex items-center justify-center"
|
||||
>
|
||||
<X size={20} className="text-white" />
|
||||
</button>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoUrl(photo)} alt="" className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 p-4 pb-[calc(env(safe-area-inset-bottom)+16px)]">
|
||||
{profile.photos.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
className={`h-1.5 rounded-(--radius-full) transition-all ${
|
||||
i === photoIndex ? "w-6 bg-white" : "w-1.5 bg-white/40"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronRight, Lock, Heart } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { DiscoverCandidate, getDiscoverFeed, photoUrl } from "@/lib/api";
|
||||
|
||||
// چه کسایی لایکت کردن — این قابلیت نیاز به شمارش لایکهای دریافتی داره که
|
||||
// هنوز توی بکاند پیاده نشده. فعلاً فقط یه پیشنمایش تارشده از پیشنهادهای
|
||||
// فعلی نشون داده میشه تا رابط کاربری آماده باشه.
|
||||
export default function LikesYouPage() {
|
||||
const router = useRouter();
|
||||
const [preview, setPreview] = useState<DiscoverCandidate[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getDiscoverFeed()
|
||||
.then((data) => setPreview(data.slice(0, 6)))
|
||||
.catch(() => setPreview([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<header className="flex items-center gap-3 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
|
||||
<button
|
||||
onClick={() => router.push("/home")}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[18px]">کسایی که لایکت کردن</h1>
|
||||
</header>
|
||||
|
||||
{preview === null ? (
|
||||
<FullScreenLoader />
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 px-5">
|
||||
{preview.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="relative aspect-[3/4] rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary)"
|
||||
>
|
||||
{p.photos[0] && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={photoUrl(p.photos[0])}
|
||||
alt=""
|
||||
className="w-full h-full object-cover blur-md scale-110"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||
<Lock size={20} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-4 px-8 pt-8">
|
||||
<div className="w-16 h-16 rounded-(--radius-lg) bg-(--color-brand-50) flex items-center justify-center text-(--color-brand-500)">
|
||||
<Heart size={28} fill="currentColor" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="font-[var(--font-display)] font-bold text-[17px]">
|
||||
این بخش هنوز بهطور کامل فعال نشده
|
||||
</h3>
|
||||
<p className="text-[13px] text-(--color-content-secondary) leading-6 max-w-xs">
|
||||
شمارش دقیق لایکهای دریافتی هنوز توی بکاند پیاده نشده. فعلاً میتونی توی
|
||||
صفحهی کشف، خودت پروفایلها رو ببینی و لایک کنی.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => router.push("/home")}>
|
||||
برگرد به کشف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bell, HourglassIcon, XCircle, SlidersHorizontal, Heart } from "lucide-react";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { SwipeDeck } from "@/components/discover/SwipeDeck";
|
||||
import { FiltersSheet, DiscoverFilters } from "@/components/discover/FiltersSheet";
|
||||
import { DiscoverCandidate, getDiscoverFeed, updateLocation } from "@/lib/api";
|
||||
|
||||
const POLL_MS = 20_000;
|
||||
const DEFAULT_FILTERS: DiscoverFilters = { maxDistance: 300, minAge: 18, maxAge: 45 };
|
||||
|
||||
function BrandHeader() {
|
||||
return (
|
||||
<header className="flex items-center px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
|
||||
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
|
||||
دلدل 💌
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingReview() {
|
||||
const { refreshUser } = useAuth();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
refreshUser().catch(() => {});
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [refreshUser]);
|
||||
|
||||
async function handleCheck() {
|
||||
setChecking(true);
|
||||
try {
|
||||
await refreshUser();
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<BrandHeader />
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6">
|
||||
<Badge tone="warning" className="mb-4">
|
||||
در انتظار بررسی ⏳
|
||||
</Badge>
|
||||
<EmptyState
|
||||
icon={<HourglassIcon size={32} />}
|
||||
title="پروفایلت داره بررسی میشه"
|
||||
description="یکی از ادمینهای دلدل داره اطلاعاتت رو نگاه میکنه. معمولاً زیاد طول نمیکشه؛ بهمحض تایید، خودکار میای اینجا."
|
||||
actionLabel={checking ? "در حال بررسی..." : "بررسی وضعیت"}
|
||||
onAction={handleCheck}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rejected({ reason }: { reason?: string }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<BrandHeader />
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6">
|
||||
<Badge tone="error" className="mb-4">
|
||||
نیاز به اصلاح ✋
|
||||
</Badge>
|
||||
<div className="flex flex-col items-center text-center gap-4 max-w-xs">
|
||||
<div className="w-20 h-20 rounded-(--radius-xl) bg-red-50 flex items-center justify-center text-(--color-status-error)">
|
||||
<XCircle size={32} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="font-[var(--font-display)] font-bold text-[18px] text-(--color-content-primary)">
|
||||
این بار تایید نشدی
|
||||
</h3>
|
||||
{reason && (
|
||||
<p className="text-[14px] text-(--color-content-secondary) leading-6">{reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => router.push("/profile")}>
|
||||
ویرایش پروفایل
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscoverScreen() {
|
||||
const router = useRouter();
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<DiscoverFilters>(DEFAULT_FILTERS);
|
||||
const [profiles, setProfiles] = useState<DiscoverCandidate[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const locationRequested = useRef(false);
|
||||
const isFirstFiltersRun = useRef(true);
|
||||
|
||||
const load = useCallback(async (f: DiscoverFilters) => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDiscoverFeed({
|
||||
maxDistanceKm: f.maxDistance,
|
||||
minAge: f.minAge,
|
||||
maxAge: f.maxAge,
|
||||
});
|
||||
setProfiles(data);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
setProfiles([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// فقط بار اول موقعیت مکانی رو میگیریم؛ اگه کاربر اجازه نده، بدون فاصله ادامه میدیم
|
||||
if (!locationRequested.current && typeof navigator !== "undefined" && navigator.geolocation) {
|
||||
locationRequested.current = true;
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
updateLocation(pos.coords.latitude, pos.coords.longitude)
|
||||
.catch(() => {})
|
||||
.finally(() => load(filters));
|
||||
},
|
||||
() => load(filters),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
} else {
|
||||
load(filters);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstFiltersRun.current) {
|
||||
isFirstFiltersRun.current = false;
|
||||
return;
|
||||
}
|
||||
load(filters);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters]);
|
||||
|
||||
const key = useMemo(() => JSON.stringify(filters), [filters]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<header className="flex items-center justify-between px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
|
||||
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
|
||||
دلدل 💌
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => router.push("/home/likes")}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-full bg-(--color-surface-interactive) text-(--color-brand-600)"
|
||||
>
|
||||
<Heart size={16} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFiltersOpen(true)}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
|
||||
>
|
||||
<SlidersHorizontal size={18} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
<button className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)">
|
||||
<Bell size={19} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{profiles === null ? (
|
||||
<FullScreenLoader label="داریم پیشنهاد میاریم..." />
|
||||
) : error && profiles.length === 0 ? (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
) : profiles.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<EmptyState
|
||||
icon={<SlidersHorizontal size={32} />}
|
||||
title="کسی با این فیلترها پیدا نشد"
|
||||
description="محدودهی فاصله یا سن رو بازتر کن، یا بعداً دوباره سر بزن."
|
||||
actionLabel="بازتنظیم فیلترها"
|
||||
onAction={() => setFilters(DEFAULT_FILTERS)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<SwipeDeck key={key} profiles={profiles} onReload={() => load(filters)} />
|
||||
)}
|
||||
|
||||
<FiltersSheet
|
||||
open={filtersOpen}
|
||||
initial={filters}
|
||||
onClose={() => setFiltersOpen(false)}
|
||||
onApply={setFilters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user?.approval_status === "rejected") {
|
||||
return <Rejected reason={user.rejection_reason} />;
|
||||
}
|
||||
if (user?.approval_status === "approved") {
|
||||
return <DiscoverScreen />;
|
||||
}
|
||||
return <PendingReview />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { BottomNav } from "@/components/BottomNav";
|
||||
import { stepByOrder } from "@/lib/onboarding-steps";
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { status, user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "guest") {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
if (status === "authenticated" && user && !user.is_onboarded) {
|
||||
const slug = stepByOrder(user.current_step)?.slug ?? "basic-info";
|
||||
router.replace(`/onboarding/${slug}`);
|
||||
}
|
||||
}, [status, user, router]);
|
||||
|
||||
if (status !== "authenticated" || !user?.is_onboarded) {
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-dvh">
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ChevronRight, Send, User } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import {
|
||||
MessageView,
|
||||
MatchView,
|
||||
listMatches,
|
||||
listMessages,
|
||||
sendMessage,
|
||||
photoUrl,
|
||||
} from "@/lib/api";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
|
||||
export default function ChatPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const matchId = Number(params.id);
|
||||
|
||||
const [match, setMatch] = useState<MatchView | null>(null);
|
||||
const [messages, setMessages] = useState<MessageView[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const refreshMessages = useCallback(async () => {
|
||||
try {
|
||||
const msgs = await listMessages(matchId);
|
||||
setMessages(msgs);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, [matchId]);
|
||||
|
||||
useEffect(() => {
|
||||
listMatches()
|
||||
.then((matches) => {
|
||||
const found = matches.find((m) => m.id === matchId);
|
||||
setMatch(found ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
refreshMessages();
|
||||
|
||||
pollRef.current = setInterval(refreshMessages, POLL_MS);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [matchId, refreshMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [messages?.length]);
|
||||
|
||||
async function handleSend() {
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
setSending(true);
|
||||
setDraft("");
|
||||
try {
|
||||
const msg = await sendMessage(matchId, text);
|
||||
setMessages((prev) => (prev ? [...prev, msg] : [msg]));
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !messages) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<ErrorBanner message={error} />
|
||||
<button
|
||||
onClick={() => router.push("/messages")}
|
||||
className="text-[13px] font-bold text-(--color-brand-600)"
|
||||
>
|
||||
برگرد به پیامها
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!messages) return <FullScreenLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<header className="flex items-center gap-3 px-4 pt-[calc(env(safe-area-inset-top)+12px)] pb-3 border-b border-(--color-border-subtle) sticky top-0 bg-(--color-surface-default) z-10">
|
||||
<button
|
||||
onClick={() => router.push("/messages")}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) shrink-0"
|
||||
>
|
||||
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => match && router.push(`/home/${match.user_id}`)}
|
||||
className="flex items-center gap-2.5 flex-1 min-w-0"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full overflow-hidden bg-(--color-bg-tertiary) flex items-center justify-center shrink-0">
|
||||
{match?.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={photoUrl(match.photos[0])} alt={match.first_name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User size={18} className="text-(--color-content-disabled)" />
|
||||
)}
|
||||
</div>
|
||||
<p className="font-bold text-[15px] text-(--color-content-primary) truncate">
|
||||
{match?.first_name ?? "..."}
|
||||
</p>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-3 px-4 py-4 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-[13px] text-(--color-content-tertiary) text-center py-8">
|
||||
هنوز پیامی نیست؛ اولین نفر باش که سلام میده 👋
|
||||
</p>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn("flex flex-col max-w-[75%]", m.is_mine ? "self-start items-start" : "self-end items-end")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-[14px] leading-6",
|
||||
m.is_mine
|
||||
? "bg-(--color-brand-500) text-white rounded-2xl rounded-br-md"
|
||||
: "bg-(--color-bg-secondary) text-(--color-content-primary) rounded-2xl rounded-bl-md"
|
||||
)}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<ErrorBanner message={error} />
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-(--color-border-subtle) pb-[calc(env(safe-area-inset-bottom)+12px)]">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||
placeholder="پیامت رو بنویس..."
|
||||
className="flex-1 h-11 rounded-(--radius-full) bg-(--color-bg-secondary) px-4 text-[14px] focus:outline-none focus:ring-2 focus:ring-(--color-brand-200)"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!draft.trim() || sending}
|
||||
className="w-11 h-11 rounded-full bg-(--color-brand-500) text-white flex items-center justify-center disabled:opacity-40 shrink-0"
|
||||
>
|
||||
<Send size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MessageCircleHeart, User } from "lucide-react";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { MatchView, listMatches, photoUrl } from "@/lib/api";
|
||||
|
||||
export default function MessagesPage() {
|
||||
const router = useRouter();
|
||||
const [matches, setMatches] = useState<MatchView[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listMatches()
|
||||
.then(setMatches)
|
||||
.catch((err) => setError(formatApiError(err)));
|
||||
}, []);
|
||||
|
||||
if (error && !matches) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!matches) return <FullScreenLoader />;
|
||||
|
||||
const newMatches = matches.filter((m) => !m.last_message);
|
||||
const conversations = matches.filter((m) => !!m.last_message);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<header className="px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">پیامها 💬</h1>
|
||||
</header>
|
||||
|
||||
{newMatches.length > 0 && (
|
||||
<div className="pb-2">
|
||||
<h2 className="text-[13px] font-bold text-(--color-content-secondary) px-5 mb-3">
|
||||
مچهای جدید
|
||||
</h2>
|
||||
<div className="flex gap-4 px-5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{newMatches.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => router.push(`/messages/${m.id}`)}
|
||||
className="flex flex-col items-center gap-1.5 shrink-0"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full p-[2.5px] bg-gradient-to-tr from-(--color-brand-500) to-(--color-pop-yellow)">
|
||||
<div className="w-full h-full rounded-full border-2 border-(--color-surface-default) overflow-hidden bg-(--color-bg-tertiary) flex items-center justify-center">
|
||||
{m.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={photoUrl(m.photos[0])}
|
||||
alt={m.first_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User size={22} className="text-(--color-content-disabled)" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-(--color-content-primary)">
|
||||
{m.first_name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 flex-1">
|
||||
<h2 className="text-[13px] font-bold text-(--color-content-secondary) px-5 mb-2">
|
||||
گفتگوها
|
||||
</h2>
|
||||
{matches.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MessageCircleHeart size={32} />}
|
||||
title="هنوز مچی نداری"
|
||||
description="وقتی با کسی مچ بشی، میتونی از همینجا باهاش گفتگو رو شروع کنی."
|
||||
/>
|
||||
) : conversations.length === 0 ? (
|
||||
<p className="text-[13px] text-(--color-content-tertiary) text-center py-8 px-6">
|
||||
هنوز پیامی رد و بدل نشده؛ از بالا روی یکی از مچها بزن و شروع کن.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{conversations.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => router.push(`/messages/${c.id}`)}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-(--color-bg-secondary) text-right"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full overflow-hidden bg-(--color-bg-tertiary) flex items-center justify-center shrink-0">
|
||||
{c.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={photoUrl(c.photos[0])}
|
||||
alt={c.first_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User size={22} className="text-(--color-content-disabled)" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-bold text-[14px] text-(--color-content-primary)">
|
||||
{c.first_name}
|
||||
</span>
|
||||
<p className="text-[13px] truncate mt-0.5 text-(--color-content-tertiary)">
|
||||
{c.last_message}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Pencil,
|
||||
Ruler,
|
||||
Weight,
|
||||
GraduationCap,
|
||||
Briefcase,
|
||||
Heart,
|
||||
Sparkles,
|
||||
Search,
|
||||
Plus,
|
||||
X,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Tag } from "@/components/ui/Badge";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { getProfileMe, uploadPhoto, deletePhoto, photoUrl, FullProfile, Photo } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { calcAge } from "@/lib/utils";
|
||||
|
||||
function selectionTitles(profile: FullProfile, key: string): string[] {
|
||||
return profile.selections.find((s) => s.category_key === key)?.titles ?? [];
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
icon,
|
||||
title,
|
||||
editHref,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
editHref: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-(--color-content-secondary)">
|
||||
{icon}
|
||||
<span className="text-[13px] font-bold">{title}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={editHref}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_PHOTOS = 6;
|
||||
|
||||
function PhotosSection({ photos, onChange }: { photos: Photo[]; onChange: (photos: Photo[]) => void }) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const photo = await uploadPhoto(file);
|
||||
onChange([...photos, photo]);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
setError(null);
|
||||
try {
|
||||
await deletePhoto(id);
|
||||
onChange(photos.filter((p) => p.id !== id));
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] font-bold text-(--color-content-secondary)">
|
||||
عکسهای پروفایل
|
||||
</span>
|
||||
<span className="text-[11px] text-(--color-content-tertiary)">
|
||||
{photos.length}/{MAX_PHOTOS}
|
||||
</span>
|
||||
</div>
|
||||
<ErrorBanner message={error} />
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{photos.map((p) => (
|
||||
<div key={p.id} className="relative aspect-square rounded-(--radius-md) overflow-hidden group">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoUrl(p.url)} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => handleDelete(p.id)}
|
||||
className="absolute top-1 left-1 w-6 h-6 rounded-full bg-black/50 flex items-center justify-center text-white"
|
||||
aria-label="حذف عکس"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{photos.length < MAX_PHOTOS && (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="aspect-square rounded-(--radius-md) border-2 border-dashed border-(--color-border-default) flex items-center justify-center text-(--color-content-tertiary) disabled:opacity-50"
|
||||
>
|
||||
<Plus size={22} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getProfileMe()
|
||||
.then(setProfile)
|
||||
.catch((err) => setError(formatApiError(err)));
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!profile) return <FullScreenLoader />;
|
||||
|
||||
const { user } = profile;
|
||||
const age = calcAge(user.birth_date);
|
||||
const education = selectionTitles(profile, "education_level")[0];
|
||||
const fieldOfStudy = selectionTitles(profile, "field_of_study")[0];
|
||||
const occupations = selectionTitles(profile, "occupation");
|
||||
const importance = selectionTitles(profile, "important_in_relationship");
|
||||
const interests = selectionTitles(profile, "interest");
|
||||
const lookingFor = selectionTitles(profile, "looking_for")[0];
|
||||
const mainPhoto = profile.photos[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
|
||||
<div className="flex flex-col items-center gap-3 pt-2 pb-2">
|
||||
{mainPhoto ? (
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden shadow-(--shadow-cute-sm)">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoUrl(mainPhoto.url)} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar name={user.first_name || user.phone} size="lg" />
|
||||
)}
|
||||
<div className="text-center">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
|
||||
{user.first_name || "بدون نام"}
|
||||
{age ? `، ${age}` : ""}
|
||||
</h1>
|
||||
<p dir="ltr" className="text-[13px] text-(--color-content-tertiary)">
|
||||
{user.phone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.photos.length === 0 && (
|
||||
<div className="flex items-start gap-2 rounded-(--radius-sm) bg-amber-50 border-2 border-amber-100 text-amber-800 px-4 py-3 text-[12px] font-medium">
|
||||
<UserIcon size={15} className="shrink-0 mt-0.5" />
|
||||
<span>بدون عکس، توی صفحهی دوستیابی کمتر دیده میشی. همین الان یکی اضافه کن.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PhotosSection
|
||||
photos={profile.photos}
|
||||
onChange={(photos) => setProfile((p) => (p ? { ...p, photos } : p))}
|
||||
/>
|
||||
|
||||
<SectionCard icon={<Ruler size={15} />} title="قد و وزن" editHref="/onboarding/body-info">
|
||||
<div className="flex gap-4 text-[14px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Ruler size={14} className="text-(--color-brand-500)" />
|
||||
{user.height_cm ?? "—"} سانتیمتر
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Weight size={14} className="text-(--color-brand-500)" />
|
||||
{user.weight_kg ?? "—"} کیلوگرم
|
||||
</span>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<GraduationCap size={15} />} title="تحصیلات" editHref="/onboarding/education">
|
||||
<p className="text-[14px] text-(--color-content-primary)">
|
||||
{education ?? "—"} {fieldOfStudy ? `· ${fieldOfStudy}` : ""}
|
||||
</p>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<Briefcase size={15} />} title="فعالیتها" editHref="/onboarding/occupations">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{occupations.length ? (
|
||||
occupations.map((t) => <Tag key={t} label={t} color="sky" />)
|
||||
) : (
|
||||
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Heart size={15} />}
|
||||
title="توی رابطه برام مهمه"
|
||||
editHref="/onboarding/relationship-importance"
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{importance.length ? (
|
||||
importance.map((t) => <Tag key={t} label={t} color="pink" />)
|
||||
) : (
|
||||
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<Sparkles size={15} />} title="علاقهمندیها" editHref="/onboarding/interests">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{interests.length ? (
|
||||
interests.map((t) => <Tag key={t} label={t} color="yellow" />)
|
||||
) : (
|
||||
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<Search size={15} />} title="دنبال چی میگردم" editHref="/onboarding/looking-for">
|
||||
<p className="text-[14px] text-(--color-content-primary)">{lookingFor ?? "—"}</p>
|
||||
</SectionCard>
|
||||
|
||||
{profile.profile_answers.length > 0 && (
|
||||
<SectionCard icon={<Sparkles size={15} />} title="یکم از خودم" editHref="/onboarding/profile-questions">
|
||||
<div className="flex flex-col gap-3">
|
||||
{profile.profile_answers.map((a) => (
|
||||
<div key={a.id}>
|
||||
<p className="text-[12px] font-bold text-(--color-content-tertiary) mb-0.5">
|
||||
{a.question?.text}
|
||||
</p>
|
||||
<p className="text-[14px] text-(--color-content-primary)">{a.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bell, Shield, HelpCircle, LogOut, ChevronLeft } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const comingSoonItems = [
|
||||
{ icon: Bell, label: "اعلانها" },
|
||||
{ icon: Shield, label: "حریم خصوصی و امنیت" },
|
||||
{ icon: HelpCircle, label: "راهنما و پشتیبانی" },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[22px] px-1">
|
||||
تنظیمات ⚙️
|
||||
</h1>
|
||||
|
||||
<Card className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] text-(--color-content-tertiary)">شماره موبایل</p>
|
||||
<p dir="ltr" className="text-[15px] font-bold text-(--color-content-primary)">
|
||||
{user?.phone}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{comingSoonItems.map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3.5 text-right ${
|
||||
i !== comingSoonItems.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} className="text-(--color-content-secondary) shrink-0" />
|
||||
<span className="flex-1 text-[14px] text-(--color-content-primary)">
|
||||
{item.label}
|
||||
</span>
|
||||
<Badge tone="neutral" className="text-[10px]">
|
||||
بهزودی
|
||||
</Badge>
|
||||
<ChevronLeft size={16} className="text-(--color-content-tertiary)" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
isBlock
|
||||
icon={<LogOut size={17} />}
|
||||
onClick={handleLogout}
|
||||
className="!text-(--color-status-error) !border-red-200 mt-4"
|
||||
>
|
||||
خروج از حساب
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user