227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|