277 lines
9.6 KiB
TypeScript
277 lines
9.6 KiB
TypeScript
|
|
"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>
|
|||
|
|
);
|
|||
|
|
}
|