feat(profile): improve activity and payment sections
This commit is contained in:
BIN
public/images/profile-empty-comments.png
Normal file
BIN
public/images/profile-empty-comments.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 604 KiB |
BIN
public/images/profile-empty-likes.png
Normal file
BIN
public/images/profile-empty-likes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 665 KiB |
BIN
public/images/profile-empty-payments.png
Normal file
BIN
public/images/profile-empty-payments.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 616 KiB |
BIN
public/images/profile-empty-saved.png
Normal file
BIN
public/images/profile-empty-saved.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 642 KiB |
@@ -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" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user