feat: show badge paths on public profiles
This commit is contained in:
119
src/components/profile/ActivityBadgePaths.tsx
Normal file
119
src/components/profile/ActivityBadgePaths.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user