Files
TetaRdPG/frontend/src/pages/DashboardPage.tsx
Tetardtek bf896a797f
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
feat: vente items + stats combat avec équipement + forge visible
Inventaire: bouton Vendre sur items non équipés (40% du prix d'achat).
Stats forge visibles: "+5 ATK (3+2)" montre base + bonus forge.
Dashboard combat: attaque/défense calculés avec arme+armure+forge équipées.
10 side quests Égouts seedées (level 5-7).
2026-03-24 18:58:15 +01:00

310 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi, itemApi } from '../api/endpoints';
import { Bar } from '../components/Bar';
import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react';
const STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const;
const STAT_LABELS: Record<string, string> = {
force: 'Force', agilite: 'Agilité', intelligence: 'Intelligence', chance: 'Chance', vitalite: 'Vitalité',
};
function CreateCharacter() {
const qc = useQueryClient();
const [name, setName] = useState('');
const [pts, setPts] = useState<Record<string, number>>({ force:1, agilite:1, intelligence:1, chance:1, vitalite:1 });
const used = Object.values(pts).reduce((a, b) => a + b, 0) - 5;
const remaining = 5 - used;
const mut = useMutation({
mutationFn: () => characterApi.create(name, pts),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
const adjust = (stat: string, delta: number) => {
const next = (pts[stat] ?? 1) + delta;
if (next < 1 || next > 10) return;
if (delta > 0 && remaining <= 0) return;
setPts(p => ({ ...p, [stat]: next }));
};
return (
<div style={{ maxWidth: 420, margin: '4rem auto' }}>
<div className="card card-gold" style={{ padding: '1.5rem' }}>
<h2 style={{ margin: '0 0 4px', color: '#f4c94e', fontSize: 20 }}>Créer ton personnage</h2>
<p style={{ margin: '0 0 1.25rem', color: '#6b7a99', fontSize: 13 }}>
{remaining > 0 ? `${remaining} point${remaining > 1 ? 's' : ''} à répartir` : 'Tous les points répartis'}
</p>
<input
className="input-rpg"
placeholder="Nom du personnage"
value={name}
onChange={e => setName(e.target.value)}
style={{ marginBottom: '1rem' }}
maxLength={30}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: '1.25rem' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, width: 110, color: '#dce4f0' }}>{STAT_LABELS[s]}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-ghost" style={{ padding: '0.15rem 0.5rem', fontSize: 14 }} onClick={() => adjust(s, -1)}></button>
<span style={{ width: 20, textAlign: 'center', fontWeight: 700, color: '#f4c94e' }}>{pts[s]}</span>
<button className="btn btn-ghost" style={{ padding: '0.15rem 0.5rem', fontSize: 14 }} onClick={() => adjust(s, +1)}>+</button>
</div>
</div>
))}
</div>
<button
className="btn btn-gold"
style={{ width: '100%' }}
disabled={!name.trim() || remaining !== 0 || mut.isPending}
onClick={() => mut.mutate()}
>
{mut.isPending ? 'Création…' : 'Commencer l\'aventure ⚔️'}
</button>
{mut.isError && <p style={{ color: '#e84040', fontSize: 12, marginTop: 8 }}>{(mut.error as Error).message}</p>}
</div>
</div>
);
}
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>
);
}
function CombatStatsPanel({ char }: { char: any }) {
const { data: inventory } = useQuery({ queryKey: ['inventory'], queryFn: itemApi.inventory });
const weapon = inventory?.find((ci: any) => ci.equipped && ci.item.type === 'weapon');
const armor = inventory?.find((ci: any) => ci.equipped && ci.item.type === 'armor');
const weaponATK = weapon ? weapon.item.attackBonus + weapon.forgeLevel * 2 : 0;
const armorDEF = armor ? armor.item.defenseBonus + armor.forgeLevel * 2 : 0;
const baseDmg = 3 + weaponATK + Math.floor(char.force * 1.5);
return (
<div className="card" style={{ gridColumn: '1 / -1' }}>
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Sword size={14} color="#f4c94e" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Attaque : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{baseDmg}</span>
{weapon && <span style={{ fontSize: 10, color: '#6b7a99' }}>({weapon.item.name} {weapon.forgeLevel > 0 ? `+${weapon.forgeLevel}` : ''})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Shield size={14} color="#5ba4f5" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Défense : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{armorDEF}</span>
{armor && <span style={{ fontSize: 10, color: '#6b7a99' }}>({armor.item.name} {armor.forgeLevel > 0 ? `+${armor.forgeLevel}` : ''})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Zap size={14} color="#3ddc84" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Critique : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.2).toFixed(1)}%</span>
</div>
</div>
</div>
);
}
export function DashboardPage() {
const qc = useQueryClient();
const { data: char, isLoading, isError } = useQuery({
queryKey: ['character'],
queryFn: characterApi.me,
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 (isError || !char) return <CreateCharacter />;
const xpNext = char.xpToNextLevel;
const statPoints = char.statPoints ?? 0;
const needsHeal = char.hpCurrent < char.hpMax;
const endurance = char.enduranceCurrent;
const REST_COST = 10;
const COMBAT_COST = 5;
const FORGE_COST = 10;
const canRest = endurance >= REST_COST && needsHeal;
return (
<div>
{/* Header perso */}
<div className="card card-gold" style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 48 }}>🐸</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<h2 style={{ margin: 0, fontSize: 22, color: '#f4c94e' }}>{char.name}</h2>
<span style={{ fontSize: 13, color: '#6b7a99' }}>Niveau {char.level}</span>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Coins size={12} color="#f4c94e" /> {char.gold} or
</span>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
</span>
{statPoints > 0 && (
<span className="badge badge-gold">+{statPoints} pts à répartir</span>
)}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
{/* Barres vitales */}
<div className="card">
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>État</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#e84040', display: 'flex', alignItems: 'center', gap: 4 }}>
<Heart size={11} /> PV
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.hpCurrent} / {char.hpMax}</span>
</div>
<Bar value={char.hpCurrent} max={char.hpMax} type="hp" showValues={false} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#5ba4f5', display: 'flex', alignItems: 'center', gap: 4 }}>
<Zap size={11} /> Endurance
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{endurance} / {char.enduranceMax}</span>
</div>
<Bar value={endurance} max={char.enduranceMax} type="end" showValues={false} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#a78bfa', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={11} /> XP
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.xp} / {xpNext}</span>
</div>
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
</div>
{/* Budget endurance */}
<div style={{ marginTop: 8, padding: '6px 8px', background: '#111620', borderRadius: 6, fontSize: 11, color: '#6b7a99' }}>
<span style={{ fontWeight: 700, color: '#5ba4f5' }}> Budget :</span>
{' '}{Math.floor(endurance / COMBAT_COST)} combats
{' · '}{Math.floor(endurance / FORGE_COST)} forges
{' · '}{Math.floor(endurance / REST_COST)} soins
</div>
{needsHeal && (
<button
className="btn btn-ghost"
style={{ marginTop: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center', opacity: canRest ? 1 : 0.5 }}
disabled={restMut.isPending || !canRest}
onClick={() => restMut.mutate()}
>
<BedDouble size={13} />
{restMut.isPending ? 'Soins…' : `Soins (+50% PV, ${REST_COST}⚡)`}
</button>
)}
{needsHeal && !canRest && endurance < REST_COST && (
<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>}
</div>
</div>
{/* Stats */}
<div className="card">
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Statistiques</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#6b7a99' }}>{STAT_LABELS[s]}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char[s]}</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#e84040', display:'flex', alignItems:'center', gap:3 }}><Heart size={10}/> PV max</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char.hpMax}</span>
</div>
</div>
</div>
{/* Distributeur de stats */}
{statPoints > 0 && (
<div style={{ gridColumn: '1 / -1' }}>
<StatDistributor char={char} />
</div>
)}
{/* Équipement résumé */}
<CombatStatsPanel char={char} />
</div>
</div>
);
}