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 Image from "next/image";
|
||||||
import { Trophy } from "lucide-react";
|
import { Trophy } from "lucide-react";
|
||||||
import { GamificationBadges } from "@/components/profile/GamificationBadges";
|
import { ActivityBadgePaths } from "@/components/profile/ActivityBadgePaths";
|
||||||
import {
|
import {
|
||||||
getGamificationBadgeImagePath,
|
getGamificationBadgeImagePath,
|
||||||
getLatestBadgePerCategory,
|
getLatestBadgePerCategory,
|
||||||
} from "@/lib/gamification-badges";
|
} from "@/lib/gamification-badges";
|
||||||
import type { PublicGamificationBadgeSchema } from "@/lib/types";
|
import type { PublicGamificationBadgeSchema } from "@/lib/types";
|
||||||
|
|
||||||
const activityCategories = ["attendance", "posts", "comments", "likes"] as const;
|
|
||||||
|
|
||||||
export function PublicProfileBadges({ badges }: { badges: PublicGamificationBadgeSchema[] }) {
|
export function PublicProfileBadges({ badges }: { badges: PublicGamificationBadgeSchema[] }) {
|
||||||
const latestBadges = getLatestBadgePerCategory(badges);
|
const latestBadges = getLatestBadgePerCategory(badges);
|
||||||
const overallRank = latestBadges.level;
|
const overallRank = latestBadges.level;
|
||||||
const activityBadges = activityCategories.flatMap((category) => {
|
|
||||||
const badge = latestBadges[category];
|
|
||||||
return badge ? [badge] : [];
|
|
||||||
});
|
|
||||||
const rankImage = overallRank ? getGamificationBadgeImagePath(overallRank.id) : undefined;
|
const rankImage = overallRank ? getGamificationBadgeImagePath(overallRank.id) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,19 +27,15 @@ export function PublicProfileBadges({ badges }: { badges: PublicGamificationBadg
|
|||||||
<div className="min-w-0 min-[480px]:flex-1">
|
<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">
|
<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" />
|
<Trophy className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
رتبه کلی
|
سطح فعلی
|
||||||
</p>
|
</p>
|
||||||
<h3 className="mt-2 text-xl font-extrabold leading-7 sm:text-2xl">{overallRank.label}</h3>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section aria-label="نشانها" className="space-y-3">
|
<ActivityBadgePaths badges={badges} title="نشانها" gridClassName="grid gap-3" />
|
||||||
<h3 className="text-sm font-semibold">نشانها</h3>
|
|
||||||
<GamificationBadges badges={activityBadges} compact />
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user