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:
2026-09-15 23:24:34 +03:30
commit 93f6ea2f07
63 changed files with 11802 additions and 0 deletions
+317
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+221
View File
@@ -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 />;
}
+35
View File
@@ -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>
);
}
+167
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+276
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}