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 { import {
CalendarCheck2, CalendarCheck2,
ChevronLeft,
ChevronRight,
CircleHelp, CircleHelp,
Heart,
MessageCircle, MessageCircle,
Trophy, Trophy,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { GamificationBadges } from "@/components/profile/GamificationBadges";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ActivityBadgePaths } from "@/components/profile/ActivityBadgePaths";
import { import {
Card, Card,
CardContent, CardContent,
@@ -16,7 +18,10 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; 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 { Link } from "@/lib/router";
import type { GamificationSummarySchema } from "@/lib/types"; import type { GamificationSummarySchema } from "@/lib/types";
import { formatNumberPersian } from "@/lib/utils"; import { formatNumberPersian } from "@/lib/utils";
@@ -52,6 +57,8 @@ export function GamificationCard({
isError: boolean; isError: boolean;
onRetry: () => void; onRetry: () => void;
}) { }) {
const [selectedRankIndex, setSelectedRankIndex] = useState<number | null>(null);
if (isLoading) { if (isLoading) {
return ( return (
<Card className="border-border/70 shadow-sm"> <Card className="border-border/70 shadow-sm">
@@ -78,15 +85,30 @@ export function GamificationCard({
); );
} }
const currentRankBadgeId = getRankBadgeId(summary.current_level.id); const currentRankIndex = Math.max(rankLevels.findIndex((level) => level.id === summary.current_level.id), 0);
const currentRankImage = getGamificationBadgeImagePath(currentRankBadgeId); 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 ( return (
<Card className="border-border/70 shadow-sm"> <Card className="border-border/70 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-3 text-right"> <CardHeader className="flex flex-row items-start justify-between gap-3 text-right">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="text-xl">رتبه و دستاوردهای من</CardTitle> <CardTitle className="text-xl">دستاوردهای من</CardTitle>
<CardDescription>سطح مادام‌العمر بر اساس مشارکت‌های معتبر در انجمن</CardDescription> <CardDescription>وضعیت نشان‌ها و جوایز شما بر اساس فعالیت‌ها و مشارکت‌ها در انجمن</CardDescription>
</div> </div>
<Button asChild type="button" variant="outline" size="icon" className="h-10 w-10 shrink-0" aria-label="راهنمای رتبه‌بندی و نشان‌ها"> <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="بازکردن راهنما در زبانه جدید"> <Link to="/rank-guide" target="_blank" rel="noopener noreferrer" title="بازکردن راهنما در زبانه جدید">
@@ -95,50 +117,109 @@ export function GamificationCard({
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="rounded-2xl bg-transparent p-3 sm:p-4">
<div className="flex min-w-0 items-center gap-3"> <div dir="ltr" className="relative min-h-32">
{currentRankImage ? ( <div dir="ltr" className="flex min-h-32 items-center gap-2">
<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" /> <Button
) : null} type="button"
<div> variant="ghost"
<p className="text-sm text-muted-foreground">سطح فعلی</p> size="icon"
<p className="mt-1 text-2xl font-bold">{summary.current_level.label}</p> 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> </div>
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-2 text-sm font-semibold text-primary"> <div className="mt-4 space-y-2 px-10 pt-3">
<Trophy className="h-4 w-4" aria-hidden="true" /> {summary.next_level ? (
{formatNumberPersian(summary.points_total)} امتیاز <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> </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"> <div className="grid gap-3 sm:grid-cols-2">
<ScoreBreakdown icon={CalendarCheck2} label="رویدادها" points={summary.points_event} /> <ScoreBreakdown icon={CalendarCheck2} label="رویدادها" points={summary.points_event} />
<ScoreBreakdown icon={MessageCircle} label="بلاگ" points={summary.points_blog} /> <ScoreBreakdown icon={MessageCircle} label="بلاگ" points={summary.points_blog} />
</div> </div>
<section className="space-y-3 border-t border-border/70 pt-4"> <div className="border-t border-border/70 pt-4">
<h3 className="flex items-center gap-2 text-sm font-semibold"> <ActivityBadgePaths badges={summary.badges} />
<Heart className="h-4 w-4 text-primary" aria-hidden="true" />نشان‌های کسب‌شده </div>
</h3>
<GamificationBadges badges={summary.badges} excludeIds={[currentRankBadgeId]} />
</section>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -6,15 +6,16 @@ import { cn } from "@/lib/utils";
const Progress = React.forwardRef< const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>, React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => ( >(({ className, value, dir, ...props }, ref) => (
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
dir={dir}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all" className="h-full w-full flex-1 rounded-full bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(${dir === "rtl" ? "" : "-"}${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
)); ));

View File

@@ -620,3 +620,23 @@ html.dark .event-date-range-modal {
animation: none; animation: none;
} }
} }
@keyframes rank-badge-fill-up {
from {
clip-path: inset(100% 0 0);
}
to {
clip-path: inset(var(--rank-badge-reveal-top) 0 0);
}
}
.rank-badge-color-fill {
clip-path: inset(var(--rank-badge-reveal-top) 0 0);
animation: rank-badge-fill-up 850ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.rank-badge-color-fill {
animation: none;
}
}