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
+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>
);
}