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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user