Files
guilan-ace-frontend/src/components/profile/GamificationCard.tsx

227 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type CSSProperties } from "react";
import {
CalendarCheck2,
ChevronLeft,
ChevronRight,
CircleHelp,
MessageCircle,
Trophy,
} from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { ActivityBadgePaths } from "@/components/profile/ActivityBadgePaths";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
getGamificationBadgeImagePath,
rankLevels,
} 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;
}) {
const [selectedRankIndex, setSelectedRankIndex] = useState<number | null>(null);
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 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>
</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="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="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="grid gap-3 sm:grid-cols-2">
<ScoreBreakdown icon={CalendarCheck2} label="رویدادها" points={summary.points_event} />
<ScoreBreakdown icon={MessageCircle} label="بلاگ" points={summary.points_blog} />
</div>
<div className="border-t border-border/70 pt-4">
<ActivityBadgePaths badges={summary.badges} />
</div>
</CardContent>
</Card>
);
}