feat(profile): improve activity and payment sections

This commit is contained in:
2026-10-02 13:04:27 +03:30
parent 8c70f5bf01
commit 43b1e411b2
6 changed files with 218 additions and 66 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

View File

@@ -723,15 +723,15 @@ class ApiClient {
return this.request<Types.BlogProfileActivitySchema>('/api/blog/me/activity'); return this.request<Types.BlogProfileActivitySchema>('/api/blog/me/activity');
} }
async getMyBlogPostsPage(section: "liked" | "saved", page: number) { async getMyBlogPostsPage(section: "liked" | "saved", page: number, sortOrder: "asc" | "desc") {
return this.request<Types.PaginatedResponseSchema<Types.PostListSchema>>( return this.request<Types.PaginatedResponseSchema<Types.PostListSchema>>(
`/api/blog/me/activity/${section}?page=${page}`, `/api/blog/me/activity/${section}?page=${page}&sort_order=${sortOrder}`,
); );
} }
async getMyBlogCommentsPage(page: number) { async getMyBlogCommentsPage(page: number, sortOrder: "asc" | "desc") {
return this.request<Types.PaginatedResponseSchema<Types.CommentSchema>>( return this.request<Types.PaginatedResponseSchema<Types.CommentSchema>>(
`/api/blog/me/activity/comments?page=${page}`, `/api/blog/me/activity/comments?page=${page}&sort_order=${sortOrder}`,
); );
} }
@@ -1065,9 +1065,20 @@ class ApiClient {
return this.request<Types.UserPaymentSchema[]>("/api/payments/me", { method: "GET" }); return this.request<Types.UserPaymentSchema[]>("/api/payments/me", { method: "GET" });
} }
async getMyPaymentsPage(page: number) { async getMyPaymentsPage(
status: "all" | Types.UserPaymentSchema["status"],
sortBy: "time" | "amount",
sortOrder: "asc" | "desc",
page: number,
) {
const params = new URLSearchParams({
status,
sort_by: sortBy,
sort_order: sortOrder,
page: String(page),
});
return this.request<Types.PaginatedResponseSchema<Types.UserPaymentSchema>>( return this.request<Types.PaginatedResponseSchema<Types.UserPaymentSchema>>(
`/api/payments/me/paginated?page=${page}`, `/api/payments/me/paginated?${params.toString()}`,
{ method: "GET" }, { method: "GET" },
); );
} }

View File

@@ -8,18 +8,19 @@ import {
ArrowDownWideNarrow, ArrowDownWideNarrow,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Bookmark,
CalendarDays, CalendarDays,
CheckCircle2,
CreditCard, CreditCard,
Clock3,
Eye, Eye,
EyeOff, EyeOff,
ExternalLink, ExternalLink,
Heart, Heart,
MessageSquareText,
PencilLine, PencilLine,
Share2, Share2,
SlidersHorizontal, SlidersHorizontal,
UserRound, UserRound,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -45,6 +46,11 @@ type EventSortBy = "start_time" | "title";
type EventSortOrder = "asc" | "desc"; type EventSortOrder = "asc" | "desc";
type EventSortSelection = `${EventSortBy}:${EventSortOrder}`; type EventSortSelection = `${EventSortBy}:${EventSortOrder}`;
type BlogSection = "liked" | "saved" | "comments"; type BlogSection = "liked" | "saved" | "comments";
type BlogSortOrder = "asc" | "desc";
type PaymentStatus = Types.UserPaymentSchema["status"] | "all";
type PaymentSortBy = "time" | "amount";
type PaymentSortOrder = "asc" | "desc";
type PaymentSortSelection = `${PaymentSortBy}:${PaymentSortOrder}`;
function readProfileSection(value: string | null): ProfileSection { function readProfileSection(value: string | null): ProfileSection {
return value === "events" || value === "blog" || value === "payments" ? value : "overview"; return value === "events" || value === "blog" || value === "payments" ? value : "overview";
@@ -87,6 +93,33 @@ const paymentStatusLabels: Record<Types.UserPaymentSchema["status"], string> = {
canceled: "لغو شده", canceled: "لغو شده",
}; };
const paymentStatusItems: Array<{ id: PaymentStatus; label: string }> = [
{ id: "all", label: "همه وضعیت‌ها" },
{ id: "paid", label: paymentStatusLabels.paid },
{ id: "pending", label: paymentStatusLabels.pending },
{ id: "initiated", label: paymentStatusLabels.initiated },
{ id: "failed", label: paymentStatusLabels.failed },
{ id: "canceled", label: paymentStatusLabels.canceled },
];
const paymentSortItems: Array<{ id: PaymentSortSelection; label: string; sortBy: PaymentSortBy; sortOrder: PaymentSortOrder }> = [
{ id: "time:desc", label: "زمان: جدیدترین", sortBy: "time", sortOrder: "desc" },
{ id: "time:asc", label: "زمان: قدیمی‌ترین", sortBy: "time", sortOrder: "asc" },
{ id: "amount:desc", label: "مبلغ: بیشترین", sortBy: "amount", sortOrder: "desc" },
{ id: "amount:asc", label: "مبلغ: کمترین", sortBy: "amount", sortOrder: "asc" },
];
const blogSectionItems: Array<{ id: BlogSection; label: string }> = [
{ id: "liked", label: "پسندیده‌ها" },
{ id: "saved", label: "ذخیره‌شده‌ها" },
{ id: "comments", label: "دیدگاه‌ها" },
];
const blogSortItems: Array<{ id: BlogSortOrder; label: string }> = [
{ id: "desc", label: "زمان: جدیدترین" },
{ id: "asc", label: "زمان: قدیمی‌ترین" },
];
const refundStatusLabels: Record<NonNullable<Types.UserPaymentSchema["refund_status"]>, string> = { const refundStatusLabels: Record<NonNullable<Types.UserPaymentSchema["refund_status"]>, string> = {
none: "بدون بازگشت وجه", none: "بدون بازگشت وجه",
pending: "بازگشت وجه در حال پیگیری", pending: "بازگشت وجه در حال پیگیری",
@@ -101,21 +134,30 @@ const sectionItems: Array<{ id: ProfileSection; label: string; icon: typeof Acti
{ id: "payments", label: "پرداخت‌ها", icon: CreditCard }, { id: "payments", label: "پرداخت‌ها", icon: CreditCard },
]; ];
function InfoLine({ label, children }: { label: string; children: React.ReactNode }) { function InfoLine({ label, children, stacked = false }: { label: string; children: React.ReactNode; stacked?: boolean }) {
return ( return (
<div className="flex min-w-0 items-start justify-between gap-3 border-b border-border/60 py-3 last:border-0 sm:gap-4"> <div className={`min-w-0 border-b border-border/60 py-3 last:border-0 ${stacked ? "space-y-2" : "flex items-start justify-between gap-3 sm:gap-4"}`}>
<span className="shrink-0 text-sm text-muted-foreground">{label}</span> <span className="shrink-0 text-sm text-muted-foreground">{label}</span>
<span className="min-w-0 max-w-[68%] break-words text-left text-sm font-medium text-foreground [overflow-wrap:anywhere]">{children || "—"}</span> <span className={`${stacked ? "block w-full" : "max-w-[68%]"} min-w-0 break-words text-left text-sm font-medium text-foreground [overflow-wrap:anywhere]`}>{children || "—"}</span>
</div> </div>
); );
} }
function EmptyState({ title, description }: { title: string; description: string }) { function EmptyState({ title, description, imageSrc }: { title: string; description: string; imageSrc?: string }) {
return ( return (
<div className="rounded-2xl border border-dashed border-border bg-muted/20 px-5 py-10 text-center"> <div role="status" className="flex min-h-64 flex-col items-center justify-center rounded-2xl border border-dashed border-border bg-muted/10 px-5 py-8 text-center">
<Activity className="mx-auto h-6 w-6 text-muted-foreground" aria-hidden="true" /> {imageSrc ? (
<p className="mt-3 font-semibold">{title}</p> <Image
<p className="mt-1 text-sm text-muted-foreground">{description}</p> src={imageSrc}
alt=""
aria-hidden="true"
width={1536}
height={1024}
className="mb-2 h-auto w-full max-w-[30rem] object-contain"
/>
) : <Activity className="h-6 w-6 text-muted-foreground" aria-hidden="true" />}
<p className="font-semibold">{title}</p>
<p className="mt-1 max-w-lg text-sm leading-6 text-muted-foreground">{description}</p>
</div> </div>
); );
} }
@@ -231,10 +273,13 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
const { user, isAuthenticated, loading } = useAuth(); const { user, isAuthenticated, loading } = useAuth();
const [section, setSection] = useState<ProfileSection>(() => readProfileSection(initialTab ?? null)); const [section, setSection] = useState<ProfileSection>(() => readProfileSection(initialTab ?? null));
const [blogSection, setBlogSection] = useState<BlogSection>("liked"); const [blogSection, setBlogSection] = useState<BlogSection>("liked");
const [blogSortOrder, setBlogSortOrder] = useState<BlogSortOrder>("desc");
const [eventStatus, setEventStatus] = useState<EventStatus>("all"); const [eventStatus, setEventStatus] = useState<EventStatus>("all");
const [eventSort, setEventSort] = useState<EventSortSelection>("start_time:desc"); const [eventSort, setEventSort] = useState<EventSortSelection>("start_time:desc");
const [eventPage, setEventPage] = useState(1); const [eventPage, setEventPage] = useState(1);
const [blogPages, setBlogPages] = useState<Record<BlogSection, number>>({ liked: 1, saved: 1, comments: 1 }); const [blogPages, setBlogPages] = useState<Record<BlogSection, number>>({ liked: 1, saved: 1, comments: 1 });
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus>("all");
const [paymentSort, setPaymentSort] = useState<PaymentSortSelection>("time:desc");
const [paymentsPage, setPaymentsPage] = useState(1); const [paymentsPage, setPaymentsPage] = useState(1);
useEffect(() => { useEffect(() => {
@@ -280,18 +325,19 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
}); });
const blogPage = blogPages[blogSection]; const blogPage = blogPages[blogSection];
const blogPostsPageQuery = useQuery({ const blogPostsPageQuery = useQuery({
queryKey: ["my-blog-activity-page", blogSection, blogPage], queryKey: ["my-blog-activity-page", blogSection, blogSortOrder, blogPage],
queryFn: () => api.getMyBlogPostsPage(blogSection === "saved" ? "saved" : "liked", blogPage), queryFn: () => api.getMyBlogPostsPage(blogSection === "saved" ? "saved" : "liked", blogPage, blogSortOrder),
enabled: isAuthenticated && section === "blog" && blogSection !== "comments", enabled: isAuthenticated && section === "blog" && blogSection !== "comments",
}); });
const blogCommentsPageQuery = useQuery({ const blogCommentsPageQuery = useQuery({
queryKey: ["my-blog-activity-page", "comments", blogPage], queryKey: ["my-blog-activity-page", "comments", blogSortOrder, blogPage],
queryFn: () => api.getMyBlogCommentsPage(blogPage), queryFn: () => api.getMyBlogCommentsPage(blogPage, blogSortOrder),
enabled: isAuthenticated && section === "blog" && blogSection === "comments", enabled: isAuthenticated && section === "blog" && blogSection === "comments",
}); });
const paymentSortOption = paymentSortItems.find(({ id }) => id === paymentSort) ?? paymentSortItems[0];
const paymentsQuery = useQuery({ const paymentsQuery = useQuery({
queryKey: ["my-payments-page", paymentsPage], queryKey: ["my-payments-page", paymentStatus, paymentSortOption.sortBy, paymentSortOption.sortOrder, paymentsPage],
queryFn: () => api.getMyPaymentsPage(paymentsPage), queryFn: () => api.getMyPaymentsPage(paymentStatus, paymentSortOption.sortBy, paymentSortOption.sortOrder, paymentsPage),
enabled: isAuthenticated && section === "payments", enabled: isAuthenticated && section === "payments",
}); });
@@ -390,8 +436,9 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
{eventPageQuery.isError ? <p role="alert" className="py-8 text-center text-sm text-destructive">دریافت رویدادها با خطا روبه‌رو شد.</p> : null} {eventPageQuery.isError ? <p role="alert" className="py-8 text-center text-sm text-destructive">دریافت رویدادها با خطا روبه‌رو شد.</p> : null}
{noRegistrations ? ( {noRegistrations ? (
<EmptyState <EmptyState
title={eventStatus === "all" ? "هنوز ثبت‌نامی ندارید" : "رویدادی با این وضعیت نیست"} title={eventStatus === "all" ? "هنوز ثبت‌نامی ندارید" : "رویدادی با این وضعیت وجود ندارد."}
description={eventStatus === "all" ? "پس از ثبت‌نام در رویدادها، آن‌ها اینجا نمایش داده می‌شوند." : "با انتخاب وضعیت دیگر، ثبت‌نام‌های دیگر را ببینید."} description={eventStatus === "all" ? "پس از ثبت‌نام در رویدادها، آن‌ها اینجا نمایش داده می‌شوند." : "با انتخاب وضعیت دیگر، ثبت‌نام‌های دیگر را ببینید."}
imageSrc="/images/events-empty-state-quiera.png"
/> />
) : null} ) : null}
{registrations.map((registration) => <ProfileEventCard key={registration.id} registration={registration} />)} {registrations.map((registration) => <ProfileEventCard key={registration.id} registration={registration} />)}
@@ -409,11 +456,6 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
} }
if (section === "blog") { 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 posts = blogPostsPageQuery.data?.results ?? [];
const blogComments = blogCommentsPageQuery.data?.results ?? []; const blogComments = blogCommentsPageQuery.data?.results ?? [];
const noBlogActivity = !activeBlogIsLoading && !activeBlogIsError const noBlogActivity = !activeBlogIsLoading && !activeBlogIsError
@@ -425,28 +467,70 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
<CardDescription>نوشته‌هایی که پسندیده‌اید، ذخیره کرده‌اید یا درباره‌شان گفتگو کرده‌اید</CardDescription> <CardDescription>نوشته‌هایی که پسندیده‌اید، ذخیره کرده‌اید یا درباره‌شان گفتگو کرده‌اید</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:gap-4 lg:space-y-0"> <CardContent className="space-y-4 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:gap-4 lg:space-y-0">
<div className="flex min-w-0 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:flex-wrap sm:overflow-visible sm:whitespace-normal" role="tablist" aria-label="بخش‌های فعالیت بلاگ"> <div className="w-full min-w-0 rounded-xl border border-border/70 bg-muted/20 p-2">
{tabs.map(({ id, label, icon: Icon }) => ( <div dir="rtl" className="grid min-w-0 grid-cols-1 gap-2 min-[520px]:grid-cols-2 sm:hidden" role="group" aria-label="بخش و مرتب‌سازی فعالیت بلاگ">
<Select dir="rtl" value={blogSection} onValueChange={(value) => setBlogSection(value as BlogSection)}>
<SelectTrigger dir="rtl" aria-label="بخش فعالیت بلاگ" className="h-11 w-full min-w-0 flex-row justify-between gap-2 rounded-xl bg-background px-3 text-right">
<SlidersHorizontal className="h-4 w-4 shrink-0" aria-hidden="true" />
<SelectValue className="min-w-0 flex-1 text-right" />
</SelectTrigger>
<SelectContent dir="rtl" className="max-w-[calc(100vw-1.5rem)]">
{blogSectionItems.map(({ id, label }) => <SelectItem key={id} value={id} className="text-right">{label}</SelectItem>)}
</SelectContent>
</Select>
<Select dir="rtl" value={blogSortOrder} onValueChange={(value) => { setBlogSortOrder(value as BlogSortOrder); setBlogPages((current) => ({ ...current, [blogSection]: 1 })); }}>
<SelectTrigger dir="rtl" aria-label="مرتب‌سازی فعالیت بلاگ" className="h-11 w-full min-w-0 flex-row justify-between gap-2 rounded-xl bg-background px-3 text-right">
<ArrowDownWideNarrow className="h-4 w-4 shrink-0" aria-hidden="true" />
<SelectValue className="min-w-0 flex-1 text-right" />
</SelectTrigger>
<SelectContent dir="rtl" className="max-w-[calc(100vw-1.5rem)]">
{blogSortItems.map(({ id, label }) => <SelectItem key={id} value={id} className="text-right">{label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="hidden min-w-0 items-center gap-3 sm:flex">
<Select dir="rtl" value={blogSection} onValueChange={(value) => setBlogSection(value as BlogSection)}>
<SelectTrigger aria-label="بخش فعالیت بلاگ" className="h-9 w-44 shrink-0 bg-background"><SelectValue /></SelectTrigger>
<SelectContent dir="rtl">
{blogSectionItems.map(({ id, label }) => <SelectItem key={id} value={id}>{label}</SelectItem>)}
</SelectContent>
</Select>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="group" aria-label="مرتب‌سازی فعالیت بلاگ">
<span className="shrink-0 px-1 text-xs font-semibold text-muted-foreground">مرتب‌سازی:</span>
{blogSortItems.map(({ id, label }) => (
<button <button
key={id} key={id}
type="button" type="button"
role="tab" aria-pressed={blogSortOrder === id}
aria-selected={blogSection === id} onClick={() => { setBlogSortOrder(id); setBlogPages((current) => ({ ...current, [blogSection]: 1 })); }}
onClick={() => setBlogSection(id)} className={`min-h-11 shrink-0 border-b-2 px-2 py-2 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${blogSortOrder === id ? "border-primary font-semibold text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
className={`inline-flex min-h-11 shrink-0 items-center justify-center gap-1 rounded-xl border px-2.5 py-2 text-xs leading-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:gap-2 sm:px-4 sm:py-2.5 sm:text-sm ${blogSection === id ? "border-primary/30 bg-primary text-primary-foreground" : "border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground"}`}
> >
<Icon className="h-4 w-4" aria-hidden="true" />
{label} {label}
</button> </button>
))} ))}
</div> </div>
<div className="h-[min(42dvh,34rem)] min-h-64 space-y-3 overflow-y-auto overscroll-contain rounded-xl px-1 pb-20 lg:h-auto lg:min-h-0 lg:flex-1 lg:pb-0" role="tabpanel" aria-live="polite"> </div>
</div>
<div className="h-[min(42dvh,34rem)] min-h-64 space-y-3 overflow-y-auto overscroll-contain rounded-xl px-1 pb-20 lg:h-auto lg:min-h-0 lg:flex-1 lg:pb-0" aria-live="polite">
{activeBlogIsLoading ? <p className="py-8 text-center text-sm text-muted-foreground">در حال دریافت فعالیت‌ها...</p> : null} {activeBlogIsLoading ? <p className="py-8 text-center text-sm text-muted-foreground">در حال دریافت فعالیت‌ها...</p> : null}
{activeBlogIsError ? <p role="alert" className="py-8 text-center text-sm text-destructive">دریافت فعالیت‌های بلاگ با خطا روبه‌رو شد.</p> : null} {activeBlogIsError ? <p role="alert" className="py-8 text-center text-sm text-destructive">دریافت فعالیت‌های بلاگ با خطا روبه‌رو شد.</p> : null}
{noBlogActivity ? ( {noBlogActivity ? (
<EmptyState <EmptyState
title={blogSection === "comments" ? "هنوز دیدگاهی ثبت نکرده‌اید" : "موردی در این بخش نیست"} title={{
description={blogSection === "comments" ? "گفتگوهای شما درباره نوشته‌های بلاگ اینجا می‌آید." : "با مطالعه نوشته‌های بلاگ، فعالیت شما اینجا نمایش داده می‌شود."} liked: "هنوز نوشته‌ای را نپسندیده‌اید",
saved: "هنوز نوشته‌ای ذخیره نکرده‌اید",
comments: "هنوز دیدگاهی ثبت نکرده‌اید",
}[blogSection]}
description={{
liked: "نوشته‌هایی که می‌پسندید در این بخش نمایش داده می‌شوند.",
saved: "نوشته‌هایی که ذخیره می‌کنید در این بخش نمایش داده می‌شوند.",
comments: "گفتگوهای شما درباره نوشته‌های بلاگ اینجا می‌آیند.",
}[blogSection]}
imageSrc={{
liked: "/images/profile-empty-likes.png",
saved: "/images/profile-empty-saved.png",
comments: "/images/profile-empty-comments.png",
}[blogSection]}
/> />
) : null} ) : null}
{blogSection === "comments" ? blogComments.map((comment) => ( {blogSection === "comments" ? blogComments.map((comment) => (
@@ -470,6 +554,9 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
} }
if (section === "payments") { if (section === "payments") {
const noPayments = !paymentsQuery.isLoading
&& !paymentsQuery.isError
&& (paymentsQuery.data?.results.length ?? 0) === 0;
return ( return (
<Card className="border-border/70 shadow-sm lg:flex lg:h-full lg:min-h-0 lg:flex-col"> <Card className="border-border/70 shadow-sm lg:flex lg:h-full lg:min-h-0 lg:flex-col">
<CardHeader className="text-right lg:shrink-0"> <CardHeader className="text-right lg:shrink-0">
@@ -477,32 +564,86 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
<CardDescription>خلاصه پرداخت‌های مرتبط با ثبت‌نام رویدادها</CardDescription> <CardDescription>خلاصه پرداخت‌های مرتبط با ثبت‌نام رویدادها</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:gap-4 lg:space-y-0"> <CardContent className="space-y-4 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:gap-4 lg:space-y-0">
<div className="w-full min-w-0 rounded-xl border border-border/70 bg-muted/20 p-2">
<div dir="rtl" className="grid min-w-0 grid-cols-1 gap-2 min-[520px]:grid-cols-2 sm:hidden" role="group" aria-label="فیلتر و مرتب‌سازی پرداخت‌ها">
<Select dir="rtl" value={paymentSort} onValueChange={(value) => { setPaymentSort(value as PaymentSortSelection); setPaymentsPage(1); }}>
<SelectTrigger dir="rtl" aria-label="مرتب‌سازی پرداخت‌ها" className="h-11 w-full min-w-0 flex-row justify-between gap-2 rounded-xl bg-background px-3 text-right">
<ArrowDownWideNarrow className="h-4 w-4 shrink-0" aria-hidden="true" />
<SelectValue className="min-w-0 flex-1 text-right" />
</SelectTrigger>
<SelectContent dir="rtl" className="max-w-[calc(100vw-1.5rem)]">
{paymentSortItems.map(({ id, label }) => <SelectItem key={id} value={id} className="text-right">{label}</SelectItem>)}
</SelectContent>
</Select>
<Select dir="rtl" value={paymentStatus} onValueChange={(value) => { setPaymentStatus(value as PaymentStatus); setPaymentsPage(1); }}>
<SelectTrigger dir="rtl" aria-label="فیلتر وضعیت پرداخت" className="h-11 w-full min-w-0 flex-row justify-between gap-2 rounded-xl bg-background px-3 text-right">
<SlidersHorizontal className="h-4 w-4 shrink-0" aria-hidden="true" />
<SelectValue className="min-w-0 flex-1 text-right" />
</SelectTrigger>
<SelectContent dir="rtl" className="max-w-[calc(100vw-1.5rem)]">
{paymentStatusItems.map(({ id, label }) => <SelectItem key={id} value={id} className="text-right">{label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="hidden min-w-0 items-center gap-3 sm:flex">
<Select dir="rtl" value={paymentStatus} onValueChange={(value) => { setPaymentStatus(value as PaymentStatus); setPaymentsPage(1); }}>
<SelectTrigger aria-label="فیلتر وضعیت پرداخت" className="h-9 w-44 shrink-0 bg-background"><SelectValue /></SelectTrigger>
<SelectContent dir="rtl">
{paymentStatusItems.map(({ id, label }) => <SelectItem key={id} value={id}>{label}</SelectItem>)}
</SelectContent>
</Select>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="group" aria-label="مرتب‌سازی پرداخت‌ها">
<span className="shrink-0 px-1 text-xs font-semibold text-muted-foreground">مرتب‌سازی:</span>
{paymentSortItems.map(({ id, label }) => (
<button
key={id}
type="button"
aria-pressed={paymentSort === id}
onClick={() => { setPaymentSort(id); setPaymentsPage(1); }}
className={`min-h-11 shrink-0 border-b-2 px-2 py-2 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${paymentSort === id ? "border-primary font-semibold text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
{label}
</button>
))}
</div>
</div>
</div>
<div className="h-[min(42dvh,34rem)] min-h-64 space-y-3 overflow-y-auto overscroll-contain rounded-xl px-1 pb-20 lg:h-auto lg:min-h-0 lg:flex-1 lg:pb-0" aria-live="polite"> <div className="h-[min(42dvh,34rem)] min-h-64 space-y-3 overflow-y-auto overscroll-contain rounded-xl px-1 pb-20 lg:h-auto lg:min-h-0 lg:flex-1 lg:pb-0" aria-live="polite">
{paymentsQuery.isLoading ? <p className="py-8 text-center text-sm text-muted-foreground">در حال دریافت پرداخت‌ها...</p> : null} {paymentsQuery.isLoading ? <p className="py-8 text-center text-sm text-muted-foreground">در حال دریافت پرداخت‌ها...</p> : null}
{paymentsQuery.isError ? <p role="alert" className="py-8 text-center text-sm text-destructive">دریافت پرداخت‌ها با خطا روبه‌رو شد.</p> : null} {paymentsQuery.isError ? <p role="alert" className="py-8 text-center text-sm text-destructive">دریافت پرداخت‌ها با خطا روبه‌رو شد.</p> : null}
{!paymentsQuery.isLoading && !paymentsQuery.isError && !paymentsQuery.data?.results.length ? ( {noPayments ? (
<EmptyState title="پرداختی ثبت نشده است" description="پرداخت‌های رویدادها در این بخش نمایش داده می‌شوند." /> <EmptyState
title={paymentStatus === "all" ? "پرداختی ثبت نشده است" : "پرداختی با این وضعیت وجود ندارد."}
description={paymentStatus === "all" ? "پرداخت‌های رویدادها در این بخش نمایش داده می‌شوند." : "وضعیت دیگری را انتخاب کنید تا پرداخت‌های مرتبط را ببینید."}
imageSrc="/images/profile-empty-payments.png"
/>
) : null} ) : null}
{paymentsQuery.data?.results.map((payment, index) => ( {paymentsQuery.data?.results.map((payment, index) => (
<article key={`${payment.event.slug}-${payment.created_at}-${index}`} className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border/70 bg-background p-3 text-right sm:flex-row sm:items-center sm:justify-between sm:p-4"> <article key={`${payment.event.slug}-${payment.created_at}-${index}`} className="flex min-w-0 flex-col gap-4 rounded-2xl border border-border/70 bg-background p-4 text-right transition-colors hover:border-primary/30 sm:flex-row sm:items-center sm:justify-between sm:p-5">
<div className="min-w-0"> <div className="flex min-w-0 flex-1 items-start gap-3">
<Link to={getEventPath(payment.event)} className="font-semibold hover:text-primary">{payment.event.title}</Link> <span className={`mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${payment.status === "paid" ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300" : payment.status === "pending" || payment.status === "initiated" ? "bg-amber-50 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300" : "bg-muted text-muted-foreground"}`} aria-hidden="true">
<p className="mt-1 text-sm text-muted-foreground">{formatJalali(payment.paid_at || payment.created_at)}</p> {payment.status === "paid" ? <CheckCircle2 className="h-5 w-5" /> : payment.status === "pending" || payment.status === "initiated" ? <Clock3 className="h-5 w-5" /> : <XCircle className="h-5 w-5" />}
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="text-left">
<span className="block font-semibold">{formatNumberPersian(payment.amount)} ریال</span>
{payment.discount_amount > 0 ? (
<span className="mt-1 block text-xs text-muted-foreground">تخفیف {formatNumberPersian(payment.discount_amount)} ریال</span>
) : null}
</span> </span>
<div className="min-w-0">
<Link to={getEventPath(payment.event)} className="break-words font-semibold leading-6 hover:text-primary">{payment.event.title}</Link>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{formatJalali(payment.paid_at || payment.created_at)}</span>
<span>کد پرداخت {formatNumberPersian(payment.payment_id)}</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3 sm:justify-end sm:border-0 sm:pt-0">
<div className="flex min-w-0 flex-col gap-1 sm:items-end">
<span className="whitespace-nowrap text-base font-bold tabular-nums">{formatNumberPersian(payment.amount)} <span className="text-xs font-medium text-muted-foreground">ریال</span></span>
{payment.discount_amount > 0 ? <span className="text-xs text-muted-foreground">تخفیف {formatNumberPersian(payment.discount_amount)} ریال</span> : null}
<div className="flex flex-wrap gap-1.5">
<Badge variant={payment.status === "paid" ? "secondary" : payment.status === "failed" || payment.status === "canceled" ? "destructive" : "outline"}> <Badge variant={payment.status === "paid" ? "secondary" : payment.status === "failed" || payment.status === "canceled" ? "destructive" : "outline"}>
{paymentStatusLabels[payment.status]} {paymentStatusLabels[payment.status]}
</Badge> </Badge>
{payment.refund_status && payment.refund_status !== "none" ? ( {payment.refund_status && payment.refund_status !== "none" ? <Badge variant="outline">{refundStatusLabels[payment.refund_status]}</Badge> : null}
<Badge variant="outline">{refundStatusLabels[payment.refund_status]}</Badge> </div>
) : null} </div>
<Button asChild variant="outline" size="sm" className="min-h-10"> <Button asChild variant="outline" size="sm" className="min-h-10 shrink-0">
<Link to={`${getEventPath(payment.event)}/registration?payment_id=${payment.payment_id}`}> <Link to={`${getEventPath(payment.event)}/registration?payment_id=${payment.payment_id}`}>
مشاهدهٔ وضعیت مشاهدهٔ وضعیت
</Link> </Link>
@@ -599,7 +740,7 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
<CardHeader className="pb-2 text-right"><CardTitle className="text-base">حساب و حریم خصوصی</CardTitle></CardHeader> <CardHeader className="pb-2 text-right"><CardTitle className="text-base">حساب و حریم خصوصی</CardTitle></CardHeader>
<CardContent> <CardContent>
<InfoLine label="موبایل">{profile.mobile ? toPersianDigits(profile.mobile) : null}</InfoLine> <InfoLine label="موبایل">{profile.mobile ? toPersianDigits(profile.mobile) : null}</InfoLine>
<InfoLine label="ایمیل">{profile.email}</InfoLine> <InfoLine label="ایمیل" stacked><span dir="ltr" className="block text-left">{profile.email}</span></InfoLine>
<InfoLine label="حساب گوگل">{profile.has_google_link ? "متصل" : "متصل نیست"}</InfoLine> <InfoLine label="حساب گوگل">{profile.has_google_link ? "متصل" : "متصل نیست"}</InfoLine>
<div className="mt-2 flex items-center justify-between gap-4 rounded-xl border border-border/70 bg-muted/45 p-3 text-right shadow-sm dark:bg-background/70"> <div className="mt-2 flex items-center justify-between gap-4 rounded-xl border border-border/70 bg-muted/45 p-3 text-right shadow-sm dark:bg-background/70">
<div className="min-w-0"> <div className="min-w-0">