feat: add badge system with 10 achievement tiers

This commit is contained in:
jessy-david-dev
2026-04-13 16:47:47 +02:00
parent a9821623de
commit 190722838f
14 changed files with 167 additions and 31 deletions
+76 -16
View File
@@ -137,31 +137,91 @@ export default function AboutPage() {
</div>
</div>
{/* Paliers */}
{/* Badges */}
<div className="flex flex-col gap-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-[#555]">
Paliers du compteur total
Badges
</h2>
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl divide-y divide-[#2e2e2e]">
{[
{ emoji: "🌱", label: "Moins de 10 parties", range: "< 10" },
{ emoji: "👾", label: "10 parties et plus", range: "10+" },
{ emoji: "🎮", label: "100 parties et plus", range: "100+" },
{ emoji: "🎯", label: "500 parties et plus", range: "500+" },
{ emoji: "⚡", label: "1 000 parties et plus", range: "1 000+" },
{ emoji: "🔥", label: "5 000 parties et plus", range: "5 000+" },
{
emoji: "🚀",
label: "10 000 parties et plus",
file: "novice",
name: "Novice",
label: "Moins de 5 victoires",
range: "< 5",
},
{
file: "apprenti",
name: "Apprenti",
label: "5 victoires et plus",
range: "5+",
},
{
file: "initié",
name: "Initié",
label: "20 victoires et plus",
range: "20+",
},
{
file: "adepte",
name: "Adepte",
label: "50 victoires et plus",
range: "50+",
},
{
file: "expert",
name: "Expert",
label: "150 victoires et plus",
range: "150+",
},
{
file: "maître",
name: "Maître",
label: "500 victoires et plus",
range: "500+",
},
{
file: "légende",
name: "Légende",
label: "1 000 victoires et plus",
range: "1 000+",
},
{
file: "mythique",
name: "Mythique",
label: "2 500 victoires et plus",
range: "2 500+",
},
{
file: "transcendant",
name: "Transcendant",
label: "5 000 victoires et plus",
range: "5 000+",
},
{
file: "omniscient",
name: "Omniscient",
label: "10 000 victoires et plus",
range: "10 000+",
},
].map(({ emoji, label, range }) => (
<div key={range} className="flex items-center gap-3 px-4 py-2.5">
<span className="text-xl w-7 text-center shrink-0">
{emoji}
].map(({ file, name, label, range }) => (
<div key={file} className="flex items-center gap-3 px-4 py-2.5">
<Image
src={`/badges/${file}.Png`}
alt={name}
width={28}
height={28}
className="shrink-0"
/>
<span className="text-sm font-semibold text-[#f0f0f0] w-28 shrink-0">
{name}
</span>
<span className="text-sm text-[#888] flex-1 text-center">
{label}
</span>
<span className="text-xs font-mono text-[#555] w-16 text-right shrink-0">
{range}
</span>
<span className="text-sm text-[#aaa] flex-1">{label}</span>
<span className="text-xs font-mono text-[#555]">{range}</span>
</div>
))}
</div>
+43 -15
View File
@@ -3,19 +3,10 @@
import Image from "next/image";
import { useEffect, useState } from "react";
import type { Session } from "next-auth";
function totalEmoji(n: number): string {
if (n >= 10000) return "🚀";
if (n >= 5000) return "🔥";
if (n >= 1000) return "⚡";
if (n >= 500) return "🎯";
if (n >= 100) return "🎮";
if (n >= 10) return "👾";
return "🌱";
}
import { getBadge } from "../../lib/badges";
function useDailyStats() {
const [today, setToday] = useState<number | null>(null); // renommé plus bas en total
const [today, setToday] = useState<number | null>(null);
useEffect(() => {
fetch("/api/stats")
.then((r) => r.json())
@@ -25,6 +16,24 @@ function useDailyStats() {
return today;
}
function useUserWins(enabled: boolean) {
const [wins, setWins] = useState<number>(0);
useEffect(() => {
if (!enabled) return;
fetch("/api/games")
.then((r) => (r.ok ? r.json() : []))
.then((games: unknown) => {
if (Array.isArray(games)) {
setWins((games as { won: boolean }[]).filter((g) => g.won).length);
} else {
setWins(0);
}
})
.catch(() => setWins(0));
}, [enabled]);
return wins;
}
type HomeScreenProps = {
playerName: string;
setPlayerName: (v: string) => void;
@@ -65,6 +74,8 @@ export function HomeScreen({
const btnGhost =
"min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer whitespace-nowrap";
const today = useDailyStats();
const wins = useUserWins(!!session?.user);
const badge = session?.user ? getBadge(wins) : null;
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col items-center justify-center px-4 py-16 gap-6 sm:gap-8">
@@ -78,9 +89,20 @@ export function HomeScreen({
className="flex items-center gap-1.5 sm:gap-2 bg-[#242424] border border-[#2e2e2e] rounded-full pl-1 pr-2.5 sm:pr-3 py-1 cursor-pointer hover:bg-[#1a1a1a]"
onClick={onShowProfile}
>
<span className="w-7 h-7 rounded-full bg-[#7c3aed] text-white flex items-center justify-center text-xs font-bold shrink-0">
{session.user.name?.[0]?.toUpperCase() ?? "?"}
</span>
{badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/badges/${badge.file}.Png`}
alt={badge.name}
width={28}
height={28}
className="shrink-0 w-7 h-7 object-contain"
/>
) : (
<span className="w-7 h-7 rounded-full bg-[#7c3aed] text-white flex items-center justify-center text-xs font-bold shrink-0">
{session.user.name?.[0]?.toUpperCase() ?? "?"}
</span>
)}
<span className="hidden sm:block max-w-28 truncate text-sm font-medium">
{session.user.name}
</span>
@@ -109,13 +131,19 @@ export function HomeScreen({
</p>
{today !== null && (
<p className="mt-2 text-xs text-[#888]">
{totalEmoji(today)}{" "}
<span className="font-semibold text-[#f0f0f0]">
{today.toLocaleString("fr-FR")}
</span>{" "}
partie{today > 1 ? "s" : ""} jouée{today > 1 ? "s" : ""} au total
</p>
)}
{badge && (
<div className="mt-3 flex items-center justify-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/badges/${badge.file}.Png`} alt={badge.name} className="w-8 h-8 object-contain" />
<span className="text-sm font-black text-[#a78bfa]">{badge.name}</span>
</div>
)}
</div>
{/* Form */}
+25
View File
@@ -1,7 +1,9 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { signOut } from "next-auth/react";
import { getBadge, getNextBadge } from "../../lib/badges";
type Game = {
id: string;
@@ -77,6 +79,10 @@ export function ProfileScreen({
filter === "all" ? games : games.filter((g) => g.mode === filter);
const stats = computeStats(filtered);
const allStats = computeStats(games);
const badge = getBadge(allStats.won);
const next = getNextBadge(allStats.won);
const statCard =
"bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-3 sm:p-3.5 text-center flex flex-col gap-1";
const btnGhost =
@@ -105,6 +111,25 @@ export function ProfileScreen({
</button>
</div>
{/* Badge */}
{!loading && (
<div className="flex flex-col items-center gap-2 py-4 mb-1">
<Image
src={`/badges/${badge.file}.Png`}
alt={badge.name}
width={80}
height={80}
className="drop-shadow-lg"
/>
<span className="text-base font-black text-[#a78bfa]">{badge.name}</span>
{next && (
<span className="text-xs text-[#555]">
{next.threshold - allStats.won} victoire{next.threshold - allStats.won > 1 ? "s" : ""} avant {next.name}
</span>
)}
</div>
)}
{/* Stats grid */}
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 my-3 sm:my-4">
<div className={statCard}>