Files
guilan-ace-frontend/src/views/Profile.tsx
Amirhossein Khalili f2b4cfce1a
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
feat(frontend): rebuild auth around mobile-first flow
2026-05-21 10:28:03 +03:30

989 lines
42 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 type * as Types from "@/lib/types";
import type { ComponentType, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Navigate, Link, useNavigate } from "@/lib/router";
import { Helmet } from "@/lib/helmet";
import { useAuth } from "@/contexts/AuthContext";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api";
import {
BadgeCheck,
Bookmark,
CalendarClock,
Camera,
CheckCircle2,
Clock3,
Heart,
Loader2,
LogOut,
Mail,
MessageSquareText,
PencilLine,
Reply,
Shield,
Trash2,
UserRound,
XCircle,
} from "lucide-react";
import {
formatJalali,
formatNumberPersian,
resolveErrorMessage,
toPersianDigits,
} from "@/lib/utils";
import Markdown from "@/components/Markdown";
import { useQuery } from "@tanstack/react-query";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type ActivityCardProps = {
icon: ComponentType<{ className?: string }>;
title: string;
count: number | string;
description: string;
};
function ActivityPlaceholderCard({
icon: Icon,
title,
count,
description,
}: ActivityCardProps) {
return (
<Card className="h-full rounded-2xl border border-border/70 bg-background/75 shadow-sm">
<CardContent className="flex h-full flex-col gap-4 p-5 text-right">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-muted/40 p-3">
<Icon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="mt-auto flex items-center justify-between">
<Badge variant="secondary" className="rounded-full px-3 py-1">
بهزودی
</Badge>
<p className="text-2xl font-semibold">{count}</p>
</div>
</CardContent>
</Card>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b border-border/50 py-3 text-sm last:border-b-0">
<div className="text-right">{value ?? "—"}</div>
<div className="shrink-0 text-muted-foreground">{label}</div>
</div>
);
}
export default function Profile() {
const { user, isAuthenticated, loading } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const {
data: myRegs,
isLoading: regsLoading,
isError: regsError,
} = useQuery({
queryKey: ["my-registrations"],
queryFn: () => api.getMyRegistrations(),
enabled: isAuthenticated,
});
const { data: majors, isLoading: majorsLoading } = useQuery({
queryKey: ["majors"],
queryFn: () => api.getMajors(),
staleTime: 7 * 24 * 60 * 60 * 1000,
});
const { data: universities, isLoading: universitiesLoading } = useQuery({
queryKey: ["universities"],
queryFn: () => api.getUniversities(),
staleTime: 7 * 24 * 60 * 60 * 1000,
});
const [me, setMe] = useState<Types.UserProfileSchema | null>(user ?? null);
const [fetching, setFetching] = useState(false);
const [editing, setEditing] = useState(false);
const [uploading, setUploading] = useState(false);
const [formData, setFormData] = useState<Types.UserUpdateSchema>({
first_name: "",
last_name: "",
bio: "",
year_of_study: null,
major: null,
university: null,
student_id: "",
});
const isAdminUser = Boolean(me?.is_staff || me?.is_superuser);
const confirmedRegistrations = useMemo(
() => myRegs?.filter((reg) => reg.status === "confirmed" || reg.status === "attended") ?? [],
[myRegs],
);
const pendingRegistrations = useMemo(
() => myRegs?.filter((reg) => reg.status === "pending") ?? [],
[myRegs],
);
const canceledRegistrations = useMemo(
() => myRegs?.filter((reg) => reg.status === "cancelled") ?? [],
[myRegs],
);
const siteUrl = "https://east-guilan-ce.ir";
const siteName = "انجمن علمی کامپیوتر شرق دانشگاه گیلان";
const canonicalUrl = `${siteUrl}/profile`;
const toAbsoluteSiteUrl = useCallback((url?: string | null) => {
if (!url) return undefined;
if (url.startsWith("http")) return url;
const normalizedSite = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
const normalizedPath = url.startsWith("/") ? url.slice(1) : url;
return `${normalizedSite}/${normalizedPath}`;
}, [siteUrl]);
const majorLabel = useMemo(() => {
if (!me?.major) return "—";
const found = majors?.find((item) => item.code === me.major || item.label === me.major);
return found?.label ?? me.major;
}, [majors, me?.major]);
const universityLabel = useMemo(() => {
if (!me?.university) return "—";
const found = universities?.find(
(item) => item.code === me.university || item.label === me.university,
);
return found?.label ?? me.university;
}, [me?.university, universities]);
const statusLabels: Record<Types.MyEventRegistrationSchema["status"], string> = {
confirmed: "تأیید شده",
cancelled: "لغو شده",
pending: "در انتظار",
attended: "حضور یافت",
};
const { pageTitle, pageDescription, ogImage } = useMemo(() => {
const nameParts = [me?.first_name, me?.last_name].filter(Boolean) as string[];
const displayName = nameParts.join(" ").trim();
const identifier = displayName || me?.username || me?.email || "عضو";
return {
pageTitle: `پروفایل ${identifier} | ${siteName}`,
pageDescription: `داشبورد حساب ${identifier} در انجمن علمی کامپیوتر شرق گیلان؛ مدیریت اطلاعات شخصی، وضعیت حساب و فعالیت‌های رویدادی و بلاگ.`,
ogImage: toAbsoluteSiteUrl(me?.profile_picture_preview_url || me?.profile_picture) ?? `${siteUrl}/favicon.ico`,
};
}, [
me?.email,
me?.first_name,
me?.last_name,
me?.profile_picture,
me?.profile_picture_preview_url,
me?.username,
siteName,
siteUrl,
toAbsoluteSiteUrl,
]);
const avatarFallback = useMemo(
() =>
(me?.first_name?.[0] || me?.last_name?.[0] || me?.username?.[0] || me?.email?.[0] || "?").toUpperCase(),
[me?.email, me?.first_name, me?.last_name, me?.username],
);
const syncProfileToForm = useCallback((profile: Types.UserProfileSchema) => {
setFormData({
first_name: profile.first_name ?? "",
last_name: profile.last_name ?? "",
bio: profile.bio ?? "",
year_of_study: typeof profile.year_of_study === "number" ? profile.year_of_study : null,
major: profile.major ?? null,
university: profile.university ?? null,
student_id: profile.student_id ?? "",
});
}, []);
const loadProfile = useCallback(async () => {
try {
setFetching(true);
const profile = await api.getProfile();
setMe(profile);
syncProfileToForm(profile);
} catch (error: unknown) {
toast({
title: "خطا در دریافت پروفایل",
description: resolveErrorMessage(error, "مشکلی پیش آمد"),
variant: "destructive",
});
} finally {
setFetching(false);
}
}, [syncProfileToForm, toast]);
useEffect(() => {
if (user) {
setMe(user);
syncProfileToForm(user);
}
}, [syncProfileToForm, user]);
useEffect(() => {
if (isAuthenticated) {
loadProfile();
}
}, [isAuthenticated, loadProfile]);
useEffect(() => {
if (!majors || !me?.major) return;
const found = majors.find((item) => item.code === me.major || item.label === me.major);
if (found && formData.major !== found.code) {
setFormData((prev) => ({ ...prev, major: found.code }));
}
}, [formData.major, majors, me?.major]);
useEffect(() => {
if (!universities || !me?.university) return;
const found = universities.find(
(item) => item.code === me.university || item.label === me.university,
);
if (found && formData.university !== found.code) {
setFormData((prev) => ({ ...prev, university: found.code }));
}
}, [formData.university, me?.university, universities]);
const onPickFile = () => fileInputRef.current?.click();
const onUpload = async (file: File) => {
try {
setUploading(true);
await api.uploadProfilePicture(file);
await loadProfile();
toast({ title: "تصویر پروفایل به‌روزرسانی شد", variant: "success" });
} catch (error: unknown) {
toast({
title: "خطا در آپلود تصویر",
description: resolveErrorMessage(error, "مشکلی پیش آمد"),
variant: "destructive",
});
} finally {
setUploading(false);
}
};
const onDeletePicture = async () => {
try {
await api.deleteProfilePicture();
await loadProfile();
toast({ title: "تصویر پروفایل حذف شد", variant: "success" });
} catch (error: unknown) {
toast({
title: "خطا در حذف تصویر",
description: resolveErrorMessage(error, "مشکلی پیش آمد"),
variant: "destructive",
});
}
};
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
onUpload(file);
}
event.currentTarget.value = "";
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
try {
const payload: Types.UserUpdateSchema = {
first_name: formData.first_name ?? "",
last_name: formData.last_name ?? "",
bio: formData.bio ?? "",
year_of_study:
formData.year_of_study === undefined || formData.year_of_study === null
? null
: Number(formData.year_of_study),
major: formData.major || null,
university: formData.university || null,
student_id: formData.student_id || null,
};
const updated = await api.updateProfile(payload);
setMe(updated);
syncProfileToForm(updated);
setEditing(false);
toast({ title: "پروفایل به‌روزرسانی شد", variant: "success" });
} catch (error: unknown) {
toast({
title: "خطا در ذخیره پروفایل",
description: resolveErrorMessage(error, "مشکلی پیش آمد"),
variant: "destructive",
});
}
};
const renderRegistrationRow = (registration: Types.MyEventRegistrationSchema) => {
const eventData = registration.event as Types.EventListItemSchema & { start_date?: string };
const rawDate = eventData.start_date ?? eventData.start_time;
return (
<div
key={registration.id}
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/80 px-4 py-3"
>
<Link
to={`/events/${registration.event.slug}`}
className="rounded-full border border-border/70 px-3 py-1 text-xs text-primary transition hover:bg-muted"
>
مشاهده رویداد
</Link>
<div className="min-w-0 text-right">
<p className="truncate font-medium">{registration.event.title}</p>
<div className="mt-1 flex flex-wrap items-center justify-end gap-2 text-xs text-muted-foreground">
<span>{statusLabels[registration.status] ?? registration.status}</span>
{rawDate ? <span> {formatJalali(rawDate)}</span> : null}
</div>
</div>
</div>
);
};
const renderRegistrationGroup = ({
title,
description,
items,
icon: Icon,
badgeVariant,
}: {
title: string;
description: string;
items: Types.MyEventRegistrationSchema[];
icon: React.ComponentType<{ className?: string }>;
badgeVariant: "default" | "secondary" | "destructive";
}) => (
<Card className="rounded-2xl border border-border/70 bg-card/90 shadow-sm">
<CardHeader className="flex flex-row-reverse items-start justify-between gap-3 space-y-0">
<div className="rounded-2xl border border-border/70 bg-muted/35 p-3">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="text-right">
<div className="flex items-center justify-end gap-2">
<Badge variant={badgeVariant}>{formatNumberPersian(items.length)}</Badge>
<CardTitle className="text-base">{title}</CardTitle>
</div>
<CardDescription className="mt-2">{description}</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-3">
{items.length > 0 ? (
items.map(renderRegistrationRow)
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 px-4 py-6 text-center text-sm text-muted-foreground">
موردی در این بخش ثبت نشده است.
</div>
)}
</CardContent>
</Card>
);
if (!loading && !isAuthenticated) {
return (
<>
<Helmet>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
</Helmet>
<Navigate to="/auth" replace />
</>
);
}
const helmet = (
<Helmet>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta name="robots" content="noindex, nofollow" />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="profile" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:site_name" content={siteName} />
<meta property="og:image" content={ogImage} />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<meta name="twitter:image" content={ogImage} />
</Helmet>
);
return (
<>
{helmet}
<div className="bg-background px-4 py-8 md:py-10" dir="rtl">
<div className="container mx-auto max-w-6xl space-y-6">
{(loading || fetching) && !me ? (
<div className="flex min-h-[50vh] items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>در حال بارگذاری پروفایل...</span>
</div>
) : editing ? (
<Card className="rounded-[2rem] border border-border/70 shadow-lg">
<CardHeader className="space-y-5 bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.12),transparent_45%),radial-gradient(circle_at_bottom_left,rgba(15,23,42,0.06),transparent_40%)]">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<div className="text-center md:text-right">
<CardTitle className="text-2xl">ویرایش پروفایل</CardTitle>
<CardDescription className="mt-2">
اطلاعات شخصی و دانشگاهی خود را با دقت بهروزرسانی کنید.
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={() => setEditing(false)}>
انصراف
</Button>
<Button onClick={onPickFile} variant="secondary" disabled={uploading}>
<Camera className="ml-2 h-4 w-4" />
تغییر تصویر
</Button>
</div>
</div>
<div className="flex flex-col items-center gap-3 md:flex-row md:justify-end">
<div className="text-center md:text-right">
<p className="font-medium">{me?.username}</p>
<p className="text-sm text-muted-foreground">{me?.email}</p>
</div>
<div className="relative">
<Avatar className="h-24 w-24 border border-border/70 shadow-sm">
<AvatarImage
src={me?.profile_picture_preview_url || me?.profile_picture || undefined}
alt={me?.username || "profile"}
/>
<AvatarFallback className="text-xl">{avatarFallback}</AvatarFallback>
</Avatar>
{uploading ? (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/35">
<Loader2 className="h-5 w-5 animate-spin text-white" />
</div>
) : null}
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFileChange}
/>
</CardHeader>
<CardContent className="p-6">
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-5 md:grid-cols-2">
<div>
<Label htmlFor="first_name" className="mb-2 block text-right">
نام
</Label>
<Input
id="first_name"
value={formData.first_name ?? ""}
onChange={(event) =>
setFormData((prev) => ({ ...prev, first_name: event.target.value }))
}
/>
</div>
<div>
<Label htmlFor="last_name" className="mb-2 block text-right">
نام خانوادگی
</Label>
<Input
id="last_name"
value={formData.last_name ?? ""}
onChange={(event) =>
setFormData((prev) => ({ ...prev, last_name: event.target.value }))
}
/>
</div>
<div>
<Label htmlFor="year_of_study" className="mb-2 block text-right">
سال ورود
</Label>
<Input
id="year_of_study"
type="number"
inputMode="numeric"
value={formData.year_of_study ?? ""}
onChange={(event) =>
setFormData((prev) => ({
...prev,
year_of_study: event.target.value === "" ? null : Number(event.target.value),
}))
}
/>
</div>
<div>
<Label htmlFor="student_id" className="mb-2 block text-right">
شماره دانشجویی
</Label>
<Input
id="student_id"
value={formData.student_id ?? ""}
onChange={(event) =>
setFormData((prev) => ({ ...prev, student_id: event.target.value }))
}
/>
</div>
<div>
<Label htmlFor="university" className="mb-2 block text-right">
دانشگاه
</Label>
{universitiesLoading ? (
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
) : (
<Select
value={formData.university ?? ""}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, university: value || null }))
}
>
<SelectTrigger id="university" className="justify-between">
<SelectValue placeholder="انتخاب دانشگاه" />
</SelectTrigger>
<SelectContent dir="rtl" className="max-h-64">
{universities?.map((item) => (
<SelectItem key={item.code} value={item.code}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div>
<Label htmlFor="major" className="mb-2 block text-right">
رشته
</Label>
{majorsLoading ? (
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
) : (
<Select
value={formData.major ?? ""}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, major: value || null }))
}
>
<SelectTrigger id="major" className="justify-between">
<SelectValue placeholder="انتخاب رشته" />
</SelectTrigger>
<SelectContent dir="rtl" className="max-h-64">
{majors?.map((item) => (
<SelectItem key={item.code} value={item.code}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="md:col-span-2">
<Label htmlFor="bio" className="mb-2 block text-right">
بیو
</Label>
<Textarea
id="bio"
rows={8}
className="resize-y"
value={formData.bio ?? ""}
onChange={(event) =>
setFormData((prev) => ({ ...prev, bio: event.target.value }))
}
/>
</div>
<div className="md:col-span-2 flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
انصراف
</Button>
<Button type="submit">ذخیره تغییرات</Button>
</div>
</form>
</CardContent>
</Card>
) : (
<>
<Card className="overflow-hidden rounded-[2rem] border border-border/70 shadow-lg">
<CardContent className="p-0">
<div className="relative overflow-hidden bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.16),transparent_38%),radial-gradient(circle_at_bottom_left,rgba(15,23,42,0.08),transparent_42%)] px-6 py-7">
<div className="flex flex-col items-start justify-between gap-8 lg:flex-row-reverse">
<div className="flex flex-1 flex-col items-start gap-5 text-right lg:items-end">
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Badge variant={me?.is_mobile_verified ? "default" : "secondary"}>
{me?.is_mobile_verified ? "موبایل تأیید شده" : "نیازمند تأیید موبایل"}
</Badge>
{isAdminUser ? <Badge variant="outline">دسترسی مدیریتی</Badge> : null}
<Badge variant="secondary">
عضویت از {formatJalali(me?.date_joined, false)}
</Badge>
</div>
<div>
<h1 className="text-3xl font-semibold">
{me?.first_name || "عضو"} {me?.last_name || ""}
</h1>
<p className="mt-2 text-base text-muted-foreground">{me?.email}</p>
</div>
<div className="max-w-2xl text-sm leading-7 text-muted-foreground">
{me?.bio ? (
<Markdown content={me.bio} justify />
) : (
<p>
اینجا مرکز مدیریت حساب شماست؛ از ثبتنامهای رویدادی تا بخشهایی که بعداً
برای فعالیتهای بلاگ تکمیل خواهند شد.
</p>
)}
</div>
<div className="flex flex-wrap justify-end gap-3">
<Button onClick={() => setEditing(true)}>
<PencilLine className="ml-2 h-4 w-4" />
ویرایش پروفایل
</Button>
<Button variant="secondary" onClick={onPickFile} disabled={uploading}>
<Camera className="ml-2 h-4 w-4" />
تغییر تصویر
</Button>
{me?.profile_picture ? (
<Button variant="outline" onClick={onDeletePicture}>
<Trash2 className="ml-2 h-4 w-4" />
حذف تصویر
</Button>
) : null}
</div>
</div>
<div className="flex w-full max-w-sm flex-col items-center gap-4 rounded-[1.75rem] border border-border/70 bg-background/80 p-5 backdrop-blur-sm">
<div className="relative">
<Avatar className="h-28 w-28 border border-border/70 shadow-sm">
<AvatarImage
src={me?.profile_picture_preview_url || me?.profile_picture || undefined}
alt={me?.username || "profile"}
/>
<AvatarFallback className="text-2xl">{avatarFallback}</AvatarFallback>
</Avatar>
{uploading ? (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/35">
<Loader2 className="h-5 w-5 animate-spin text-white" />
</div>
) : null}
</div>
<div className="text-center">
<p className="font-semibold">{me?.username}</p>
<p className="mt-1 text-sm text-muted-foreground">
{me?.student_id ? `شماره دانشجویی ${toPersianDigits(me.student_id)}` : "حساب کاربری فعال"}
</p>
</div>
<div className="grid w-full grid-cols-2 gap-3">
<Card className="rounded-2xl border border-border/70 bg-muted/25 shadow-none">
<CardContent className="p-4 text-center">
<p className="text-xl font-semibold">
{formatNumberPersian(confirmedRegistrations.length)}
</p>
<p className="mt-1 text-xs text-muted-foreground">رویداد تأیید شده</p>
</CardContent>
</Card>
<Card className="rounded-2xl border border-border/70 bg-muted/25 shadow-none">
<CardContent className="p-4 text-center">
<p className="text-xl font-semibold">
{formatNumberPersian(pendingRegistrations.length)}
</p>
<p className="mt-1 text-xs text-muted-foreground">در انتظار</p>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFileChange}
/>
<section className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<Card className="rounded-[1.75rem] border border-border/70 shadow-sm">
<CardHeader className="flex flex-row-reverse items-start justify-between gap-4 space-y-0">
<div className="rounded-2xl border border-border/70 bg-muted/35 p-3">
<UserRound className="h-5 w-5 text-primary" />
</div>
<div className="text-right">
<CardTitle>اطلاعات شخصی و حساب</CardTitle>
<CardDescription className="mt-2">
تفکیک روشن برای اطلاعات فردی، دانشگاهی و وضعیت دسترسی حساب.
</CardDescription>
</div>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<Card className="rounded-2xl border border-border/70 bg-background/70 shadow-none">
<CardHeader className="pb-3 text-right">
<CardTitle className="text-base">جزئیات فردی</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<InfoRow label="نام" value={me?.first_name || "—"} />
<InfoRow label="نام خانوادگی" value={me?.last_name || "—"} />
<InfoRow label="دانشگاه" value={universityLabel} />
<InfoRow label="رشته" value={majorLabel} />
<InfoRow
label="سال ورود"
value={
typeof me?.year_of_study === "number"
? toPersianDigits(String(me.year_of_study))
: "—"
}
/>
<InfoRow
label="شماره دانشجویی"
value={me?.student_id ? toPersianDigits(me.student_id) : "—"}
/>
</CardContent>
</Card>
<Card className="rounded-2xl border border-border/70 bg-background/70 shadow-none">
<CardHeader className="pb-3 text-right">
<CardTitle className="text-base">وضعیت حساب</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<InfoRow label="موبایل" value={me?.mobile || "—"} />
<InfoRow label="ایمیل" value={me?.email || "—"} />
<InfoRow label="نام کاربری" value={me?.username || "—"} />
<InfoRow
label="تاریخ عضویت"
value={me?.date_joined ? formatJalali(me.date_joined, false) : "—"}
/>
<InfoRow
label="تأیید موبایل"
value={
<span className="inline-flex items-center gap-2">
{me?.is_mobile_verified ? (
<BadgeCheck className="h-4 w-4 text-emerald-600" />
) : (
<Clock3 className="h-4 w-4 text-amber-600" />
)}
{me?.is_mobile_verified ? "تأیید شده" : "در انتظار"}
</span>
}
/>
<InfoRow
label="اتصال گوگل"
value={me?.has_google_link ? "متصل" : "متصل نیست"}
/>
<InfoRow
label="سطح دسترسی"
value={isAdminUser ? "مدیریتی" : "کاربر عادی"}
/>
</CardContent>
</Card>
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-border/70 shadow-sm">
<CardHeader className="text-right">
<CardTitle>اقدامهای سریع</CardTitle>
<CardDescription>
میانبرهایی که از این پس جای خروج و مدیریت پنل را میگیرند.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<Button className="justify-between rounded-2xl py-6" onClick={() => setEditing(true)}>
<span>ویرایش اطلاعات و بیو</span>
<PencilLine className="h-4 w-4" />
</Button>
<Button variant="secondary" className="justify-between rounded-2xl py-6" asChild>
<Link to="/reset-password">
<span>بازیابی یا تغییر رمز با موبایل</span>
<Mail className="h-4 w-4" />
</Link>
</Button>
{isAdminUser ? (
<Button variant="outline" className="justify-between rounded-2xl py-6" asChild>
<Link to="/admin">
<span>ورود به داشبورد مدیریت</span>
<Shield className="h-4 w-4" />
</Link>
</Button>
) : null}
<Button
variant="outline"
className="justify-between rounded-2xl border-destructive/40 py-6 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => navigate("/logout")}
>
<span>خروج از حساب</span>
<LogOut className="h-4 w-4" />
</Button>
</CardContent>
</Card>
</section>
<section className="grid gap-6 lg:grid-cols-3">
<Card className="rounded-[1.75rem] border border-border/70 shadow-sm">
<CardContent className="flex items-center justify-between gap-3 p-5">
<div className="rounded-2xl border border-border/70 bg-emerald-500/10 p-3">
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
</div>
<div className="text-right">
<p className="text-2xl font-semibold">
{formatNumberPersian(confirmedRegistrations.length)}
</p>
<p className="text-sm text-muted-foreground">رویدادهای تأیید شده</p>
</div>
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-border/70 shadow-sm">
<CardContent className="flex items-center justify-between gap-3 p-5">
<div className="rounded-2xl border border-border/70 bg-amber-500/10 p-3">
<Clock3 className="h-5 w-5 text-amber-600" />
</div>
<div className="text-right">
<p className="text-2xl font-semibold">
{formatNumberPersian(pendingRegistrations.length)}
</p>
<p className="text-sm text-muted-foreground">ثبتنامهای در انتظار</p>
</div>
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-border/70 shadow-sm">
<CardContent className="flex items-center justify-between gap-3 p-5">
<div className="rounded-2xl border border-border/70 bg-rose-500/10 p-3">
<XCircle className="h-5 w-5 text-rose-600" />
</div>
<div className="text-right">
<p className="text-2xl font-semibold">
{formatNumberPersian(canceledRegistrations.length)}
</p>
<p className="text-sm text-muted-foreground">رویدادهای لغو شده</p>
</div>
</CardContent>
</Card>
</section>
<Card className="rounded-[1.75rem] border border-border/70 shadow-sm">
<CardHeader className="text-right">
<CardTitle>فعالیتهای رویدادی</CardTitle>
<CardDescription>
ثبتنامهای شما با تفکیک وضعیت، بدون نیاز به جستوجو در کل سایت.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 xl:grid-cols-3">
{renderRegistrationGroup({
title: "تأیید شده",
description: "رویدادهایی که ثبت‌نام آن‌ها تکمیل یا حضور شما ثبت شده است.",
items: confirmedRegistrations,
icon: CheckCircle2,
badgeVariant: "default",
})}
{renderRegistrationGroup({
title: "در انتظار",
description: "ثبت‌نام‌هایی که هنوز نهایی یا تأیید نشده‌اند.",
items: pendingRegistrations,
icon: CalendarClock,
badgeVariant: "secondary",
})}
{renderRegistrationGroup({
title: "لغو شده",
description: "رویدادهایی که ثبت‌نام آن‌ها لغو شده یا ادامه پیدا نکرده است.",
items: canceledRegistrations,
icon: XCircle,
badgeVariant: "destructive",
})}
</CardContent>
{regsLoading ? (
<div className="px-6 pb-6 text-sm text-muted-foreground">در حال بارگذاری فعالیتهای رویدادی...</div>
) : null}
{regsError ? (
<div className="px-6 pb-6 text-sm text-destructive">
خطا در دریافت ثبتنامهای رویدادی
</div>
) : null}
</Card>
<Card className="rounded-[1.75rem] border border-border/70 shadow-sm">
<CardHeader className="text-right">
<CardTitle>فعالیتهای بلاگ</CardTitle>
<CardDescription>
این ساختار برای نسخههای بعدی آماده شده تا بدون بازطراحی دوباره، لایکها،
پستهای ذخیرهشده، نظرها و پاسخها را نشان دهد.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<ActivityPlaceholderCard
icon={Heart}
title="پست‌های لایک‌شده"
count={0}
description="فهرست تعامل‌های مثبت شما با محتوای بلاگ در این بخش قرار می‌گیرد."
/>
<ActivityPlaceholderCard
icon={Bookmark}
title="پست‌های ذخیره‌شده"
count={0}
description="بخش ذخیره‌سازی بعداً به مدل و API واقعی وصل می‌شود، اما جای آن از حالا تثبیت شده است."
/>
<ActivityPlaceholderCard
icon={MessageSquareText}
title="نظرهای من"
count={0}
description="نظرهایی که روی پست‌ها می‌گذارید بعداً با جزئیات زمانی و لینک نمایش داده می‌شوند."
/>
<ActivityPlaceholderCard
icon={Reply}
title="پاسخ‌ها"
count={0}
description="پاسخ‌های شما به بحث‌های بلاگ در نمایی مستقل اما هماهنگ با نظرها قرار می‌گیرند."
/>
</CardContent>
</Card>
</>
)}
</div>
</div>
</>
);
}