feat: page Achievements + soins renommé + bouton soins en combat
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 = [
|
||||||
@@ -10,6 +10,7 @@ const NAV = [
|
|||||||
{ 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 }) {
|
||||||
|
|||||||
208
frontend/src/pages/AchievementsPage.tsx
Normal file
208
frontend/src/pages/AchievementsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user