feat: add profile rank badges and guide

This commit is contained in:
2026-09-29 22:18:59 +03:30
parent bbf924c22d
commit 22cbc0b3fc
31 changed files with 785 additions and 68 deletions

View File

@@ -0,0 +1,145 @@
import {
CalendarCheck2,
CircleHelp,
Heart,
MessageCircle,
Trophy,
} from "lucide-react";
import Image from "next/image";
import { GamificationBadges } from "@/components/profile/GamificationBadges";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { getGamificationBadgeImagePath, getRankBadgeId } from "@/lib/gamification-badges";
import { Link } from "@/lib/router";
import type { GamificationSummarySchema } from "@/lib/types";
import { formatNumberPersian } from "@/lib/utils";
function ScoreBreakdown({
icon: Icon,
label,
points,
}: {
icon: typeof CalendarCheck2;
label: string;
points: number;
}) {
return (
<div className="flex min-w-0 items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<Icon className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
<span>{label}</span>
</div>
<span className="shrink-0 font-semibold text-foreground">{formatNumberPersian(points)}</span>
</div>
);
}
export function GamificationCard({
summary,
isLoading,
isError,
onRetry,
}: {
summary?: GamificationSummarySchema;
isLoading: boolean;
isError: boolean;
onRetry: () => void;
}) {
if (isLoading) {
return (
<Card className="border-border/70 shadow-sm">
<CardContent className="space-y-4 p-6" aria-busy="true" aria-label="در حال دریافت رتبه و نشان‌ها">
<div className="h-6 w-40 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
<div className="grid gap-3 sm:grid-cols-2"><div className="h-12 animate-pulse rounded bg-muted" /><div className="h-12 animate-pulse rounded bg-muted" /></div>
</CardContent>
</Card>
);
}
if (isError || !summary) {
return (
<Card className="border-border/70 shadow-sm">
<CardContent className="flex flex-col items-start gap-3 p-6 text-right sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold">رتبه و نشان‌ها بارگذاری نشد</p>
<p className="mt-1 text-sm text-muted-foreground">برای دریافت دوباره اطلاعات تلاش کنید.</p>
</div>
<Button type="button" variant="outline" onClick={onRetry}>تلاش دوباره</Button>
</CardContent>
</Card>
);
}
const currentRankBadgeId = getRankBadgeId(summary.current_level.id);
const currentRankImage = getGamificationBadgeImagePath(currentRankBadgeId);
return (
<Card className="border-border/70 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-3 text-right">
<div className="space-y-1">
<CardTitle className="text-xl">رتبه و دستاوردهای من</CardTitle>
<CardDescription>سطح مادام‌العمر بر اساس مشارکت‌های معتبر در انجمن</CardDescription>
</div>
<Button asChild type="button" variant="outline" size="icon" className="h-10 w-10 shrink-0" aria-label="راهنمای رتبه‌بندی و نشان‌ها">
<Link to="/rank-guide" target="_blank" rel="noopener noreferrer" title="بازکردن راهنما در زبانه جدید">
<CircleHelp className="h-5 w-5" aria-hidden="true" />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{currentRankImage ? (
<Image src={currentRankImage} alt="" width={112} height={112} sizes="112px" className="h-24 w-24 shrink-0 object-contain sm:h-28 sm:w-28" />
) : null}
<div>
<p className="text-sm text-muted-foreground">سطح فعلی</p>
<p className="mt-1 text-2xl font-bold">{summary.current_level.label}</p>
</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-2 text-sm font-semibold text-primary">
<Trophy className="h-4 w-4" aria-hidden="true" />
{formatNumberPersian(summary.points_total)} امتیاز
</div>
</div>
<div className="space-y-2">
{summary.next_level ? (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">پیشرفت تا سطح {summary.next_level.label}</span>
<span className="font-medium">{formatNumberPersian(summary.points_to_next)} امتیاز تا سطح بعدی</span>
</div>
) : (
<p className="text-sm text-muted-foreground">بالاترین سطح را دریافت کرده‌اید.</p>
)}
<Progress
value={summary.progress_percent}
aria-label="پیشرفت تا سطح بعدی"
aria-valuetext={`${formatNumberPersian(summary.progress_percent)} درصد`}
className="h-2.5 bg-muted"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ScoreBreakdown icon={CalendarCheck2} label="رویدادها" points={summary.points_event} />
<ScoreBreakdown icon={MessageCircle} label="بلاگ" points={summary.points_blog} />
</div>
<section className="space-y-3 border-t border-border/70 pt-4">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Heart className="h-4 w-4 text-primary" aria-hidden="true" />نشان‌های کسب‌شده
</h3>
<GamificationBadges badges={summary.badges} excludeIds={[currentRankBadgeId]} />
</section>
</CardContent>
</Card>
);
}