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