feat: add profile rank badges and guide
This commit is contained in:
@@ -6,7 +6,6 @@ import Image from "next/image";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDownWideNarrow,
|
||||
ArrowUpLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Bookmark,
|
||||
@@ -26,6 +25,7 @@ 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 { GamificationCard } from "@/components/profile/GamificationCard";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
@@ -162,7 +162,7 @@ function ProfileEventCard({ registration }: { registration: Types.MyEventRegistr
|
||||
className="w-full shrink-0 overflow-hidden rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-32"
|
||||
aria-label={`مشاهده رویداد ${event.title}`}
|
||||
>
|
||||
<Image src={imageUrl || "/placeholder.svg"} alt="" width={160} height={120} unoptimized className="aspect-video h-auto w-full object-cover sm:aspect-[4/3] sm:h-full" />
|
||||
<Image src={imageUrl} alt="" width={160} height={120} unoptimized className="aspect-video h-auto w-full object-cover sm:aspect-[4/3] sm:h-full" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Link to={`/events/${event.slug}`} className="break-words font-semibold text-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
@@ -248,14 +248,9 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
|
||||
enabled: isAuthenticated,
|
||||
initialData: user ?? undefined,
|
||||
});
|
||||
const registrationsQuery = useQuery({
|
||||
queryKey: ["my-registrations"],
|
||||
queryFn: () => api.getMyRegistrations(),
|
||||
enabled: isAuthenticated && section === "overview",
|
||||
});
|
||||
const blogQuery = useQuery({
|
||||
queryKey: ["my-blog-activity"],
|
||||
queryFn: () => api.getMyBlogActivity(),
|
||||
const gamificationQuery = useQuery({
|
||||
queryKey: ["my-gamification"],
|
||||
queryFn: () => api.getMyGamification(),
|
||||
enabled: isAuthenticated && section === "overview",
|
||||
});
|
||||
const eventSortOption = eventSortItems.find(({ id }) => id === eventSort) ?? eventSortItems[0];
|
||||
@@ -286,12 +281,7 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
|
||||
() => [profile?.first_name, profile?.last_name].filter(Boolean).join(" ").trim() || profile?.username || "کاربر",
|
||||
[profile?.first_name, profile?.last_name, profile?.username],
|
||||
);
|
||||
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 gamification = gamificationQuery.data;
|
||||
const activeBlogPageData = blogSection === "comments" ? blogCommentsPageQuery.data : blogPostsPageQuery.data;
|
||||
const activeBlogIsLoading = blogSection === "comments" ? blogCommentsPageQuery.isLoading : blogPostsPageQuery.isLoading;
|
||||
const activeBlogIsError = blogSection === "comments" ? blogCommentsPageQuery.isError : blogPostsPageQuery.isError;
|
||||
@@ -319,7 +309,6 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
|
||||
|
||||
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 = () => {
|
||||
@@ -507,41 +496,33 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
|
||||
<div className="space-y-5">
|
||||
<Card className="border-border/70 shadow-sm">
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>فعالیتهای اخیر</CardTitle>
|
||||
<CardDescription>مروری سریع بر حضور و مشارکت شما در انجمن</CardDescription>
|
||||
<CardTitle>مشارکتهای ثبتشده</CardTitle>
|
||||
<CardDescription>فعالیتهای معتبر برای امتیاز و نشانهای شما</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
|
||||
<CalendarDays className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(confirmedEvents.length)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">رویداد تأییدشده یا حضور یافته</p>
|
||||
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(gamification?.activity_counts.attendance ?? 0)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">حضور تأییدشده در رویداد</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
|
||||
<Heart className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(blogActivity?.liked_posts.length ?? 0)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">نوشته پسندیدهشده</p>
|
||||
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(gamification?.activity_counts.likes ?? 0)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">پسند دادهشده یا دریافتشده</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
|
||||
<Activity className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(blogCount)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">تعامل بلاگ</p>
|
||||
<p className="mt-3 text-2xl font-bold">{formatNumberPersian((gamification?.activity_counts.posts ?? 0) + (gamification?.activity_counts.comments ?? 0))}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">نوشتهها و دیدگاههای تأییدشده</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/70 shadow-sm">
|
||||
<CardHeader className="flex flex-row-reverse items-center justify-between gap-3 text-right">
|
||||
<div>
|
||||
<CardTitle>رویدادهای اخیر</CardTitle>
|
||||
<CardDescription>ثبتنامها و حضورهای شما</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigateToSection("events")}>همه رویدادها <ArrowUpLeft className="mr-1 h-4 w-4" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{registrationsQuery.isLoading ? <p className="text-sm text-muted-foreground">در حال دریافت رویدادها...</p> : null}
|
||||
{!registrationsQuery.isLoading && !confirmedEvents.length ? <EmptyState title="هنوز رویداد تأییدشدهای ندارید" description="ثبتنامهای تأییدشده اینجا دیده میشوند." /> : null}
|
||||
{confirmedEvents.slice(0, 3).map((registration) => <ProfileEventCard key={registration.id} registration={registration} />)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<GamificationCard
|
||||
summary={gamification}
|
||||
isLoading={gamificationQuery.isLoading}
|
||||
isError={gamificationQuery.isError}
|
||||
onRetry={() => { void gamificationQuery.refetch(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,17 +5,18 @@ import Image from "next/image";
|
||||
import { CalendarDays, EyeOff, UserRound } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PublicProfileBadges } from "@/components/profile/PublicProfileBadges";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatJalali, formatNumberPersian, toPersianDigits } from "@/lib/utils";
|
||||
import { formatJalali, toPersianDigits } from "@/lib/utils";
|
||||
import type { PublicProfileEventSchema } from "@/lib/types";
|
||||
|
||||
function EventCard({ event }: { event: PublicProfileEventSchema }) {
|
||||
return (
|
||||
<Link to={`/events/${event.slug}`} className="group flex min-h-28 gap-4 rounded-2xl border border-border/70 bg-background p-3 text-right transition-colors hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{event.image_url ? <Image src={event.image_url} alt="" width={160} height={120} unoptimized className="aspect-[4/3] w-24 shrink-0 rounded-xl object-cover sm:w-32" /> : (
|
||||
<span className="flex aspect-[4/3] w-24 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:w-32"><CalendarDays className="h-6 w-6" aria-hidden="true" /></span>
|
||||
<Link to={`/events/${event.slug}`} className="group flex min-h-28 flex-col gap-3 rounded-2xl border border-border/70 bg-background p-3 text-right transition-colors hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:flex-row sm:gap-4">
|
||||
{event.image_url ? <Image src={event.image_url} alt="" width={640} height={360} unoptimized className="aspect-video w-full shrink-0 rounded-xl object-cover sm:w-32" /> : (
|
||||
<span className="flex aspect-video w-full shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:w-32"><CalendarDays className="h-6 w-6" aria-hidden="true" /></span>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-2 py-1">
|
||||
<span className="font-semibold text-foreground group-hover:text-primary">{event.title}</span>
|
||||
@@ -53,11 +54,12 @@ export default function PublicProfile() {
|
||||
|
||||
const fallback = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}` || profile.username[0];
|
||||
const maskedDetails = [
|
||||
{ label: "شماره موبایل", value: profile.mobile },
|
||||
{ label: "شماره دانشجویی", value: profile.student_id },
|
||||
{ label: "دانشگاه", value: profile.university },
|
||||
{ label: "رشته", value: profile.major },
|
||||
{ label: "سال ورود", value: profile.year_of_study || null },
|
||||
{ label: "شماره موبایل", value: profile.mobile, isEmail: false },
|
||||
...(profile.email ? [{ label: "ایمیل", value: profile.email, isEmail: true }] : []),
|
||||
{ label: "دانشگاه", value: profile.university, isEmail: false },
|
||||
{ label: "رشته", value: profile.major, isEmail: false },
|
||||
{ label: "شماره دانشجویی", value: profile.student_id, isEmail: false },
|
||||
{ label: "سال ورود", value: profile.year_of_study || null, isEmail: false },
|
||||
];
|
||||
const pageDescription = profile.bio || `پروفایل عمومی ${profile.full_name} و رویدادهای او در انجمن علمی مهندسی کامپیوتر.`;
|
||||
|
||||
@@ -85,25 +87,11 @@ export default function PublicProfile() {
|
||||
</div>
|
||||
</div>
|
||||
{profile.bio ? <p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{profile.bio}</p> : null}
|
||||
<div className="mt-5 grid grid-cols-1 gap-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-center shadow-sm dark:bg-background/70"><p className="text-xl font-bold">{formatNumberPersian(profile.events.length)}</p><p className="mt-1 text-xs text-muted-foreground">رویداد</p></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70 shadow-sm">
|
||||
<CardHeader className="pb-2 text-right"><CardTitle className="text-base">{profile.details_visible ? "اطلاعات بیشتر" : "اطلاعات خصوصی"}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{profile.details_visible ? maskedDetails.map(({ label, value }) => (
|
||||
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="text-left font-medium">{value ? toPersianDigits(String(value)) : "ثبت نشده"}</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="relative isolate overflow-hidden rounded-xl border border-border/70 bg-muted/20 p-5 sm:p-6">
|
||||
<div aria-hidden="true" className="pointer-events-none select-none space-y-3 opacity-55 blur-[4px]">
|
||||
{!profile.details_visible ? (
|
||||
<div className="relative isolate mt-5 overflow-hidden rounded-xl border border-border/70 bg-muted/20 p-4 sm:p-5">
|
||||
<div aria-hidden="true" className="pointer-events-none select-none space-y-2.5 opacity-55 blur-[4px]">
|
||||
{maskedDetails.map(({ label }) => (
|
||||
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
|
||||
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-2.5 last:border-0 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="font-medium">••••••••</span>
|
||||
</div>
|
||||
@@ -111,17 +99,34 @@ export default function PublicProfile() {
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/55 p-4 text-center">
|
||||
<div className="max-w-xs">
|
||||
<span className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<EyeOff className="h-6 w-6" aria-hidden="true" />
|
||||
<span className="mx-auto mb-2 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<EyeOff className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="font-semibold">جزئیات این پروفایل خصوصی است</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">نمایش اطلاعات تکمیلی به درخواست صاحب حساب غیرفعال شده است.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-1">
|
||||
{maskedDetails.map(({ label, value, isEmail }) => (
|
||||
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span dir={isEmail ? "ltr" : undefined} className={`min-w-0 text-left font-medium ${isEmail ? "break-all" : ""}`}>
|
||||
{value ? (isEmail ? String(value) : toPersianDigits(String(value))) : "ثبت نشده"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70 shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<PublicProfileBadges badges={profile.badges} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
<div className="space-y-5 lg:col-span-8">
|
||||
|
||||
222
src/views/RankGuide.tsx
Normal file
222
src/views/RankGuide.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Award,
|
||||
CalendarCheck2,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
PenLine,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
activityLadders,
|
||||
activityPoints,
|
||||
getGamificationBadgeImagePath,
|
||||
rankLevels,
|
||||
} from "@/lib/gamification-badges";
|
||||
import { formatNumberPersian } from "@/lib/utils";
|
||||
|
||||
const activityIcons = {
|
||||
attendance: CalendarCheck2,
|
||||
posts: PenLine,
|
||||
comments: MessageCircle,
|
||||
likes: Heart,
|
||||
} as const;
|
||||
|
||||
function ProgressionBadge({
|
||||
id,
|
||||
label,
|
||||
requirement,
|
||||
description,
|
||||
size,
|
||||
eyebrow,
|
||||
className = "",
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
requirement: string;
|
||||
description: string;
|
||||
size: "level" | "activity";
|
||||
eyebrow?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const imagePath = getGamificationBadgeImagePath(id);
|
||||
|
||||
return (
|
||||
<li className={`flex min-w-0 flex-col items-center text-center ${size === "level" ? "gap-2" : "gap-1.5"} ${className}`}>
|
||||
{eyebrow ? <span className="mb-1 text-[0.65rem] font-semibold text-muted-foreground">{eyebrow}</span> : null}
|
||||
{imagePath ? (
|
||||
<Image
|
||||
src={imagePath}
|
||||
alt=""
|
||||
width={size === "level" ? 144 : 96}
|
||||
height={size === "level" ? 144 : 96}
|
||||
sizes={size === "level" ? "(max-width: 640px) 112px, 144px" : "96px"}
|
||||
className={size === "level" ? "h-28 w-28 object-contain sm:h-36 sm:w-36" : "h-20 w-20 object-contain sm:h-24 sm:w-24"}
|
||||
/>
|
||||
) : null}
|
||||
<span className={`font-semibold leading-5 ${size === "level" ? "text-sm sm:text-base" : "text-xs sm:text-sm"}`}>{label}</span>
|
||||
<span className="text-xs leading-5 text-muted-foreground">{requirement}</span>
|
||||
<span className="sr-only">{description}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RankGuide() {
|
||||
return (
|
||||
<main className="min-h-screen bg-muted/20 px-4 py-6 sm:py-10" dir="rtl">
|
||||
<div className="container mx-auto max-w-6xl space-y-8">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
بازگشت به پروفایل
|
||||
</Link>
|
||||
|
||||
<header className="relative isolate overflow-hidden rounded-3xl bg-primary text-primary-foreground">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute -left-16 -top-24 h-72 w-72 rounded-full bg-cyan-300/20 blur-3xl" />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute -bottom-40 right-1/3 h-72 w-72 rounded-full bg-amber-300/15 blur-3xl" />
|
||||
<div className="relative flex flex-col gap-5 p-6 sm:flex-row sm:items-end sm:justify-between sm:p-9">
|
||||
<div className="max-w-2xl space-y-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-medium">
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
راهنمای دستاوردهای انجمن
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold leading-tight sm:text-4xl">مشارکت شما، نشاندار میشود</h1>
|
||||
<p className="max-w-xl text-sm leading-7 text-primary-foreground/80 sm:text-base">
|
||||
با حضور در رویدادها و مشارکت در گفتوگوهای انجمن امتیاز بگیرید، سطح کلیتان را بالا ببرید و نشانهای هر مسیر را جمع کنید.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold">
|
||||
<Award className="h-5 w-5 text-amber-200" aria-hidden="true" />
|
||||
۵ سطح کلی · ۴ مسیر نشان
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="overall-levels-title" className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-wide text-primary">پیشرفت ترکیبی</p>
|
||||
<h2 id="overall-levels-title" className="mt-1 text-2xl font-bold">سطح کلی شما</h2>
|
||||
</div>
|
||||
<p className="max-w-lg text-sm leading-6 text-muted-foreground">امتیاز رویدادها و فعالیتهای بلاگ با هم جمع میشوند. سطحها مادامالعمرند و برای همه دستیافتنی هستند.</p>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/70 bg-card p-4 shadow-sm sm:p-6">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-[9%] top-[5.7rem] hidden h-px bg-gradient-to-l from-primary/10 via-primary/50 to-primary/10 lg:block" />
|
||||
<ol className="relative grid grid-cols-2 gap-x-3 gap-y-7 sm:grid-cols-3 lg:grid-cols-5 lg:gap-2">
|
||||
{rankLevels.map((level, index) => (
|
||||
<ProgressionBadge
|
||||
key={level.id}
|
||||
id={level.badgeId}
|
||||
label={level.label}
|
||||
requirement={level.minimumPoints === 0 ? "شروع مسیر" : `از ${formatNumberPersian(level.minimumPoints)} امتیاز`}
|
||||
description={`نشان سطح ${level.label}`}
|
||||
size="level"
|
||||
eyebrow={`سطح ${formatNumberPersian(index + 1)}`}
|
||||
className="relative"
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="activity-ladders-title" className="space-y-4">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-xs font-semibold tracking-wide text-primary">دستاوردهای مستقل</p>
|
||||
<h2 id="activity-ladders-title" className="mt-1 text-2xl font-bold">چهار مسیر برای نشان گرفتن</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">هر مسیر جداگانه پیش میرود. با رسیدن به هر حدنصاب، نشان همان مرحله به مجموعه دستاوردهای شما اضافه میشود.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{activityLadders.map((ladder) => {
|
||||
const Icon = activityIcons[ladder.category];
|
||||
return (
|
||||
<Card key={ladder.category} className="overflow-hidden border-border/70 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border/70 bg-muted/20 px-5 py-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-bold">{ladder.title}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{ladder.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full border border-primary/20 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary">
|
||||
+{formatNumberPersian(ladder.pointsPerAction)} امتیاز
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 sm:p-5">
|
||||
<p className="mb-3 text-xs font-medium text-muted-foreground">نشانها با این تعداد {ladder.unitLabel} باز میشوند</p>
|
||||
<ol className={`grid gap-3 ${ladder.badges.length === 4 ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"}`}>
|
||||
{ladder.badges.map((badge) => (
|
||||
<ProgressionBadge
|
||||
key={badge.id}
|
||||
id={badge.id}
|
||||
label={badge.label}
|
||||
requirement={`${formatNumberPersian(badge.threshold)} ${ladder.unitLabel}`}
|
||||
description={badge.description}
|
||||
size="activity"
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="scoring-title" className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<div className="rounded-2xl border border-border/70 bg-card p-5 sm:p-6">
|
||||
<p className="text-xs font-semibold tracking-wide text-primary">امتیازها چطور حساب میشوند؟</p>
|
||||
<h2 id="scoring-title" className="mt-1 text-xl font-bold">مشارکت معتبر، امتیاز میگیرد</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">این امتیازها سطح کلی را میسازند؛ حدنصاب نشانهای هر مسیر بر اساس تعداد فعالیت همان مسیر است.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{activityLadders.map((ladder) => {
|
||||
const Icon = activityIcons[ladder.category];
|
||||
return (
|
||||
<div key={ladder.category} className="flex min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card px-4 py-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{ladder.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">هر مورد معتبر: {formatNumberPersian(ladder.pointsPerAction)} امتیاز</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card px-4 py-3 sm:col-span-2">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Heart className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">تعامل با نوشتهها</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
{activityPoints.saveLabel} {activityPoints.selfLikeLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-muted/20 p-5 text-sm leading-6 text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>{activityPoints.reversalLabel} پرداختها و مبالغ تراکنش در رتبهبندی نقشی ندارند.</p>
|
||||
<Link href="/profile" className="inline-flex shrink-0 items-center gap-2 font-semibold text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
مشاهده دستاوردهای من
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user