feat: page Achievements + soins renommé + bouton soins en combat
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s

Page /achievements : 20 succès groupés par catégorie (Combat, Progression,
Économie, Équipement), progress bars, paliers bronze/silver/gold,
bouton réclamer, compteur débloqués/total.

Renommage "repos" → "soins" partout (dashboard, budget, messages).
Bouton soins ajouté dans la page combat (accès rapide entre les fights).
Icône Trophy dans la sidebar pour les succès.
This commit is contained in:
2026-03-24 17:36:20 +01:00
parent 210f32b9cc
commit 4d254692b0
5 changed files with 244 additions and 11 deletions

View File

@@ -10,6 +10,7 @@ import { InventoryPage } from './pages/InventoryPage';
import { CraftPage } from './pages/CraftPage'; import { CraftPage } from './pages/CraftPage';
import { ForgePage } from './pages/ForgePage'; import { ForgePage } from './pages/ForgePage';
import { QuestPage } from './pages/QuestPage'; import { QuestPage } from './pages/QuestPage';
import { AchievementsPage } from './pages/AchievementsPage';
const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
@@ -35,6 +36,7 @@ function AppRoutes() {
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} /> <Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} /> <Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} /> <Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />
<Route path="/achievements" element={<ProtectedLayout><AchievementsPage /></ProtectedLayout>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
); );

View File

@@ -1,15 +1,16 @@
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Swords, Package, Hammer, User, LogOut, Shield, Scroll } from 'lucide-react'; import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy } from 'lucide-react';
import { HudBar } from './HudBar'; import { HudBar } from './HudBar';
const NAV = [ const NAV = [
{ to: '/dashboard', icon: User, label: 'Personnage' }, { to: '/dashboard', icon: User, label: 'Personnage' },
{ to: '/quests', icon: Scroll, label: 'Quêtes' }, { to: '/quests', icon: Scroll, label: 'Quêtes' },
{ to: '/combat', icon: Swords, label: 'Combat' }, { to: '/combat', icon: Swords, label: 'Combat' },
{ to: '/inventory', icon: Package, label: 'Inventaire' }, { to: '/inventory', icon: Package, label: 'Inventaire' },
{ to: '/craft', icon: Hammer, label: 'Artisanat' }, { to: '/craft', icon: Hammer, label: 'Artisanat' },
{ to: '/forge', icon: Shield, label: 'Forge' }, { to: '/forge', icon: Shield, label: 'Forge' },
{ to: '/achievements', icon: Trophy, label: 'Succès' },
]; ];
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {

View File

@@ -0,0 +1,208 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { Trophy, Lock, CheckCircle, Gift, Star, Coins } from 'lucide-react';
const CATEGORY_LABELS: Record<string, { label: string; emoji: string }> = {
combat: { label: 'Combat', emoji: '⚔️' },
progression: { label: 'Progression', emoji: '⭐' },
economy: { label: 'Économie', emoji: '💰' },
equipment: { label: 'Équipement', emoji: '🔨' },
};
const TIER_COLORS: Record<string, string> = {
bronze: '#cd7f32',
silver: '#c0c0c0',
gold: '#f4c94e',
};
interface AchievementProgress {
id: string;
key: string;
name: string;
description: string;
category: string;
tier: string;
criteriaType: string;
criteriaValue: number;
rewardGold: number;
rewardTitle: string | null;
progress: number;
unlocked: boolean;
unlockedAt: string | null;
claimed: boolean;
percentage: number;
}
function AchievementCard({ a }: { a: AchievementProgress }) {
const qc = useQueryClient();
const claimMut = useMutation({
mutationFn: () => api.post(`/achievements/claim/${a.id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['achievements'] });
qc.invalidateQueries({ queryKey: ['character'] });
},
});
const tierColor = TIER_COLORS[a.tier] ?? '#6b7a99';
const canClaim = a.unlocked && !a.claimed;
return (
<div
className={`card ${a.unlocked ? 'card-gold' : ''}`}
style={{ padding: '0.75rem 1rem', opacity: a.unlocked ? 1 : 0.7 }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
{/* Icon */}
<div style={{
width: 36, height: 36, borderRadius: 8,
background: a.unlocked ? tierColor + '22' : '#1e2535',
border: `2px solid ${a.unlocked ? tierColor : '#2a3448'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{a.claimed ? <CheckCircle size={18} color="#3ddc84" />
: a.unlocked ? <Trophy size={18} color={tierColor} />
: <Lock size={16} color="#3a4560" />
}
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 700, fontSize: 13, color: a.unlocked ? tierColor : '#6b7a99' }}>
{a.name}
</span>
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 4,
background: tierColor + '22', color: tierColor, fontWeight: 700,
textTransform: 'uppercase',
}}>
{a.tier}
</span>
</div>
<p style={{ margin: 0, fontSize: 11, color: '#6b7a99' }}>{a.description}</p>
{/* Progress bar */}
{!a.claimed && (
<div style={{ marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: '#6b7a99', marginBottom: 2 }}>
<span>{a.progress} / {a.criteriaValue}</span>
<span>{a.percentage}%</span>
</div>
<div style={{ background: '#1e2535', borderRadius: 3, height: 4, overflow: 'hidden' }}>
<div style={{
width: `${a.percentage}%`, height: '100%',
background: a.unlocked ? tierColor : '#5ba4f5',
borderRadius: 3, transition: 'width 0.3s',
}} />
</div>
</div>
)}
{/* Rewards */}
<div style={{ display: 'flex', gap: 10, marginTop: 6, fontSize: 10, color: '#6b7a99' }}>
{a.rewardGold > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Coins size={9} color="#f4c94e" /> {a.rewardGold} or
</span>
)}
{a.rewardTitle && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Star size={9} color="#a78bfa" /> Titre : {a.rewardTitle}
</span>
)}
</div>
{/* Claim button */}
{canClaim && (
<button
className="btn btn-gold"
style={{ marginTop: 6, fontSize: 11, padding: '0.2rem 0.75rem' }}
disabled={claimMut.isPending}
onClick={() => claimMut.mutate()}
>
<Gift size={12} style={{ display: 'inline', marginRight: 4 }} />
{claimMut.isPending ? 'Réclamation…' : 'Réclamer'}
</button>
)}
{a.claimed && (
<div style={{ marginTop: 4, fontSize: 10, color: '#3ddc84', display: 'flex', alignItems: 'center', gap: 4 }}>
<CheckCircle size={10} /> Réclamé
</div>
)}
</div>
</div>
</div>
);
}
export function AchievementsPage() {
const { data: achievements, isLoading } = useQuery({
queryKey: ['achievements'],
queryFn: () => api.get<AchievementProgress[]>('/achievements/me'),
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
if (!achievements) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Aucun succès</div>;
// Group by category
const categories = new Map<string, AchievementProgress[]>();
for (const a of achievements) {
const list = categories.get(a.category) ?? [];
list.push(a);
categories.set(a.category, list);
}
const totalUnlocked = achievements.filter(a => a.unlocked).length;
const totalClaimed = achievements.filter(a => a.claimed).length;
const claimable = achievements.filter(a => a.unlocked && !a.claimed).length;
return (
<div>
<h2 style={{ margin: '0 0 0.5rem', color: '#f4c94e', fontSize: 20 }}>🏆 Succès</h2>
{/* Summary */}
<div className="card" style={{ marginBottom: '1rem', display: 'flex', gap: 24, padding: '0.75rem 1rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#f4c94e' }}>{totalUnlocked}</div>
<div style={{ fontSize: 10, color: '#6b7a99' }}>Débloqués</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#3ddc84' }}>{totalClaimed}</div>
<div style={{ fontSize: 10, color: '#6b7a99' }}>Réclamés</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#dce4f0' }}>{achievements.length}</div>
<div style={{ fontSize: 10, color: '#6b7a99' }}>Total</div>
</div>
{claimable > 0 && (
<div style={{ textAlign: 'center', marginLeft: 'auto' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#f4c94e' }}>🎁 {claimable} à réclamer !</div>
</div>
)}
</div>
{/* Categories */}
{Array.from(categories.entries()).map(([cat, achs]) => {
const info = CATEGORY_LABELS[cat] ?? { label: cat, emoji: '📋' };
return (
<div key={cat} style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>
{info.emoji} {info.label}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{achs
.sort((a, b) => {
const tierOrder = { bronze: 0, silver: 1, gold: 2 };
return (tierOrder[a.tier as keyof typeof tierOrder] ?? 0) - (tierOrder[b.tier as keyof typeof tierOrder] ?? 0);
})
.map(a => <AchievementCard key={a.id} a={a} />)
}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -2,9 +2,10 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { combatApi, characterApi } from '../api/endpoints'; import { combatApi, characterApi } from '../api/endpoints';
import type { Monster, CombatResult, CombatLog } from '../api/types'; import type { Monster, CombatResult, CombatLog } from '../api/types';
import { Swords, Trophy, Skull, Clock, Zap } from 'lucide-react'; import { Swords, Trophy, Skull, Clock, Zap, Heart } from 'lucide-react';
const COMBAT_COST = 5; const COMBAT_COST = 5;
const REST_COST = 10;
const ATTACK_TYPES = [ const ATTACK_TYPES = [
{ id: 'melee', label: 'Mêlée', emoji: '⚔️', stat: 'Force × 1.5' }, { id: 'melee', label: 'Mêlée', emoji: '⚔️', stat: 'Force × 1.5' },
@@ -113,6 +114,13 @@ export function CombatPage() {
const endurance = char?.enduranceCurrent ?? 0; const endurance = char?.enduranceCurrent ?? 0;
const playerLevel = char?.level ?? 1; const playerLevel = char?.level ?? 1;
const canFight = endurance >= COMBAT_COST; const canFight = endurance >= COMBAT_COST;
const needsHeal = char ? char.hpCurrent < char.hpMax : false;
const canHeal = needsHeal && endurance >= REST_COST;
const healMut = useMutation({
mutationFn: () => characterApi.rest(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
const { data: monsters, isLoading } = useQuery({ const { data: monsters, isLoading } = useQuery({
queryKey: ['monsters'], queryKey: ['monsters'],
@@ -190,6 +198,20 @@ export function CombatPage() {
))} ))}
</div> </div>
{/* Soins rapide */}
{needsHeal && (
<button
className="btn btn-ghost"
style={{ width: '100%', marginBottom: 8, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center', opacity: canHeal ? 1 : 0.5 }}
disabled={healMut.isPending || !canHeal}
onClick={() => healMut.mutate()}
>
<Heart size={12} color="#e84040" />
{healMut.isPending ? 'Soins' : `Soins (+50% PV, ${REST_COST}⚡)`}
<span style={{ color: '#6b7a99', fontSize: 11 }}>— {char!.hpCurrent}/{char!.hpMax} PV</span>
</button>
)}
{/* Coût endurance */} {/* Coût endurance */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 6, fontSize: 12, color: canFight ? '#5ba4f5' : '#e84040' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 6, fontSize: 12, color: canFight ? '#5ba4f5' : '#e84040' }}>
<Zap size={12} /> Coût : {COMBAT_COST} endurance — Disponible : {endurance} <Zap size={12} /> Coût : {COMBAT_COST} endurance — Disponible : {endurance}

View File

@@ -220,7 +220,7 @@ export function DashboardPage() {
<span style={{ fontWeight: 700, color: '#5ba4f5' }}> Budget :</span> <span style={{ fontWeight: 700, color: '#5ba4f5' }}> Budget :</span>
{' '}{Math.floor(endurance / COMBAT_COST)} combats {' '}{Math.floor(endurance / COMBAT_COST)} combats
{' · '}{Math.floor(endurance / FORGE_COST)} forges {' · '}{Math.floor(endurance / FORGE_COST)} forges
{' · '}{Math.floor(endurance / REST_COST)} repos {' · '}{Math.floor(endurance / REST_COST)} soins
</div> </div>
{needsHeal && ( {needsHeal && (
@@ -231,11 +231,11 @@ export function DashboardPage() {
onClick={() => restMut.mutate()} onClick={() => restMut.mutate()}
> >
<BedDouble size={13} /> <BedDouble size={13} />
{restMut.isPending ? 'Repos…' : `Se reposer (+50% PV, ${REST_COST}⚡)`} {restMut.isPending ? 'Soins…' : `Soins (+50% PV, ${REST_COST}⚡)`}
</button> </button>
)} )}
{needsHeal && !canRest && endurance < REST_COST && ( {needsHeal && !canRest && endurance < REST_COST && (
<p style={{ fontSize: 10, color: '#e84040', textAlign: 'center', margin: '2px 0 0' }}>Endurance insuffisante pour se reposer</p> <p style={{ fontSize: 10, color: '#e84040', textAlign: 'center', margin: '2px 0 0' }}>Endurance insuffisante pour les soins</p>
)} )}
{restMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 2 }}>{(restMut.error as Error).message}</p>} {restMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 2 }}>{(restMut.error as Error).message}</p>}
</div> </div>