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>
</div> </div>
{/* Paliers */} {/* Badges */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-[#555]"> <h2 className="text-xs font-bold uppercase tracking-widest text-[#555]">
Paliers du compteur total Badges
</h2> </h2>
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl divide-y divide-[#2e2e2e]"> <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: "🚀", file: "novice",
label: "10 000 parties et plus", 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+", range: "10 000+",
}, },
].map(({ emoji, label, range }) => ( ].map(({ file, name, label, range }) => (
<div key={range} className="flex items-center gap-3 px-4 py-2.5"> <div key={file} className="flex items-center gap-3 px-4 py-2.5">
<span className="text-xl w-7 text-center shrink-0"> <Image
{emoji} 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>
<span className="text-sm text-[#aaa] flex-1">{label}</span>
<span className="text-xs font-mono text-[#555]">{range}</span>
</div> </div>
))} ))}
</div> </div>
+40 -12
View File
@@ -3,19 +3,10 @@
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { getBadge } from "../../lib/badges";
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 "🌱";
}
function useDailyStats() { function useDailyStats() {
const [today, setToday] = useState<number | null>(null); // renommé plus bas en total const [today, setToday] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
fetch("/api/stats") fetch("/api/stats")
.then((r) => r.json()) .then((r) => r.json())
@@ -25,6 +16,24 @@ function useDailyStats() {
return today; 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 = { type HomeScreenProps = {
playerName: string; playerName: string;
setPlayerName: (v: string) => void; setPlayerName: (v: string) => void;
@@ -65,6 +74,8 @@ export function HomeScreen({
const btnGhost = 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"; "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 today = useDailyStats();
const wins = useUserWins(!!session?.user);
const badge = session?.user ? getBadge(wins) : null;
return ( 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"> <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]" 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} onClick={onShowProfile}
> >
{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"> <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() ?? "?"} {session.user.name?.[0]?.toUpperCase() ?? "?"}
</span> </span>
)}
<span className="hidden sm:block max-w-28 truncate text-sm font-medium"> <span className="hidden sm:block max-w-28 truncate text-sm font-medium">
{session.user.name} {session.user.name}
</span> </span>
@@ -109,13 +131,19 @@ export function HomeScreen({
</p> </p>
{today !== null && ( {today !== null && (
<p className="mt-2 text-xs text-[#888]"> <p className="mt-2 text-xs text-[#888]">
{totalEmoji(today)}{" "}
<span className="font-semibold text-[#f0f0f0]"> <span className="font-semibold text-[#f0f0f0]">
{today.toLocaleString("fr-FR")} {today.toLocaleString("fr-FR")}
</span>{" "} </span>{" "}
partie{today > 1 ? "s" : ""} jouée{today > 1 ? "s" : ""} au total partie{today > 1 ? "s" : ""} jouée{today > 1 ? "s" : ""} au total
</p> </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> </div>
{/* Form */} {/* Form */}
+25
View File
@@ -1,7 +1,9 @@
"use client"; "use client";
import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { getBadge, getNextBadge } from "../../lib/badges";
type Game = { type Game = {
id: string; id: string;
@@ -77,6 +79,10 @@ export function ProfileScreen({
filter === "all" ? games : games.filter((g) => g.mode === filter); filter === "all" ? games : games.filter((g) => g.mode === filter);
const stats = computeStats(filtered); const stats = computeStats(filtered);
const allStats = computeStats(games);
const badge = getBadge(allStats.won);
const next = getNextBadge(allStats.won);
const statCard = const statCard =
"bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-3 sm:p-3.5 text-center flex flex-col gap-1"; "bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-3 sm:p-3.5 text-center flex flex-col gap-1";
const btnGhost = const btnGhost =
@@ -105,6 +111,25 @@ export function ProfileScreen({
</button> </button>
</div> </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 */} {/* Stats grid */}
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 my-3 sm:my-4"> <div className="grid grid-cols-3 gap-2 sm:gap-2.5 my-3 sm:my-4">
<div className={statCard}> <div className={statCard}>
+23
View File
@@ -0,0 +1,23 @@
export type Badge = { name: string; file: string; threshold: number };
export const BADGES: Badge[] = [
{ name: "Omniscient", file: "omniscient", threshold: 10000 },
{ name: "Transcendant", file: "transcendant", threshold: 5000 },
{ name: "Mythique", file: "mythique", threshold: 2500 },
{ name: "Légende", file: "légende", threshold: 1000 },
{ name: "Maître", file: "maître", threshold: 500 },
{ name: "Expert", file: "expert", threshold: 150 },
{ name: "Adepte", file: "adepte", threshold: 50 },
{ name: "Initié", file: "initié", threshold: 20 },
{ name: "Apprenti", file: "apprenti", threshold: 5 },
{ name: "Novice", file: "novice", threshold: 0 },
];
export function getBadge(wins: number): Badge {
return BADGES.find((b) => wins >= b.threshold) ?? BADGES[BADGES.length - 1];
}
export function getNextBadge(wins: number): Badge | null {
const idx = BADGES.findIndex((b) => wins >= b.threshold);
return idx > 0 ? BADGES[idx - 1] : null;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB