feat: stat distribution UI + rest button + xpToNextLevel from backend
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s

Dashboard: stat distributor with +/- buttons when statPoints > 0,
rest button (+50% HP, -20 endurance) when HP < max,
XP bar uses xpToNextLevel from backend instead of local formula.
API: distributeStats + rest endpoints added to client.
This commit is contained in:
2026-03-24 16:09:55 +01:00
parent 214045c7ce
commit 93b34b1f7b
2 changed files with 95 additions and 4 deletions

View File

@@ -17,6 +17,9 @@ export const characterApi = {
create: (name: string, stats: Record<string, number>) => create: (name: string, stats: Record<string, number>) =>
api.post<Character>('/characters', { name, ...stats }), api.post<Character>('/characters', { name, ...stats }),
me: () => api.get<Character>('/characters/me'), me: () => api.get<Character>('/characters/me'),
distributeStats: (stats: Record<string, number>) =>
api.post<Character>('/characters/stats', stats),
rest: () => api.post<{ hpBefore: number; hpAfter: number; hpMax: number; healed: number }>('/characters/rest'),
}; };
// Combat // Combat

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi } from '../api/endpoints'; import { characterApi } from '../api/endpoints';
import { Bar } from '../components/Bar'; import { Bar } from '../components/Bar';
import { Zap, Heart, Star, Coins, Sword, Shield } from 'lucide-react'; import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react';
const STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const; const STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const;
const STAT_LABELS: Record<string, string> = { const STAT_LABELS: Record<string, string> = {
@@ -73,17 +73,86 @@ function CreateCharacter() {
); );
} }
function StatDistributor({ char }: { char: any }) {
const qc = useQueryClient();
const [pts, setPts] = useState<Record<string, number>>({ force: 0, agilite: 0, intelligence: 0, chance: 0, vitalite: 0 });
const used = Object.values(pts).reduce((a, b) => a + b, 0);
const remaining = (char.statPoints ?? 0) - used;
const mut = useMutation({
mutationFn: () => characterApi.distributeStats(pts),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['character'] });
setPts({ force: 0, agilite: 0, intelligence: 0, chance: 0, vitalite: 0 });
},
});
const adjust = (stat: string, delta: number) => {
const next = (pts[stat] ?? 0) + delta;
if (next < 0) return;
if (delta > 0 && remaining <= 0) return;
setPts(p => ({ ...p, [stat]: next }));
};
return (
<div className="card card-gold">
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#f4c94e' }}>
Répartir {char.statPoints} point{char.statPoints > 1 ? 's' : ''} de stats
</p>
<p style={{ margin: '0 0 0.75rem', fontSize: 11, color: '#6b7a99' }}>
{remaining > 0 ? `${remaining} restant${remaining > 1 ? 's' : ''}` : 'Prêt à valider'}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: '0.75rem' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 12, color: '#dce4f0', width: 100 }}>
{STAT_LABELS[s]} <span style={{ color: '#6b7a99' }}>({char[s]})</span>
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button className="btn btn-ghost" style={{ padding: '0.1rem 0.4rem', fontSize: 13 }} onClick={() => adjust(s, -1)} disabled={pts[s] <= 0}></button>
<span style={{ width: 20, textAlign: 'center', fontWeight: 700, color: pts[s] > 0 ? '#3ddc84' : '#6b7a99', fontSize: 13 }}>
{pts[s] > 0 ? `+${pts[s]}` : '0'}
</span>
<button className="btn btn-ghost" style={{ padding: '0.1rem 0.4rem', fontSize: 13 }} onClick={() => adjust(s, +1)} disabled={remaining <= 0}>+</button>
</div>
</div>
))}
</div>
<button
className="btn btn-gold"
style={{ width: '100%', fontSize: 13 }}
disabled={used === 0 || mut.isPending}
onClick={() => mut.mutate()}
>
{mut.isPending ? 'Application…' : `Valider (+${used} pts)`}
</button>
{mut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 6 }}>{(mut.error as Error).message}</p>}
</div>
);
}
export function DashboardPage() { export function DashboardPage() {
const qc = useQueryClient();
const { data: char, isLoading, isError } = useQuery({ const { data: char, isLoading, isError } = useQuery({
queryKey: ['character'], queryKey: ['character'],
queryFn: characterApi.me, queryFn: characterApi.me,
retry: 1, retry: 1,
}); });
const restMut = useMutation({
mutationFn: () => characterApi.rest(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>; if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
if (isError || !char) return <CreateCharacter />; if (isError || !char) return <CreateCharacter />;
const xpNext = Math.round(100 * Math.pow(char.level, 1.5)); const xpNext = (char as any).xpToNextLevel ?? Math.round(100 * Math.pow(char.level, 1.5));
const statPoints = (char as any).statPoints ?? 0;
const needsHeal = char.hpCurrent < char.hpMax;
return ( return (
<div> <div>
@@ -102,8 +171,8 @@ export function DashboardPage() {
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}> <span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP <Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
</span> </span>
{(char as any).statPoints > 0 && ( {statPoints > 0 && (
<span className="badge badge-gold">+{(char as any).statPoints} pts à répartir</span> <span className="badge badge-gold">+{statPoints} pts à répartir</span>
)} )}
</div> </div>
</div> </div>
@@ -141,6 +210,18 @@ export function DashboardPage() {
</div> </div>
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} /> <Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
</div> </div>
{needsHeal && (
<button
className="btn btn-ghost"
style={{ marginTop: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}
disabled={restMut.isPending}
onClick={() => restMut.mutate()}
>
<BedDouble size={13} />
{restMut.isPending ? 'Repos…' : 'Se reposer (+50% PV, -20 endurance)'}
</button>
)}
{restMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 2 }}>{(restMut.error as Error).message}</p>}
</div> </div>
</div> </div>
@@ -161,6 +242,13 @@ export function DashboardPage() {
</div> </div>
</div> </div>
{/* Distributeur de stats */}
{statPoints > 0 && (
<div style={{ gridColumn: '1 / -1' }}>
<StatDistributor char={char} />
</div>
)}
{/* Équipement résumé */} {/* Équipement résumé */}
<div className="card" style={{ gridColumn: '1 / -1' }}> <div className="card" style={{ gridColumn: '1 / -1' }}>
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p> <p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>