989 lines
42 KiB
TypeScript
989 lines
42 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|