feat: titres sélectionnables + prix revente forge inclus
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s

Dashboard: titre actif affiché "« Champion »" + sélecteur avec tous les
titres débloqués (achievements claimed avec rewardTitle).
Header: titre visible à côté du level.

Revente: prix inclut l'investissement forge (50% des coûts cumulés).
Épée +5 (investissement 1900 or) → revente base + 950 au lieu de base seul.
API client: ajout méthode PUT.
This commit is contained in:
2026-03-24 19:31:02 +01:00
parent da8401dec2
commit 6938eedcda
5 changed files with 72 additions and 2 deletions

View File

@@ -55,5 +55,6 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

View File

@@ -20,6 +20,7 @@ export const characterApi = {
distributeStats: (stats: Record<string, number>) =>
api.post<Character>('/characters/stats', stats),
rest: () => api.post<{ hpBefore: number; hpAfter: number; hpMax: number; healed: number }>('/characters/rest'),
setTitle: (title: string | null) => api.put<any>('/profile/title', { title }),
};
// Combat

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi, itemApi } from '../api/endpoints';
import { api } from '../api/client';
import { Bar } from '../components/Bar';
import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react';
@@ -134,6 +135,58 @@ function StatDistributor({ char }: { char: any }) {
);
}
function TitleSelector({ char }: { char: any }) {
const qc = useQueryClient();
const { data: achievements } = useQuery({
queryKey: ['achievements'],
queryFn: () => api.get<any[]>('/achievements/me'),
});
const titleMut = useMutation({
mutationFn: (title: string | null) => characterApi.setTitle(title),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
// Collect unlocked titles from claimed achievements
const unlockedTitles: string[] = [];
if (achievements) {
for (const a of achievements) {
if (a.claimed && a.rewardTitle) {
unlockedTitles.push(a.rewardTitle);
}
}
}
if (unlockedTitles.length === 0) return null;
return (
<div className="card" style={{ gridColumn: '1 / -1' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>🏅 Titre actif</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
className={`btn ${!char.activeTitle ? 'btn-gold' : 'btn-ghost'}`}
style={{ fontSize: 11, padding: '0.2rem 0.6rem' }}
disabled={titleMut.isPending}
onClick={() => titleMut.mutate(null)}
>
Aucun
</button>
{unlockedTitles.map(t => (
<button
key={t}
className={`btn ${char.activeTitle === t ? 'btn-gold' : 'btn-ghost'}`}
style={{ fontSize: 11, padding: '0.2rem 0.6rem' }}
disabled={titleMut.isPending}
onClick={() => titleMut.mutate(t)}
>
{t}
</button>
))}
</div>
</div>
);
}
function CombatStatsPanel({ char }: { char: any }) {
const { data: inventory } = useQuery({ queryKey: ['inventory'], queryFn: itemApi.inventory });
@@ -204,6 +257,9 @@ export function DashboardPage() {
<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>
{char.activeTitle && (
<span style={{ fontSize: 11, color: '#a78bfa', fontStyle: 'italic' }}>« {char.activeTitle} »</span>
)}
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
@@ -301,6 +357,9 @@ export function DashboardPage() {
</div>
)}
{/* Titres */}
<TitleSelector char={char} />
{/* Équipement résumé */}
<CombatStatsPanel char={char} />
</div>

View File

@@ -16,7 +16,10 @@ function ItemCard({ ci, onEquip, onUnequip, onSell, selling }: {
const forgeBonusDEF = item.type === 'armor' ? ci.forgeLevel * 2 : 0;
const totalATK = item.attackBonus + forgeBonusATK;
const totalDEF = item.defenseBonus + forgeBonusDEF;
const sellPrice = Math.floor((item as any).buyPrice * 0.4) || 0;
const FORGE_COSTS: Record<number, number> = { 1: 50, 2: 100, 3: 250, 4: 500, 5: 1000 };
let forgeInvestment = 0;
for (let i = 1; i <= ci.forgeLevel; i++) forgeInvestment += FORGE_COSTS[i] ?? 0;
const sellPrice = Math.floor(((item as any).buyPrice || 0) * 0.4 + forgeInvestment * 0.5);
const bonuses = [
totalATK > 0 && `+${totalATK} ATK${forgeBonusATK > 0 ? ` (${item.attackBonus}+${forgeBonusATK})` : ''}`,

View File

@@ -153,7 +153,13 @@ export class ShopService {
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
if (charItem.equipped) throw new BadRequestException('Déséquipez l\'item avant de le vendre');
const sellPrice = Math.floor(charItem.item.buyPrice * SELL_RATIO);
// Prix de vente = base + investissement forge (coûts cumulés * 50%)
const FORGE_GOLD_COST: Record<number, number> = { 1: 50, 2: 100, 3: 250, 4: 500, 5: 1000 };
let forgeInvestment = 0;
for (let i = 1; i <= charItem.forgeLevel; i++) {
forgeInvestment += FORGE_GOLD_COST[i] ?? 0;
}
const sellPrice = Math.floor(charItem.item.buyPrice * SELL_RATIO + forgeInvestment * 0.5);
const char = await manager
.getRepository(Character)