feat: show badge paths on public profiles

This commit is contained in:
2026-10-02 12:20:02 +03:30
parent 878f417905
commit 1089420030
2 changed files with 122 additions and 13 deletions

View File

@@ -0,0 +1,119 @@
import { useState } from "react";
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
activityLadders,
getGamificationBadgeImagePath,
} from "@/lib/gamification-badges";
import type { PublicGamificationBadgeSchema } from "@/lib/types";
const badgeProgressionRows = activityLadders.map((ladder) => ({
id: ladder.category,
label: ladder.category === "likes" ? "لایک‌های انجمن" : ladder.title,
badges: ladder.badges.map((badge) => ({
id: badge.id,
label: badge.label,
description: ladder.category === "likes" ? badge.description.replace(/پسند/g, "لایک") : badge.description,
})),
}));
export function ActivityBadgePaths({
badges,
title = "نشان‌های هر مسیر",
gridClassName = "grid gap-3 sm:grid-cols-2",
}: {
badges: PublicGamificationBadgeSchema[];
title?: string | null;
gridClassName?: string;
}) {
const [selectedBadgeIndices, setSelectedBadgeIndices] = useState<Record<string, number>>({});
const earnedBadgeIds = new Set(badges.map((badge) => badge.id));
return (
<section className="space-y-3" aria-label={title ?? "نشان‌های هر مسیر"}>
{title ? <h3 className="text-sm font-semibold">{title}</h3> : null}
<div className={gridClassName}>
{badgeProgressionRows.map((row) => {
const latestEarnedIndex = row.badges.reduce(
(latest, badge, index) => earnedBadgeIds.has(badge.id) ? index : latest,
-1,
);
const selectedIndex = Math.min(
selectedBadgeIndices[row.id] ?? Math.max(latestEarnedIndex, 0),
row.badges.length - 1,
);
const selectedBadge = row.badges[selectedIndex];
const selectedEarned = earnedBadgeIds.has(selectedBadge.id);
const selectBadge = (index: number) => {
setSelectedBadgeIndices((current) => ({ ...current, [row.id]: index }));
};
return (
<Card key={row.id} role="group" aria-label={row.label} className="min-w-0 border-border/70 shadow-sm">
<CardContent className="flex min-h-[18rem] min-w-0 flex-col items-center p-3 pt-3 text-center sm:min-h-[20rem] sm:p-4 sm:pt-4">
{getGamificationBadgeImagePath(selectedBadge.id) ? (
<Image
src={getGamificationBadgeImagePath(selectedBadge.id)!}
alt=""
width={160}
height={160}
sizes="(max-width: 640px) 128px, 144px"
className={`mt-1 h-32 w-32 object-contain sm:mt-2 sm:h-36 sm:w-36 ${selectedEarned ? "" : "grayscale opacity-50"}`}
/>
) : null}
<h4 className={`mt-1 text-sm font-semibold leading-5 sm:text-base sm:leading-6 ${selectedEarned ? "text-foreground" : "text-muted-foreground"}`}>
{selectedBadge.label}
</h4>
<p className="mt-2 max-w-full text-[11px] leading-[1.15rem] text-muted-foreground sm:text-xs sm:leading-5">{selectedBadge.description}</p>
<div className="mt-auto flex w-full items-center justify-between gap-2 pt-4">
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
aria-label="نشان قبلی"
disabled={selectedIndex === 0}
onClick={() => selectBadge(selectedIndex - 1)}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Button>
<div className="flex min-w-0 items-center justify-center gap-2" role="group" aria-label={`انتخاب نشان از مسیر ${row.label}`}>
{row.badges.map((badge, index) => {
const earned = earnedBadgeIds.has(badge.id);
const isSelected = index === selectedIndex;
return (
<button
key={badge.id}
type="button"
aria-label={`${badge.label}، ${earned ? "دریافت‌شده" : "دریافت‌نشده"}`}
aria-pressed={isSelected}
onClick={() => selectBadge(index)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border transition-colors ${earned ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/50 bg-background text-muted-foreground"} ${isSelected ? "ring-2 ring-primary/30 ring-offset-2" : ""}`}
>
{earned ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : null}
</button>
);
})}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
aria-label="نشان بعدی"
disabled={selectedIndex === row.badges.length - 1}
onClick={() => selectBadge(selectedIndex + 1)}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</section>
);
}

View File

@@ -1,21 +1,15 @@
import Image from "next/image";
import { Trophy } from "lucide-react";
import { GamificationBadges } from "@/components/profile/GamificationBadges";
import { ActivityBadgePaths } from "@/components/profile/ActivityBadgePaths";
import {
getGamificationBadgeImagePath,
getLatestBadgePerCategory,
} from "@/lib/gamification-badges";
import type { PublicGamificationBadgeSchema } from "@/lib/types";
const activityCategories = ["attendance", "posts", "comments", "likes"] as const;
export function PublicProfileBadges({ badges }: { badges: PublicGamificationBadgeSchema[] }) {
const latestBadges = getLatestBadgePerCategory(badges);
const overallRank = latestBadges.level;
const activityBadges = activityCategories.flatMap((category) => {
const badge = latestBadges[category];
return badge ? [badge] : [];
});
const rankImage = overallRank ? getGamificationBadgeImagePath(overallRank.id) : undefined;
return (
@@ -33,19 +27,15 @@ export function PublicProfileBadges({ badges }: { badges: PublicGamificationBadg
<div className="min-w-0 min-[480px]:flex-1">
<p className="inline-flex max-w-full shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-semibold text-amber-700 dark:text-amber-200">
<Trophy className="h-3.5 w-3.5" aria-hidden="true" />
رتبه کلی
سطح فعلی
</p>
<h3 className="mt-2 text-xl font-extrabold leading-7 sm:text-2xl">{overallRank.label}</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">بالاترین سطح کلی کسب‌شده</p>
</div>
</div>
</section>
) : null}
<section aria-label="نشان‌ها" className="space-y-3">
<h3 className="text-sm font-semibold">نشان‌ها</h3>
<GamificationBadges badges={activityBadges} compact />
</section>
<ActivityBadgePaths badges={badges} title="نشان‌ها" gridClassName="grid gap-3" />
</div>
);
}