Files
chat_room/src/app/(main)/profile/page.tsx
T
mobiniqow 93f6ea2f07 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>
2026-09-15 23:25:42 +03:30

277 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}