feat: improve private profile badge progress
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user