From bbf924c22d197755dcdd3323b1b21eda6b0aada1 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 29 Sep 2026 15:26:28 +0330 Subject: [PATCH] feat: improve profile navigation and responsive privacy UX --- src/app/layout.tsx | 8 +- src/app/members/[username]/page.tsx | 10 + src/app/profile/edit/page.tsx | 11 + src/app/profile/page.tsx | 9 +- src/app/users/profiles/[username]/page.tsx | 11 + src/components/AsyncSearchableCombobox.tsx | 20 +- src/components/MobileVerificationGate.tsx | 23 +- src/components/Navbar.tsx | 2 +- src/components/RouteProgress.tsx | 2 +- src/components/ui/command.tsx | 6 +- src/components/ui/context-menu.tsx | 8 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 8 +- src/components/ui/menubar.tsx | 10 +- src/components/ui/select.tsx | 9 +- src/components/ui/switch.tsx | 1 + src/components/ui/toggle.tsx | 2 +- src/index.css | 2 - src/lib/api.ts | 47 + src/lib/digits.ts | 5 + src/lib/types.ts | 62 +- src/views/Profile.tsx | 1274 +++++++++----------- src/views/ProfileEdit.tsx | 431 +++++++ src/views/PublicProfile.tsx | 143 +++ tests/digits.test.mjs | 11 + 25 files changed, 1353 insertions(+), 764 deletions(-) create mode 100644 src/app/members/[username]/page.tsx create mode 100644 src/app/profile/edit/page.tsx create mode 100644 src/app/users/profiles/[username]/page.tsx create mode 100644 src/lib/digits.ts create mode 100644 src/views/ProfileEdit.tsx create mode 100644 src/views/PublicProfile.tsx create mode 100644 tests/digits.test.mjs diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0bbbf23..fe028f5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -49,7 +49,7 @@ export default async function RootLayout({ return ( - + -
-
+
+
{children}
-
+
diff --git a/src/app/members/[username]/page.tsx b/src/app/members/[username]/page.tsx new file mode 100644 index 0000000..60e2a50 --- /dev/null +++ b/src/app/members/[username]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function LegacyPublicMemberProfilePage({ + params, +}: { + params: Promise<{ username: string }>; +}) { + const { username } = await params; + redirect(`/users/profiles/${encodeURIComponent(username)}`); +} diff --git a/src/app/profile/edit/page.tsx b/src/app/profile/edit/page.tsx new file mode 100644 index 0000000..a24a220 --- /dev/null +++ b/src/app/profile/edit/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import ProfileEdit from "@/views/ProfileEdit"; + +export const metadata: Metadata = { + title: "ویرایش پروفایل", + robots: { index: false, follow: false }, +}; + +export default function ProfileEditPage() { + return ; +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 306086a..f2f9c46 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -6,6 +6,11 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; -export default function ProfilePage() { - return ; +type SearchParams = Promise>; + +export default async function ProfilePage({ searchParams }: { searchParams: SearchParams }) { + const params = await searchParams; + const tab = Array.isArray(params.tab) ? params.tab[0] : params.tab; + + return ; } diff --git a/src/app/users/profiles/[username]/page.tsx b/src/app/users/profiles/[username]/page.tsx new file mode 100644 index 0000000..24e22ad --- /dev/null +++ b/src/app/users/profiles/[username]/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import PublicProfile from "@/views/PublicProfile"; + +export const metadata: Metadata = { + title: "پروفایل عمومی عضو", + description: "پروفایل عمومی اعضای انجمن علمی مهندسی کامپیوتر دانشگاه گیلان.", +}; + +export default function PublicMemberProfilePage() { + return ; +} diff --git a/src/components/AsyncSearchableCombobox.tsx b/src/components/AsyncSearchableCombobox.tsx index c723af2..3fa203b 100644 --- a/src/components/AsyncSearchableCombobox.tsx +++ b/src/components/AsyncSearchableCombobox.tsx @@ -21,7 +21,9 @@ export type AsyncComboboxOption = { }; type AsyncSearchableComboboxProps = { + id?: string; value?: string | null; + selectedLabel?: string | null; onChange: (value: string | null) => void; loadOptions: (params: { search: string; limit: number; offset: number }) => Promise<{ count: number; @@ -37,7 +39,9 @@ type AsyncSearchableComboboxProps = { }; export default function AsyncSearchableCombobox({ + id, value, + selectedLabel, onChange, loadOptions, placeholder = "انتخاب کنید", @@ -97,19 +101,27 @@ export default function AsyncSearchableCombobox({ - - + + {loading ? ( diff --git a/src/components/MobileVerificationGate.tsx b/src/components/MobileVerificationGate.tsx index 5342547..75f64db 100644 --- a/src/components/MobileVerificationGate.tsx +++ b/src/components/MobileVerificationGate.tsx @@ -9,13 +9,10 @@ import OtpCodeField from "@/components/OtpCodeField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { resolveErrorMessage } from "@/lib/utils"; +import { toEnglishDigits } from "@/lib/digits"; +import { resolveErrorMessage, toPersianDigits } from "@/lib/utils"; -const toEnglishDigits = (value: string) => - value - .replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0)) - .replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660)) - .replace(/[^\d]/g, ""); +const normalizeNumericInput = (value: string) => toEnglishDigits(value).replace(/[^\d]/g, ""); export default function MobileVerificationGate() { const { user, isAuthenticated, loading, refreshProfile, setUser, logout } = useAuth(); @@ -29,7 +26,7 @@ export default function MobileVerificationGate() { useEffect(() => { if (user?.mobile) { - setMobile(user.mobile); + setMobile(normalizeNumericInput(user.mobile)); setStep("verify"); } }, [user?.mobile]); @@ -45,7 +42,7 @@ export default function MobileVerificationGate() { const handleSendOtp = async () => { try { setSending(true); - const normalizedMobile = toEnglishDigits(mobile); + const normalizedMobile = normalizeNumericInput(mobile); const response = await api.sendMobileVerificationOtp({ mobile: normalizedMobile }); setMobile(normalizedMobile); setStep("verify"); @@ -70,8 +67,8 @@ export default function MobileVerificationGate() { try { setVerifying(true); const profile = await api.verifyMobile({ - mobile: toEnglishDigits(mobile), - code: toEnglishDigits(code), + mobile: normalizeNumericInput(mobile), + code: normalizeNumericInput(code), }); setUser(profile); await refreshProfile(); @@ -143,8 +140,8 @@ export default function MobileVerificationGate() { type="tel" dir="ltr" inputMode="numeric" - value={mobile} - onChange={(event) => setMobile(toEnglishDigits(event.target.value))} + value={toPersianDigits(mobile)} + onChange={(event) => setMobile(normalizeNumericInput(event.target.value))} placeholder="09xxxxxxxxx" className="h-12 rounded-2xl" disabled={sending || verifying} @@ -157,7 +154,7 @@ export default function MobileVerificationGate() { setCode(toEnglishDigits(value))} + onChange={(value) => setCode(normalizeNumericInput(value))} disabled={verifying} />
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index a6ff5c5..31613e0 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -103,7 +103,7 @@ function ProfileAvatarMenu() { ) : null} - + خروج از حساب diff --git a/src/components/RouteProgress.tsx b/src/components/RouteProgress.tsx index 109018c..9413ed6 100644 --- a/src/components/RouteProgress.tsx +++ b/src/components/RouteProgress.tsx @@ -111,7 +111,7 @@ export default function RouteProgress() { }`} >
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index ad32c15..1fbe3a1 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -40,11 +40,11 @@ const CommandInput = React.forwardRef< React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => (
- + {children} - + Close diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index ddabcfd..cd57137 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef< , React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( +>(({ className, children, position = "popper", dir = "rtl", ...props }, ref) => ( @@ -94,7 +95,7 @@ const SelectLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); SelectLabel.displayName = SelectPrimitive.Label.displayName; @@ -105,12 +106,12 @@ const SelectItem = React.forwardRef< - + diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index a76b761..48e300c 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -8,6 +8,7 @@ const Switch = React.forwardRef< React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( ( + `/api/auth/public-profile/${encodeURIComponent(username)}`, + ); + } + async updateProfile(data: Types.UserUpdateSchema) { return this.request('/api/auth/profile', { method: 'PUT', @@ -711,6 +717,18 @@ class ApiClient { return this.request('/api/blog/me/activity'); } + async getMyBlogPostsPage(section: "liked" | "saved", page: number) { + return this.request>( + `/api/blog/me/activity/${section}?page=${page}`, + ); + } + + async getMyBlogCommentsPage(page: number) { + return this.request>( + `/api/blog/me/activity/comments?page=${page}`, + ); + } + // Categories async getCategories() { return this.request('/api/blog/categories'); @@ -982,6 +1000,35 @@ class ApiClient { ); } + async getMyRegistrationsPage( + status: Types.MyEventRegistrationSchema["status"] | "all", + sortBy: "start_time" | "title", + sortOrder: "asc" | "desc", + page: number, + ) { + const params = new URLSearchParams({ + status, + sort_by: sortBy, + sort_order: sortOrder, + page: String(page), + }); + return this.request>( + `/api/events/my-registrations/paginated?${params.toString()}`, + { method: "GET" }, + ); + } + + async getMyPayments() { + return this.request("/api/payments/me", { method: "GET" }); + } + + async getMyPaymentsPage(page: number) { + return this.request>( + `/api/payments/me/paginated?page=${page}`, + { method: "GET" }, + ); + } + async getRegistrationStatus(eventId: number) { return this.request( `/api/events/${eventId}/is-registered`, diff --git a/src/lib/digits.ts b/src/lib/digits.ts new file mode 100644 index 0000000..f7d0a7c --- /dev/null +++ b/src/lib/digits.ts @@ -0,0 +1,5 @@ +export function toEnglishDigits(value: string): string { + return value + .replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0)) + .replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660)); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 5a8c110..f3198ad 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -55,7 +55,9 @@ export interface UserProfileSchema { student_id?: string | null; year_of_study?: number; university?: string; + university_code?: string | null; major?: string; + major_code?: string | null; date_joined: string; is_email_verified?: boolean; @@ -71,6 +73,31 @@ export interface UserProfileSchema { can_access_blog_admin?: boolean; can_write_blog_posts?: boolean; can_review_blog_posts?: boolean; + show_public_profile_details?: boolean; +} + +export interface PublicProfileEventSchema { + title: string; + slug: string; + start_time: string; + image_url?: string | null; + participation: "confirmed" | "attended"; +} + +export interface PublicMemberProfileSchema { + username: string; + first_name: string; + last_name: string; + full_name: string; + profile_picture?: string | null; + bio: string; + details_visible: boolean; + mobile?: string | null; + student_id?: string | null; + year_of_study?: number | null; + university?: string | null; + major?: string | null; + events: PublicProfileEventSchema[]; } export interface UserListSchema { @@ -144,6 +171,7 @@ export interface UserRegistrationSchema { } export type UserUpdateSchema = { + username?: string; email?: string | null; first_name?: string | null; last_name?: string | null; @@ -152,8 +180,31 @@ export type UserUpdateSchema = { major?: string | null; university?: string | null; student_id?: string | null; + show_public_profile_details?: boolean; }; +export interface UserPaymentSchema { + event: { + title: string; + slug: string; + image_url?: string | null; + }; + base_amount: number; + discount_amount: number; + amount: number; + status: "initiated" | "pending" | "paid" | "failed" | "canceled"; + created_at: string; + paid_at?: string | null; +} + +export interface PaginatedResponseSchema { + count: number; + page: number; + page_size: number; + total_pages: number; + results: T[]; +} + export interface UserLoginSchema { identifier: string; @@ -715,7 +766,16 @@ export interface MyEventRegistrationSchema { id: number; created_at: string; status: 'pending' | 'confirmed' | 'cancelled' | 'attended'; - event: EventListItemSchema; + event: { + id: number; + title: string; + slug: string; + start_date: string; + end_date?: string | null; + location?: string | null; + price: number; + absolute_image_url?: string | null; + }; } // Gallery Types diff --git a/src/views/Profile.tsx b/src/views/Profile.tsx index 89f488e..b9e8510 100644 --- a/src/views/Profile.tsx +++ b/src/views/Profile.tsx @@ -1,786 +1,632 @@ "use client"; -import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import Image from "next/image"; import { + Activity, + ArrowDownWideNarrow, + ArrowUpLeft, + ChevronLeft, + ChevronRight, Bookmark, - Camera, - CalendarClock, - CheckCircle2, - Clock3, + CalendarDays, + CreditCard, + Eye, + EyeOff, + ExternalLink, Heart, - Loader2, MessageSquareText, PencilLine, - Trash2, + Share2, + SlidersHorizontal, UserRound, - XCircle, } from "lucide-react"; -import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox"; -import BlogThumbnail from "@/components/BlogThumbnail"; -import ConfirmAction from "@/components/ConfirmAction"; -import Markdown from "@/components/Markdown"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Helmet } from "@/lib/helmet"; import { Link, Navigate } from "@/lib/router"; import { buildAuthPath } from "@/lib/auth-return"; -import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; import { blogPostPath } from "@/lib/blog-routes"; import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site"; import type * as Types from "@/lib/types"; -import { - formatJalali, - formatNumberPersian, - getBlogCardImageUrl, - getEventCardImageUrl, - resolveErrorMessage, - toPersianDigits, -} from "@/lib/utils"; -import { useToast } from "@/hooks/use-toast"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; +import { formatJalali, formatNumberPersian, getBlogCardImageUrl, toPersianDigits } from "@/lib/utils"; -type EventTab = "confirmed" | "pending" | "cancelled"; -type BlogTab = "liked" | "saved" | "comments"; +type ProfileSection = "overview" | "events" | "blog" | "payments"; +type EventStatus = Types.MyEventRegistrationSchema["status"] | "all"; +type EventSortBy = "start_time" | "title"; +type EventSortOrder = "asc" | "desc"; +type EventSortSelection = `${EventSortBy}:${EventSortOrder}`; +type BlogSection = "liked" | "saved" | "comments"; -function InfoRow({ label, value }: { label: string; value: ReactNode }) { +function readProfileSection(value: string | null): ProfileSection { + return value === "events" || value === "blog" || value === "payments" ? value : "overview"; +} + +const eventStatusLabels: Record = { + confirmed: "ثبت‌نام شده", + attended: "حضور یافته", + pending: "در انتظار پرداخت", + cancelled: "لغو شده", +}; + +const eventStatusBadgeClasses: Record = { + confirmed: "border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-200", + attended: "border-sky-300 bg-sky-50 text-sky-800 dark:border-sky-700 dark:bg-sky-950/50 dark:text-sky-200", + pending: "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-700 dark:bg-amber-950/50 dark:text-amber-200", + cancelled: "border-rose-300 bg-rose-50 text-rose-800 dark:border-rose-700 dark:bg-rose-950/50 dark:text-rose-200", +}; + +const eventStatusItems: Array<{ id: EventStatus; label: string }> = [ + { id: "all", label: "همه وضعیت‌ها" }, + { id: "confirmed", label: eventStatusLabels.confirmed }, + { id: "attended", label: eventStatusLabels.attended }, + { id: "pending", label: eventStatusLabels.pending }, + { id: "cancelled", label: eventStatusLabels.cancelled }, +]; + +const eventSortItems: Array<{ id: EventSortSelection; label: string; sortBy: EventSortBy; sortOrder: EventSortOrder }> = [ + { id: "start_time:desc", label: "زمان: جدیدترین", sortBy: "start_time", sortOrder: "desc" }, + { id: "start_time:asc", label: "زمان: قدیمی‌ترین", sortBy: "start_time", sortOrder: "asc" }, + { id: "title:asc", label: "نام: الف تا ی", sortBy: "title", sortOrder: "asc" }, + { id: "title:desc", label: "نام: ی تا الف", sortBy: "title", sortOrder: "desc" }, +]; + +const paymentStatusLabels: Record = { + initiated: "آغازشده", + pending: "در انتظار تأیید", + paid: "پرداخت موفق", + failed: "ناموفق", + canceled: "لغو شده", +}; + +const sectionItems: Array<{ id: ProfileSection; label: string; icon: typeof Activity }> = [ + { id: "overview", label: "نمای کلی", icon: UserRound }, + { id: "events", label: "رویدادها", icon: CalendarDays }, + { id: "blog", label: "فعالیت بلاگ", icon: Activity }, + { id: "payments", label: "پرداخت‌ها", icon: CreditCard }, +]; + +function InfoLine({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} - {value || "—"} +
+ {label} + {children || "—"}
); } -function SectionTitle({ - icon: Icon, - title, - description, -}: { - icon: React.ComponentType<{ className?: string }>; - title: string; - description?: string; -}) { +function EmptyState({ title, description }: { title: string; description: string }) { return ( -
-
-
- +
+
+ ); +} + +function PagePagination({ + page, + totalPages, + onPageChange, +}: { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + const firstPage = Math.max(1, Math.min(page - 2, totalPages - 4)); + const lastPage = Math.min(totalPages, firstPage + 4); + const pages = Array.from({ length: Math.max(0, lastPage - firstPage + 1) }, (_, index) => firstPage + index); + + return ( + + ); +} + +function ProfileEventCard({ registration }: { registration: Types.MyEventRegistrationSchema }) { + const event = registration.event; + const imageUrl = toAbsoluteUrl(event.absolute_image_url, apiBaseUrl) || "/placeholder.svg"; + + return ( +
+ + + +
+ + {event.title} + +
+ {eventStatusLabels[registration.status]} + {formatJalali(event.start_date, false)}
- {title}
- {description ? {description} : null} -
+ ); } -function TabButton({ - value, - active, - label, - count, - onClick, -}: { - value: T; - active: T; - label: string; - count: number; - onClick: (value: T) => void; -}) { +function BlogPostCard({ post }: { post: Types.PostListSchema }) { return ( - +
+ + + +
+ + {post.title} + +

{post.excerpt || "بدون خلاصه"}

+
+ {formatJalali(post.published_at || post.created_at, false)} + +
+
+
); } -export default function Profile() { +export default function Profile({ initialTab }: { initialTab?: string }) { const { user, isAuthenticated, loading } = useAuth(); - const { toast } = useToast(); - const fileInputRef = useRef(null); - const [me, setMe] = useState(user ?? null); - const [fetching, setFetching] = useState(false); - const [editing, setEditing] = useState(false); - const [uploading, setUploading] = useState(false); - const [eventTab, setEventTab] = useState("confirmed"); - const [blogTab, setBlogTab] = useState("liked"); - const [formData, setFormData] = useState({ - first_name: "", - last_name: "", - bio: "", - year_of_study: null, - major: null, - university: null, - student_id: "", - }); + const [section, setSection] = useState(() => readProfileSection(initialTab ?? null)); + const [blogSection, setBlogSection] = useState("liked"); + const [eventStatus, setEventStatus] = useState("all"); + const [eventSort, setEventSort] = useState("start_time:desc"); + const [eventPage, setEventPage] = useState(1); + const [blogPages, setBlogPages] = useState>({ liked: 1, saved: 1, comments: 1 }); + const [paymentsPage, setPaymentsPage] = useState(1); - const { data: myRegs, isLoading: regsLoading, isError: regsError } = useQuery({ + useEffect(() => { + const syncSectionFromUrl = () => { + setSection(readProfileSection(new URLSearchParams(window.location.search).get("tab"))); + }; + + window.addEventListener("popstate", syncSectionFromUrl); + return () => window.removeEventListener("popstate", syncSectionFromUrl); + }, []); + + const navigateToSection = (nextSection: ProfileSection) => { + const url = new URL(window.location.href); + if (nextSection === "overview") { + url.searchParams.delete("tab"); + } else { + url.searchParams.set("tab", nextSection); + } + + const nextUrl = `${url.pathname}${url.search}${url.hash}`; + if (nextUrl !== `${window.location.pathname}${window.location.search}${window.location.hash}`) { + window.history.pushState(window.history.state, "", nextUrl); + } + setSection(nextSection); + }; + + const profileQuery = useQuery({ + queryKey: ["profile"], + queryFn: () => api.getProfile(), + enabled: isAuthenticated, + initialData: user ?? undefined, + }); + const registrationsQuery = useQuery({ queryKey: ["my-registrations"], queryFn: () => api.getMyRegistrations(), - enabled: isAuthenticated, + enabled: isAuthenticated && section === "overview", }); - - const { data: majors } = useQuery({ - queryKey: ["majors"], - queryFn: () => api.getMajors(), - staleTime: 7 * 24 * 60 * 60 * 1000, - }); - - const { data: universities } = useQuery({ - queryKey: ["universities"], - queryFn: () => api.getUniversities(), - staleTime: 7 * 24 * 60 * 60 * 1000, - }); - - const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => { - const data = await api.getMajorsPaged(params); - return { - count: data.count, - results: data.results.map((major) => ({ value: String(major.code), label: major.label })), - }; - }, []); - - const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => { - const data = await api.getUniversitiesPaged(params); - return { - count: data.count, - results: data.results.map((university) => ({ value: String(university.code), label: university.label })), - }; - }, []); - - const { - data: blogActivity, - isLoading: blogActivityLoading, - isError: blogActivityError, - refetch: refetchBlogActivity, - } = useQuery({ + const blogQuery = useQuery({ queryKey: ["my-blog-activity"], queryFn: () => api.getMyBlogActivity(), - enabled: isAuthenticated, + enabled: isAuthenticated && section === "overview", + }); + const eventSortOption = eventSortItems.find(({ id }) => id === eventSort) ?? eventSortItems[0]; + const eventPageQuery = useQuery({ + queryKey: ["my-registrations-page", eventStatus, eventSortOption.sortBy, eventSortOption.sortOrder, eventPage], + queryFn: () => api.getMyRegistrationsPage(eventStatus, eventSortOption.sortBy, eventSortOption.sortOrder, eventPage), + enabled: isAuthenticated && section === "events", + }); + const blogPage = blogPages[blogSection]; + const blogPostsPageQuery = useQuery({ + queryKey: ["my-blog-activity-page", blogSection, blogPage], + queryFn: () => api.getMyBlogPostsPage(blogSection === "saved" ? "saved" : "liked", blogPage), + enabled: isAuthenticated && section === "blog" && blogSection !== "comments", + }); + const blogCommentsPageQuery = useQuery({ + queryKey: ["my-blog-activity-page", "comments", blogPage], + queryFn: () => api.getMyBlogCommentsPage(blogPage), + enabled: isAuthenticated && section === "blog" && blogSection === "comments", + }); + const paymentsQuery = useQuery({ + queryKey: ["my-payments-page", paymentsPage], + queryFn: () => api.getMyPaymentsPage(paymentsPage), + enabled: isAuthenticated && section === "payments", }); - 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) { - toast({ - title: "خطا در دریافت پروفایل", - description: resolveErrorMessage(error, "دوباره تلاش کنید"), - variant: "destructive", - }); - } finally { - setFetching(false); - } - }, [syncProfileToForm, toast]); - - useEffect(() => { - if (user) { - setMe(user); - syncProfileToForm(user); - } - }, [syncProfileToForm, user]); - - useEffect(() => { - if (isAuthenticated) void loadProfile(); - }, [isAuthenticated, loadProfile]); - - useEffect(() => { - if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("edit") === "1") { - setEditing(true); - } - }, []); - - 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 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 profile = profileQuery.data; const fullName = useMemo( - () => [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() || me?.username || "کاربر", - [me?.first_name, me?.last_name, me?.username], + () => [profile?.first_name, profile?.last_name].filter(Boolean).join(" ").trim() || profile?.username || "کاربر", + [profile?.first_name, profile?.last_name, profile?.username], ); - - const avatarFallback = useMemo( - () => (me?.first_name?.[0] || me?.last_name?.[0] || me?.username?.[0] || "?").toUpperCase(), - [me?.first_name, me?.last_name, me?.username], - ); - - 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 = { - confirmed: "تأیید شده", - cancelled: "لغو شده", - pending: "در انتظار", - attended: "حضور یافته", - }; - - const activeEventItems = - eventTab === "confirmed" - ? confirmedRegistrations - : eventTab === "pending" - ? pendingRegistrations - : canceledRegistrations; - - const blogCommentActivity = useMemo( - () => - [...(blogActivity?.comments ?? []), ...(blogActivity?.replies ?? [])].sort( - (left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime(), - ), - [blogActivity?.comments, blogActivity?.replies], - ); - - const blogCounts = { - liked: blogActivity?.liked_posts.length ?? 0, - saved: blogActivity?.saved_posts.length ?? 0, - comments: blogCommentActivity.length, - }; - const showBlogActivityTabs = !blogActivityLoading && !blogActivityError; - - const pageTitle = `پروفایل ${fullName} | انجمن علمی مهندسی کامپیوتر`; - const pageDescription = `داشبورد حساب ${fullName} در انجمن علمی مهندسی کامپیوتر.`; - - 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) { - toast({ - title: "خطا در آپلود تصویر", - description: resolveErrorMessage(error, "دوباره تلاش کنید"), - variant: "destructive", - }); - } finally { - setUploading(false); - } - }; - - const onDeletePicture = async () => { - try { - setUploading(true); - await api.deleteProfilePicture(); - await loadProfile(); - toast({ title: "تصویر پروفایل حذف شد", variant: "success" }); - } catch (error) { - toast({ - title: "خطا در حذف تصویر", - description: resolveErrorMessage(error, "دوباره تلاش کنید"), - variant: "destructive", - }); - } finally { - setUploading(false); - } - }; - - const onFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - event.currentTarget.value = ""; - if (file) void onUpload(file); - }; - - 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) { - toast({ - title: "خطا در ذخیره پروفایل", - description: resolveErrorMessage(error, "دوباره تلاش کنید"), - variant: "destructive", - }); - } - }; - - const renderAvatarControl = (size = "h-32 w-32") => ( - - ); - - const renderRegistrationRow = (registration: Types.MyEventRegistrationSchema) => { - const eventData = registration.event as Types.EventListItemSchema & { start_date?: string }; - const rawDate = eventData.start_date ?? eventData.start_time; - const eventImage = getEventCardImageUrl(eventData); - const eventImageUrl = eventImage === "/placeholder.svg" ? eventImage : toAbsoluteUrl(eventImage, apiBaseUrl); - - return ( -
-
- - {registration.event.title} - -
- - {registration.event.title} - -

{registration.event.description || "بدون توضیح"}

-
- {statusLabels[registration.status] ?? registration.status} - {rawDate ? • {formatJalali(rawDate)} : null} -
-
-
-
- ); - }; - - const renderPostRow = (post: Types.PostListSchema) => ( -
-
- - - -
- - {post.title} - -

{post.excerpt || "بدون خلاصه"}

-
-
-
- ); - - const renderCommentRow = (comment: Types.CommentSchema) => ( -
- - {comment.post_title} - -

{comment.content}

-

{formatJalali(comment.created_at, false)}

-
+ const eventRegistrations = registrationsQuery.data ?? []; + const confirmedEvents = eventRegistrations.filter((item) => item.status === "confirmed" || item.status === "attended"); + const blogActivity = blogQuery.data; + const comments = [...(blogActivity?.comments ?? []), ...(blogActivity?.replies ?? [])].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), ); + const activeBlogPageData = blogSection === "comments" ? blogCommentsPageQuery.data : blogPostsPageQuery.data; + const activeBlogIsLoading = blogSection === "comments" ? blogCommentsPageQuery.isLoading : blogPostsPageQuery.isLoading; + const activeBlogIsError = blogSection === "comments" ? blogCommentsPageQuery.isError : blogPostsPageQuery.isError; + const pageDescription = `داشبورد خصوصی ${fullName} در انجمن علمی مهندسی کامپیوتر.`; if (!loading && !isAuthenticated) { + return ; + } + + if ((loading || profileQuery.isLoading) && !profile) { return ( - <> - - {pageTitle} - - - - +
+ در حال بارگذاری پروفایل... +
); } + if (profileQuery.isError || !profile) { + return ( +
+ +
+ ); + } + + const profilePicture = profile.profile_picture_preview_url || profile.profile_picture_thumbnail_url || profile.profile_picture; + const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}` || profile.username.slice(0, 1); + const blogCount = (blogActivity?.liked_posts.length ?? 0) + (blogActivity?.saved_posts.length ?? 0) + comments.length; + const publicPath = `/users/profiles/${encodeURIComponent(profile.username)}`; + + const renderSection = () => { + if (section === "events") { + const registrations = eventPageQuery.data?.results ?? []; + const noRegistrations = !eventPageQuery.isLoading && !eventPageQuery.isError && registrations.length === 0; + return ( + + + رویدادهای من + همه ثبت‌نام‌های رویداد شما با امکان فیلتر و مرتب‌سازی + + +
+
+ + +
+
+ +
+ مرتب‌سازی: + {eventSortItems.map(({ id, label }) => ( + + ))} +
+
+
+
+ {eventPageQuery.isLoading ?

در حال دریافت رویدادها...

: null} + {eventPageQuery.isError ?

دریافت رویدادها با خطا روبه‌رو شد.

: null} + {noRegistrations ? ( + + ) : null} + {registrations.map((registration) => )} +
+ {eventPageQuery.data ? ( + + ) : null} +
+
+ ); + } + + if (section === "blog") { + const tabs: Array<{ id: BlogSection; label: string; icon: typeof Heart }> = [ + { id: "liked", label: "پسندیده‌ها", icon: Heart }, + { id: "saved", label: "ذخیره‌شده‌ها", icon: Bookmark }, + { id: "comments", label: "دیدگاه‌ها", icon: MessageSquareText }, + ]; + const posts = blogPostsPageQuery.data?.results ?? []; + const blogComments = blogCommentsPageQuery.data?.results ?? []; + const noBlogActivity = !activeBlogIsLoading && !activeBlogIsError + && (blogSection === "comments" ? blogComments.length === 0 : posts.length === 0); + return ( + + + فعالیت‌های بلاگ + نوشته‌هایی که پسندیده‌اید، ذخیره کرده‌اید یا درباره‌شان گفتگو کرده‌اید + + +
+ {tabs.map(({ id, label, icon: Icon }) => ( + + ))} +
+
+ {activeBlogIsLoading ?

در حال دریافت فعالیت‌ها...

: null} + {activeBlogIsError ?

دریافت فعالیت‌های بلاگ با خطا روبه‌رو شد.

: null} + {noBlogActivity ? ( + + ) : null} + {blogSection === "comments" ? blogComments.map((comment) => ( +
+ {comment.post_title} +

{comment.content}

+

{formatJalali(comment.created_at, false)}

+
+ )) : posts.map((post) => )} +
+ {activeBlogPageData ? ( + setBlogPages((current) => ({ ...current, [blogSection]: page }))} + /> + ) : null} +
+
+ ); + } + + if (section === "payments") { + return ( + + + تاریخچه پرداخت‌ها + خلاصه پرداخت‌های مرتبط با ثبت‌نام رویدادها + + +
+ {paymentsQuery.isLoading ?

در حال دریافت پرداخت‌ها...

: null} + {paymentsQuery.isError ?

دریافت پرداخت‌ها با خطا روبه‌رو شد.

: null} + {!paymentsQuery.isLoading && !paymentsQuery.isError && !paymentsQuery.data?.results.length ? ( + + ) : null} + {paymentsQuery.data?.results.map((payment, index) => ( +
+
+ {payment.event.title} +

{formatJalali(payment.paid_at || payment.created_at)}

+
+
+ + {formatNumberPersian(payment.amount)} ریال + {payment.discount_amount > 0 ? ( + تخفیف {formatNumberPersian(payment.discount_amount)} ریال + ) : null} + + + {paymentStatusLabels[payment.status]} + +
+
+ ))} +
+ {paymentsQuery.data ? ( + + ) : null} +
+
+ ); + } + + return ( +
+ + + فعالیت‌های اخیر + مروری سریع بر حضور و مشارکت شما در انجمن + + +
+
+
+
+
+
+
+
+ + +
+ رویدادهای اخیر + ثبت‌نام‌ها و حضورهای شما +
+ +
+ + {registrationsQuery.isLoading ?

در حال دریافت رویدادها...

: null} + {!registrationsQuery.isLoading && !confirmedEvents.length ? : null} + {confirmedEvents.slice(0, 3).map((registration) => )} +
+
+
+ ); + }; + return ( <> - {pageTitle} + {`پروفایل ${fullName} | انجمن علمی مهندسی کامپیوتر`} - -
-
- {(loading || fetching) && !me ? ( -
- - در حال بارگذاری پروفایل... -
- ) : editing ? ( - - -
-
- ویرایش پروفایل - اطلاعات شخصی و دانشگاهی خود را به‌روزرسانی کنید. -
- -
-
- -
{renderAvatarControl("h-28 w-28")}
-
-
- - setFormData((prev) => ({ ...prev, first_name: event.target.value }))} /> -
-
- - setFormData((prev) => ({ ...prev, last_name: event.target.value }))} /> -
-
- - setFormData((prev) => ({ ...prev, university: value }))} - loadOptions={loadUniversities} - placeholder="انتخاب دانشگاه" - /> -
-
- - setFormData((prev) => ({ ...prev, major: value }))} - loadOptions={loadMajors} - placeholder="انتخاب رشته" - /> -
-
- - setFormData((prev) => ({ ...prev, student_id: event.target.value }))} /> -
-
- - setFormData((prev) => ({ ...prev, year_of_study: event.target.value ? Number(event.target.value) : null }))} /> -
-
- -