feat: improve private profile badge progress

This commit is contained in:
2026-10-02 12:20:12 +03:30
parent 1089420030
commit 0dba1ba398
3 changed files with 146 additions and 44 deletions

View File

@@ -1,13 +1,15 @@
import { useState, type CSSProperties } from "react";
import {
CalendarCheck2,
ChevronLeft,
ChevronRight,
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 { ActivityBadgePaths } from "@/components/profile/ActivityBadgePaths";
import {
Card,
CardContent,
@@ -16,7 +18,10 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { getGamificationBadgeImagePath, getRankBadgeId } from "@/lib/gamification-badges";
import {
getGamificationBadgeImagePath,
rankLevels,
} from "@/lib/gamification-badges";
import { Link } from "@/lib/router";
import type { GamificationSummarySchema } from "@/lib/types";
import { formatNumberPersian } from "@/lib/utils";
@@ -52,6 +57,8 @@ export function GamificationCard({
isError: boolean;
onRetry: () => void;
}) {
const [selectedRankIndex, setSelectedRankIndex] = useState<number | null>(null);
if (isLoading) {
return (
<Card className="border-border/70 shadow-sm">
@@ -78,15 +85,30 @@ export function GamificationCard({
);
}
const currentRankBadgeId = getRankBadgeId(summary.current_level.id);
const currentRankImage = getGamificationBadgeImagePath(currentRankBadgeId);
const currentRankIndex = Math.max(rankLevels.findIndex((level) => level.id === summary.current_level.id), 0);
const nextRankIndex = summary.next_level
? rankLevels.findIndex((level) => level.id === summary.next_level?.id)
: -1;
const defaultRankIndex = nextRankIndex >= 0 ? nextRankIndex : currentRankIndex;
const visibleRankIndex = Math.min(selectedRankIndex ?? defaultRankIndex, rankLevels.length - 1);
const visibleRank = rankLevels[visibleRankIndex];
const visibleRankImage = getGamificationBadgeImagePath(visibleRank.badgeId);
const visibleRankPointTarget = visibleRank.minimumPoints;
const visibleRankPoints = Math.min(Math.max(summary.points_total, 0), visibleRankPointTarget);
const nextLevelPointTarget = summary.next_level?.minimum_points ?? 0;
const nextLevelProgressPercent = nextLevelPointTarget > 0
? Math.min(Math.max((summary.points_total / nextLevelPointTarget) * 100, 0), 100)
: 100;
const earnedBadgeIds = new Set(summary.badges.map((badge) => badge.id));
const visibleRankIsEarned = earnedBadgeIds.has(visibleRank.badgeId);
const visibleRankIsNextLevel = summary.next_level?.id === visibleRank.id;
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>
<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="بازکردن راهنما در زبانه جدید">
@@ -95,50 +117,109 @@ export function GamificationCard({
</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 className="rounded-2xl bg-transparent p-3 sm:p-4">
<div dir="ltr" className="relative min-h-32">
<div dir="ltr" className="flex min-h-32 items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="سطح بعدی"
disabled={visibleRankIndex === rankLevels.length - 1}
onClick={() => setSelectedRankIndex(visibleRankIndex + 1)}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<div dir="ltr" className="relative flex min-h-32 min-w-0 flex-1 items-center justify-end">
<div dir="ltr" className="flex min-w-0 items-start justify-end gap-2 sm:gap-4">
<div dir="rtl" className="min-w-0 pt-1 text-right">
<p className={`mt-1 text-xl font-bold sm:text-2xl ${visibleRankIsEarned ? "text-foreground" : "text-muted-foreground"}`}>
{visibleRank.label}
</p>
<p className="mt-1 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<span>{formatNumberPersian(visibleRankPoints).replace(/,/g, "")} از {formatNumberPersian(visibleRankPointTarget).replace(/,/g, "")} امتیاز</span>
<Trophy className="h-4 w-4 shrink-0 text-current" aria-hidden="true" />
</p>
</div>
{visibleRankImage ? (
visibleRankIsNextLevel && !visibleRankIsEarned ? (
<div
key={`${visibleRank.id}-${nextLevelProgressPercent}`}
className="relative h-32 w-32 shrink-0"
style={{ "--rank-badge-reveal-top": `${100 - nextLevelProgressPercent}%` } as CSSProperties}
>
<Image
src={visibleRankImage}
alt=""
width={144}
height={144}
sizes="128px"
className="absolute inset-0 h-full w-full object-contain grayscale"
/>
<Image
src={visibleRankImage}
alt=""
width={144}
height={144}
sizes="128px"
className="rank-badge-color-fill absolute inset-0 h-full w-full object-contain"
/>
</div>
) : (
<Image
src={visibleRankImage}
alt=""
width={144}
height={144}
sizes="128px"
className={`h-32 w-32 shrink-0 object-contain ${visibleRankIsEarned ? "" : "grayscale opacity-50"}`}
/>
)
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="سطح قبلی"
disabled={visibleRankIndex === 0}
onClick={() => setSelectedRankIndex(visibleRankIndex - 1)}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Button>
</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 className="mt-4 space-y-2 px-10 pt-3">
{summary.next_level ? (
<p dir="rtl" className="text-left text-xs font-medium text-muted-foreground">
{formatNumberPersian(summary.points_to_next)} امتیاز تا رسیدن به «{summary.next_level.label}»
</p>
) : (
<p dir="rtl" className="text-left text-xs font-medium text-muted-foreground">بالاترین سطح را دریافت کرده‌اید.</p>
)}
<Progress
dir="rtl"
value={nextLevelProgressPercent}
aria-label={summary.next_level ? `پیشرفت تا سطح ${summary.next_level.label}` : "بالاترین سطح دریافت شده"}
aria-valuetext={summary.next_level
? `${formatNumberPersian(summary.points_total)} امتیاز، ${formatNumberPersian(summary.points_to_next)} امتیاز تا سطح ${summary.next_level.label}`
: "بالاترین سطح دریافت شده"}
className="h-2.5 min-w-0 flex-1 bg-muted"
/>
</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>
<div className="border-t border-border/70 pt-4">
<ActivityBadgePaths badges={summary.badges} />
</div>
</CardContent>
</Card>
);