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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 911 B |
@@ -0,0 +1,233 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
DELDEL DESIGN SYSTEM — نسخهی هماهنگشده با کیت Figma
|
||||
(Dating app - Free UI Kit): صورتی-قرمز پرانرژی روی زمینهی
|
||||
سفید/خاکستری خیلی روشن، کارتهای سفید با سایهی نرم، دکمههای
|
||||
کاملاً گرد (pill) و دکمههای دایرهای شناور برای اکشنهای سواپ.
|
||||
============================================================ */
|
||||
:root {
|
||||
/* -------- brand / accent (صورتی-قرمز اصلی کیت) -------- */
|
||||
--color-brand-50: #fff0f3;
|
||||
--color-brand-100: #ffdce3;
|
||||
--color-brand-200: #ffb8c7;
|
||||
--color-brand-300: #ff8ca3;
|
||||
--color-brand-400: #ff6683;
|
||||
--color-brand-500: #ff4d6d; /* صورتی-قرمز اصلی کیت */
|
||||
--color-brand-600: #f22c55;
|
||||
--color-brand-700: #d81b47;
|
||||
--color-brand-800: #ad1338;
|
||||
--color-brand-900: #7c0e29;
|
||||
--color-brand-onPrimary: #ffffff;
|
||||
|
||||
/* -------- accent دوم (نقطهها/برچسبها/ستارهی سوپرلایک) -------- */
|
||||
--color-pop-yellow: #ffc93c;
|
||||
--color-pop-pink: #ff8fb1;
|
||||
--color-pop-mint: #3ddc97;
|
||||
--color-pop-sky: #4fb4ff;
|
||||
--color-pop-purple: #8b5cf6;
|
||||
|
||||
/* -------- neutral (پسزمینهی سفید/خاکستری خیلی روشن مثل کیت) -------- */
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #f6f6f9;
|
||||
--color-bg-tertiary: #eeeef2;
|
||||
|
||||
--color-surface-default: #ffffff;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-surface-interactive: #fff0f3;
|
||||
|
||||
--color-content-primary: #16161d;
|
||||
--color-content-secondary: #6f6f7d;
|
||||
--color-content-tertiary: #a4a4b0;
|
||||
--color-content-disabled: #d3d3da;
|
||||
--color-content-onColor: #ffffff;
|
||||
|
||||
--color-border-subtle: #f0f0f4;
|
||||
--color-border-default: #e3e3ea;
|
||||
--color-border-interactive: var(--color-brand-500);
|
||||
|
||||
--color-status-success: #21a179;
|
||||
--color-status-warning: #d98a12;
|
||||
--color-status-error: #e0301e;
|
||||
--color-status-info: #3d8fd6;
|
||||
|
||||
/* -------- radius: کارتهای خیلی گرد + دکمههای کاملاً pill -------- */
|
||||
--radius-xs: 10px;
|
||||
--radius-sm: 14px;
|
||||
--radius-md: 20px;
|
||||
--radius-lg: 28px;
|
||||
--radius-xl: 36px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* -------- سایههای نرم صورتی (بهجای بردر مسطح) -------- */
|
||||
--shadow-cute-sm: 0 2px 8px -2px rgba(255, 77, 109, 0.18), 0 1px 2px rgba(22, 22, 29, 0.05);
|
||||
--shadow-cute-md: 0 10px 24px -8px rgba(255, 77, 109, 0.24), 0 2px 6px rgba(22, 22, 29, 0.05);
|
||||
--shadow-cute-lg: 0 20px 45px -12px rgba(255, 77, 109, 0.3), 0 4px 10px rgba(22, 22, 29, 0.07);
|
||||
--shadow-pop-yellow: 0 8px 20px -6px rgba(255, 201, 60, 0.45);
|
||||
--shadow-pop-mint: 0 8px 20px -6px rgba(61, 220, 151, 0.4);
|
||||
--shadow-float: 0 6px 16px -4px rgba(22, 22, 29, 0.18);
|
||||
|
||||
--background: var(--color-bg-primary);
|
||||
--foreground: var(--color-content-primary);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-brand-50: var(--color-brand-50);
|
||||
--color-brand-100: var(--color-brand-100);
|
||||
--color-brand-200: var(--color-brand-200);
|
||||
--color-brand-300: var(--color-brand-300);
|
||||
--color-brand-400: var(--color-brand-400);
|
||||
--color-brand-500: var(--color-brand-500);
|
||||
--color-brand-600: var(--color-brand-600);
|
||||
--color-brand-700: var(--color-brand-700);
|
||||
--color-brand-800: var(--color-brand-800);
|
||||
--color-brand-900: var(--color-brand-900);
|
||||
|
||||
--color-pop-yellow: var(--color-pop-yellow);
|
||||
--color-pop-pink: var(--color-pop-pink);
|
||||
--color-pop-mint: var(--color-pop-mint);
|
||||
--color-pop-sky: var(--color-pop-sky);
|
||||
--color-pop-purple: var(--color-pop-purple);
|
||||
|
||||
--color-bg-primary: var(--color-bg-primary);
|
||||
--color-bg-secondary: var(--color-bg-secondary);
|
||||
--color-bg-tertiary: var(--color-bg-tertiary);
|
||||
|
||||
--color-surface-default: var(--color-surface-default);
|
||||
--color-surface-elevated: var(--color-surface-elevated);
|
||||
--color-surface-interactive: var(--color-surface-interactive);
|
||||
|
||||
--color-content-primary: var(--color-content-primary);
|
||||
--color-content-secondary: var(--color-content-secondary);
|
||||
--color-content-tertiary: var(--color-content-tertiary);
|
||||
--color-content-disabled: var(--color-content-disabled);
|
||||
--color-content-onColor: var(--color-content-onColor);
|
||||
|
||||
--color-border-subtle: var(--color-border-subtle);
|
||||
--color-border-default: var(--color-border-default);
|
||||
--color-border-interactive: var(--color-border-interactive);
|
||||
|
||||
--color-status-success: var(--color-status-success);
|
||||
--color-status-warning: var(--color-status-warning);
|
||||
--color-status-error: var(--color-status-error);
|
||||
--color-status-info: var(--color-status-info);
|
||||
|
||||
--radius-xs: var(--radius-xs);
|
||||
--radius-sm: var(--radius-sm);
|
||||
--radius-md: var(--radius-md);
|
||||
--radius-lg: var(--radius-lg);
|
||||
--radius-xl: var(--radius-xl);
|
||||
--radius-full: var(--radius-full);
|
||||
|
||||
--shadow-cute-sm: var(--shadow-cute-sm);
|
||||
--shadow-cute-md: var(--shadow-cute-md);
|
||||
--shadow-cute-lg: var(--shadow-cute-lg);
|
||||
--shadow-pop-yellow: var(--shadow-pop-yellow);
|
||||
--shadow-pop-mint: var(--shadow-pop-mint);
|
||||
--shadow-float: var(--shadow-float);
|
||||
|
||||
--font-sans: "Vazirmatn Variable", "Baloo 2 Variable", system-ui, sans-serif;
|
||||
--font-display: "Baloo 2 Variable", "Vazirmatn Variable", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-brand-200);
|
||||
color: var(--color-brand-900);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-brand-200);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0.9) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.02) translateY(-2px);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heart-pop {
|
||||
0% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 109, 0.35);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 14px rgba(255, 77, 109, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "@fontsource-variable/vazirmatn";
|
||||
import "@fontsource-variable/baloo-2";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "دلدل | Deldel",
|
||||
description: "اپلیکیشن آشنایی و دوستیابی — شاد، گرد و صمیمی",
|
||||
manifest: "/manifest.webmanifest",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "دلدل",
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
apple: [{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#ff4433",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className="h-full antialiased">
|
||||
<body className="min-h-dvh flex flex-col bg-(--color-bg-primary) text-(--color-content-primary) overscroll-none">
|
||||
<AuthProvider>
|
||||
<div className="mx-auto w-full max-w-[480px] min-h-dvh flex flex-col relative bg-(--color-bg-primary) shadow-[0_0_60px_rgba(0,0,0,0.06)]">
|
||||
{children}
|
||||
</div>
|
||||
</AuthProvider>
|
||||
<ServiceWorkerRegister />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronRight, Smartphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { requestOtp } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const PHONE_REGEX = /^09\d{9}$/;
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { status } = useAuth();
|
||||
const [phone, setPhone] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
router.replace("/home");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
const isValid = PHONE_REGEX.test(phone);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await requestOtp(phone);
|
||||
router.push(`/verify?phone=${phone}`);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-dvh px-6 pt-[calc(env(safe-area-inset-top)+16px)] pb-[calc(env(safe-area-inset-bottom)+32px)]">
|
||||
<button
|
||||
onClick={() => router.push("/welcome")}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) self-start"
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-8">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[28px] text-(--color-content-primary)">
|
||||
شماره موبایلت 📱
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) leading-7 max-w-xs">
|
||||
شماره موبایل معتبرت رو وارد کن؛ یه کد ۴ رقمی برات میفرستیم تا حسابت رو تایید کنیم.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center gap-4">
|
||||
<div
|
||||
dir="ltr"
|
||||
className={`flex items-center h-14 rounded-(--radius-md) bg-(--color-bg-secondary) border-2 transition-colors focus-within:border-(--color-border-interactive) focus-within:bg-(--color-surface-default) focus-within:ring-4 focus-within:ring-(--color-brand-100) ${
|
||||
error ? "border-(--color-status-error)" : "border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 border-l-2 border-(--color-border-subtle) h-full shrink-0">
|
||||
<span className="text-[18px] leading-none">🇮🇷</span>
|
||||
<span className="text-[15px] font-bold text-(--color-content-secondary)">+98</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1 px-4">
|
||||
<Smartphone size={17} className="text-(--color-content-tertiary) shrink-0" />
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
placeholder="09xxxxxxxxx"
|
||||
className="flex-1 bg-transparent text-[16px] tracking-widest text-(--color-content-primary) placeholder:text-(--color-content-tertiary) focus:outline-none"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||
ادامه
|
||||
</Button>
|
||||
<p className="text-[11px] text-center text-(--color-content-tertiary) leading-6">
|
||||
با ادامه دادن، قوانین و حریم خصوصی دلدل رو قبول میکنی.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useRequireAuth } from "@/lib/hooks";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { OnboardingScreen } from "@/components/onboarding/OnboardingScreen";
|
||||
import { BasicInfoStep } from "@/components/onboarding/steps/BasicInfoStep";
|
||||
import { BodyInfoStep } from "@/components/onboarding/steps/BodyInfoStep";
|
||||
import { ProfileQuestionsStep } from "@/components/onboarding/steps/ProfileQuestionsStep";
|
||||
import { EducationStep } from "@/components/onboarding/steps/EducationStep";
|
||||
import { GenericOptionStep } from "@/components/onboarding/steps/GenericOptionStep";
|
||||
import {
|
||||
stepBySlug,
|
||||
nextStepSlug,
|
||||
} from "@/lib/onboarding-steps";
|
||||
import { getProfileMe, FullProfile, submitOccupations, submitRelationshipImportance, submitInterests, submitLookingFor } from "@/lib/api";
|
||||
|
||||
function slugFromKey(key: string) {
|
||||
return key.replace(/_/g, "-");
|
||||
}
|
||||
|
||||
export default function OnboardingStepPage() {
|
||||
const params = useParams<{ slug: string }>();
|
||||
const slug = params.slug;
|
||||
const router = useRouter();
|
||||
const { status } = useRequireAuth();
|
||||
const { onboardingStatus, refreshOnboardingStatus } = useAuth();
|
||||
|
||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||
const [guardChecked, setGuardChecked] = useState(false);
|
||||
|
||||
const meta = stepBySlug(slug);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
const p = await getProfileMe();
|
||||
setProfile(p);
|
||||
} catch {
|
||||
// اگه خطا خورد، فرمها بدون prefill نمایش داده میشن
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated" || !meta) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const s = onboardingStatus ?? (await refreshOnboardingStatus());
|
||||
if (cancelled) return;
|
||||
|
||||
if (!s) {
|
||||
setGuardChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOrder =
|
||||
s.steps.find((st) => st.key === s.current_step_key)?.step ?? meta.order;
|
||||
|
||||
// اگه هنوز onboarded نشده و میخواد از مرحلهی فعلی جلوتر بره، برشگردون
|
||||
if (!s.is_onboarded && meta.order > currentOrder) {
|
||||
const correctSlug = s.steps.find((st) => st.step === currentOrder);
|
||||
router.replace(`/onboarding/${correctSlug ? slugFromKey(correctSlug.key) : ""}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadProfile();
|
||||
setGuardChecked(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, slug]);
|
||||
|
||||
const goNext = useCallback(async () => {
|
||||
await refreshOnboardingStatus();
|
||||
const next = nextStepSlug(slug);
|
||||
if (next) {
|
||||
router.push(`/onboarding/${next}`);
|
||||
} else {
|
||||
router.push("/onboarding/done");
|
||||
}
|
||||
}, [slug, router, refreshOnboardingStatus]);
|
||||
|
||||
if (status !== "authenticated" || !meta || !guardChecked) {
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingScreen
|
||||
order={meta.order}
|
||||
title={meta.title}
|
||||
emoji={meta.emoji}
|
||||
onBack={meta.order === 1 ? () => router.push("/login") : undefined}
|
||||
>
|
||||
{renderStep(slug, profile, goNext)}
|
||||
</OnboardingScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep(slug: string, profile: FullProfile | null, onNext: () => void) {
|
||||
switch (slug) {
|
||||
case "basic-info":
|
||||
return <BasicInfoStep initialProfile={profile} onNext={onNext} />;
|
||||
case "body-info":
|
||||
return <BodyInfoStep initialProfile={profile} onNext={onNext} />;
|
||||
case "profile-questions":
|
||||
return <ProfileQuestionsStep initialProfile={profile} onNext={onNext} />;
|
||||
case "education":
|
||||
return <EducationStep initialProfile={profile} onNext={onNext} />;
|
||||
case "occupations":
|
||||
return (
|
||||
<GenericOptionStep
|
||||
initialProfile={profile}
|
||||
onNext={onNext}
|
||||
category="occupation"
|
||||
mode="multi"
|
||||
min={1}
|
||||
submitFn={submitOccupations}
|
||||
helperText="هرچی که به کارت میاد رو انتخاب کن"
|
||||
emptyMessage="هنوز گزینهای برای فعالیتها ثبت نشده."
|
||||
/>
|
||||
);
|
||||
case "relationship-importance":
|
||||
return (
|
||||
<GenericOptionStep
|
||||
initialProfile={profile}
|
||||
onNext={onNext}
|
||||
category="important_in_relationship"
|
||||
mode="multi"
|
||||
min={3}
|
||||
max={3}
|
||||
submitFn={submitRelationshipImportance}
|
||||
helperText="دقیقاً ۳ تا انتخاب کن"
|
||||
emptyMessage="هنوز گزینهای ثبت نشده."
|
||||
/>
|
||||
);
|
||||
case "interests":
|
||||
return (
|
||||
<GenericOptionStep
|
||||
initialProfile={profile}
|
||||
onNext={onNext}
|
||||
category="interest"
|
||||
mode="multi"
|
||||
min={5}
|
||||
max={5}
|
||||
submitFn={submitInterests}
|
||||
helperText="دقیقاً ۵ تا انتخاب کن"
|
||||
emptyMessage="هنوز گزینهای ثبت نشده."
|
||||
/>
|
||||
);
|
||||
case "looking-for":
|
||||
return (
|
||||
<GenericOptionStep
|
||||
initialProfile={profile}
|
||||
onNext={onNext}
|
||||
category="looking_for"
|
||||
mode="single"
|
||||
min={1}
|
||||
max={1}
|
||||
submitFn={submitLookingFor}
|
||||
emptyMessage="هنوز گزینهای ثبت نشده."
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PartyPopper } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useRequireAuth } from "@/lib/hooks";
|
||||
|
||||
export default function OnboardingDonePage() {
|
||||
const router = useRouter();
|
||||
const { status } = useRequireAuth();
|
||||
const { onboardingStatus, refreshOnboardingStatus, refreshUser } = useAuth();
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated") return;
|
||||
(async () => {
|
||||
const s = onboardingStatus ?? (await refreshOnboardingStatus());
|
||||
if (!s?.is_onboarded) {
|
||||
router.replace("/onboarding/basic-info");
|
||||
return;
|
||||
}
|
||||
// user توی context از قبل onboarding ممکنه هنوز is_onboarded=false داشته باشه
|
||||
// (هیچ مرحلهای توی خود فرآیند onboarding این رو رفرش نمیکنه)؛ بدون این،
|
||||
// MainLayout با state قدیمی دوباره برمیگردونه به یکی از مراحل onboarding.
|
||||
await refreshUser();
|
||||
setChecked(true);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status]);
|
||||
|
||||
if (status !== "authenticated" || !checked) return <FullScreenLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-6 px-8 min-h-dvh">
|
||||
<div className="w-24 h-24 rounded-(--radius-xl) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-lg) [animation:bounce-in_0.5s_ease-out]">
|
||||
<PartyPopper size={40} className="text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||
پروفایلت کامل شد! 🎉
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) leading-7 max-w-xs">
|
||||
پروفایلت رفت برای بررسی ادمین. بهمحض تایید، خودکار میتونی افراد جدید رو
|
||||
کشف کنی. هر وقت خواستی هم میتونی از پروفایلت هرکدوم از این اطلاعات رو
|
||||
ویرایش کنی.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" isBlock onClick={() => router.push("/home")}>
|
||||
بزن بریم 🚀
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { stepByOrder } from "@/lib/onboarding-steps";
|
||||
|
||||
export default function RootPage() {
|
||||
const router = useRouter();
|
||||
const { status, user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (status === "guest") {
|
||||
router.replace("/welcome");
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
if (user.is_onboarded) {
|
||||
router.replace("/home");
|
||||
} else {
|
||||
const slug = stepByOrder(user.current_step)?.slug ?? "basic-info";
|
||||
router.replace(`/onboarding/${slug}`);
|
||||
}
|
||||
}
|
||||
}, [status, user, router]);
|
||||
|
||||
return <FullScreenLoader label="دلدل داره باز میشه..." />;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { OtpInput } from "@/components/ui/OtpInput";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { requestOtp } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { stepByOrder } from "@/lib/onboarding-steps";
|
||||
|
||||
const RESEND_SECONDS = 60;
|
||||
|
||||
function formatCountdown(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function VerifyForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const phone = searchParams.get("phone") ?? "";
|
||||
const { login } = useAuth();
|
||||
|
||||
const [otp, setOtp] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [countdown, setCountdown] = useState(RESEND_SECONDS);
|
||||
const [resending, setResending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!phone) router.replace("/login");
|
||||
}, [phone, router]);
|
||||
|
||||
async function handleVerify(code: string) {
|
||||
if (code.length !== 4) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = await login(phone, code);
|
||||
if (user.is_onboarded) {
|
||||
router.push("/home");
|
||||
} else {
|
||||
const stepSlug = stepByOrder(user.current_step)?.slug ?? "basic-info";
|
||||
router.push(`/onboarding/${stepSlug}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(formatApiError(err, "کد وارد شده اشتباهه"));
|
||||
setOtp("");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
setResending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await requestOtp(phone);
|
||||
setCountdown(RESEND_SECONDS);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-dvh px-6 pt-[calc(env(safe-area-inset-top)+16px)] pb-[calc(env(safe-area-inset-bottom)+32px)]">
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) self-start"
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center text-center gap-2 pt-6">
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`font-[var(--font-display)] font-extrabold text-[44px] tabular-nums tracking-wide ${
|
||||
countdown > 0 ? "text-(--color-brand-500)" : "text-(--color-content-disabled)"
|
||||
}`}
|
||||
>
|
||||
{formatCountdown(countdown)}
|
||||
</span>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[20px] pt-2">
|
||||
کد تایید رو وارد کن
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary)">
|
||||
کد ۴ رقمی ارسالشده به{" "}
|
||||
<span dir="ltr" className="font-bold text-(--color-content-primary)">
|
||||
{phone}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 pt-10">
|
||||
<OtpInput value={otp} onChange={setOtp} onComplete={handleVerify} disabled={loading} />
|
||||
<ErrorBanner message={error} />
|
||||
<Button
|
||||
size="lg"
|
||||
isBlock
|
||||
isLoading={loading}
|
||||
disabled={otp.length !== 4}
|
||||
onClick={() => handleVerify(otp)}
|
||||
>
|
||||
تایید کد
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-1.5 text-[13px]">
|
||||
<span className="text-(--color-content-tertiary)">کد نیومد؟</span>
|
||||
{countdown > 0 ? (
|
||||
<span className="font-bold text-(--color-content-secondary)">صبر کن...</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={resending}
|
||||
className="font-bold text-(--color-brand-600) hover:text-(--color-brand-700) disabled:opacity-50"
|
||||
>
|
||||
دوباره بفرست
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<VerifyForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { ShieldCheck, Sparkles, Heart } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Slide {
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SLIDES: Slide[] = [
|
||||
{
|
||||
icon: <ShieldCheck size={64} className="text-white" strokeWidth={1.75} />,
|
||||
gradient: "linear-gradient(160deg, #ff8ca3 0%, #ff4d6d 55%, #d81b47 100%)",
|
||||
title: "پروفایلهای تایید شده",
|
||||
description:
|
||||
"قبل از اینکه کسی وارد بخش دوستیابی بشه، تیم دلدل پروفایلش رو بررسی میکنه؛ یعنی خبری از ربات و پروفایلهای تقلبی نیست.",
|
||||
},
|
||||
{
|
||||
icon: <Sparkles size={64} className="text-white" strokeWidth={1.75} />,
|
||||
gradient: "linear-gradient(160deg, #ffc93c 0%, #ff8ca3 55%, #ff4d6d 100%)",
|
||||
title: "مچهای واقعی",
|
||||
description:
|
||||
"بر اساس علاقهمندیها، سبک زندگی و چیزهایی که برات مهمه، با آدمهای مناسبتر آشنا میشی.",
|
||||
},
|
||||
{
|
||||
icon: <Heart size={64} className="text-white" fill="white" strokeWidth={0} />,
|
||||
gradient: "linear-gradient(160deg, #8b5cf6 0%, #ff4d6d 55%, #ff8ca3 100%)",
|
||||
title: "دوستیابی، ساده و امن",
|
||||
description:
|
||||
"یکییکی پروفایلها رو ببین، اگه خوشت اومد لایک کن، نه که نه؛ همهچیز دست خودته.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function WelcomePage() {
|
||||
const router = useRouter();
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
function onScroll() {
|
||||
if (!el) return;
|
||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActive((prev) => (prev === index ? prev : index));
|
||||
}
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
function goToSlide(index: number) {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ left: index * el.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-dvh bg-(--color-bg-primary)">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
dir="ltr"
|
||||
className="flex-1 flex overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{SLIDES.map((slide, i) => (
|
||||
<div
|
||||
key={i}
|
||||
dir="rtl"
|
||||
className="w-full shrink-0 snap-center flex flex-col items-center px-8 pt-[calc(env(safe-area-inset-top)+32px)]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.85, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="w-full aspect-square max-w-[280px] rounded-(--radius-xl) flex items-center justify-center shadow-(--shadow-cute-lg) mt-6"
|
||||
style={{ background: slide.gradient }}
|
||||
>
|
||||
{slide.icon}
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-col items-center text-center gap-3 mt-10 max-w-xs">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[26px] text-(--color-content-primary)">
|
||||
{slide.title}
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) leading-7">
|
||||
{slide.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 px-8 pb-[calc(env(safe-area-inset-bottom)+28px)] pt-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{SLIDES.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`اسلاید ${i + 1}`}
|
||||
onClick={() => goToSlide(i)}
|
||||
className={cn(
|
||||
"h-2 rounded-(--radius-full) transition-all duration-300",
|
||||
i === active
|
||||
? "w-6 bg-(--color-brand-500)"
|
||||
: "w-2 bg-(--color-bg-tertiary)"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button size="lg" isBlock onClick={() => router.push("/login")}>
|
||||
بساز حساب کاربری
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="text-[13px] text-center text-(--color-content-secondary)"
|
||||
>
|
||||
قبلاً حساب داری؟ <span className="font-bold text-(--color-brand-600)">ورود</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user