Files
TetaRdPG/frontend/src/pages/ShopPage.tsx
Tetardtek 1ffde61f97
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
feat: boutique + zones (égouts, désert) + 10 monstres + 14 items + potions
Shop module: GET /api/shop, POST /api/shop/buy/:id, POST /api/shop/sell/:id
Potions: achat instantané, heal 50% HP, pas d'inventaire.
Items: buyPrice + minLevel + zone ajoutés à l'entité.
12 équipements (4 par zone: marais/égouts/désert) + 2 potions.

Monstres: zone field ajouté, 10 nouveaux monstres:
  Égouts (lv4-10): Rat, Slime, Araignée, Crocodile, Roi des Rats
  Désert (lv8-15): Scorpion, Vautour, Momie, Ver des Sables, Sphinx

Frontend: page /shop groupée par zone, rarity colors, achat/vente.
Sidebar: icône ShoppingBag pour la boutique.
2026-03-24 17:46:21 +01:00

164 lines
6.3 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi } from '../api/endpoints';
import { api } from '../api/client';
import { Coins, ShoppingBag, Sword, Shield, Heart } from 'lucide-react';
const RARITY_COLORS: Record<string, string> = {
common: '#9ca3af',
rare: '#5ba4f5',
epic: '#a78bfa',
legendary: '#f4c94e',
};
const TYPE_EMOJI: Record<string, string> = {
weapon: '⚔️',
armor: '🛡️',
consumable: '🧪',
};
const ZONE_LABELS: Record<string, string> = {
marais: '🌿 Marais',
egouts: '🕳️ Égouts',
desert: '🏜️ Désert',
};
interface ShopItem {
id: string;
name: string;
description: string | null;
type: string;
rarity: string;
attackBonus: number;
defenseBonus: number;
buyPrice: number;
minLevel: number;
zone: string | null;
affordable: boolean;
levelOk: boolean;
sellPrice: number;
}
function ShopItemCard({ item, onBuy, buying }: { item: ShopItem; onBuy: () => void; buying: boolean }) {
const canBuy = item.affordable && item.levelOk;
const rarityColor = RARITY_COLORS[item.rarity] ?? '#9ca3af';
return (
<div className="card" style={{ padding: '0.75rem 1rem', opacity: item.levelOk ? 1 : 0.5 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<span style={{ fontSize: 24 }}>{TYPE_EMOJI[item.type] ?? '📦'}</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 700, fontSize: 13, color: rarityColor }}>{item.name}</span>
<span style={{ fontSize: 9, padding: '1px 5px', borderRadius: 4, background: rarityColor + '22', color: rarityColor, textTransform: 'uppercase' }}>
{item.rarity}
</span>
</div>
{item.description && <p style={{ margin: '0 0 4px', fontSize: 11, color: '#6b7a99' }}>{item.description}</p>}
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: '#6b7a99' }}>
{item.attackBonus > 0 && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Sword size={10} color="#f4c94e" /> +{item.attackBonus} ATK</span>}
{item.defenseBonus > 0 && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Shield size={10} color="#5ba4f5" /> +{item.defenseBonus} DEF</span>}
{item.type === 'consumable' && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Heart size={10} color="#e84040" /> +50% PV</span>}
{item.minLevel > 1 && <span>Niv. {item.minLevel}+</span>}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: item.affordable ? '#f4c94e' : '#e84040', display: 'flex', alignItems: 'center', gap: 4, justifyContent: 'flex-end' }}>
<Coins size={12} /> {item.buyPrice}
</div>
<button
className={canBuy ? 'btn btn-gold' : 'btn btn-ghost'}
style={{ marginTop: 4, fontSize: 11, padding: '0.2rem 0.6rem', opacity: canBuy ? 1 : 0.5 }}
disabled={!canBuy || buying}
onClick={onBuy}
>
{buying ? '...' : item.type === 'consumable' ? 'Utiliser' : 'Acheter'}
</button>
{!item.levelOk && <div style={{ fontSize: 9, color: '#e84040', marginTop: 2 }}>Niv. {item.minLevel} requis</div>}
</div>
</div>
</div>
);
}
export function ShopPage() {
const qc = useQueryClient();
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const { data: catalogue, isLoading } = useQuery({
queryKey: ['shop'],
queryFn: () => api.get<ShopItem[]>('/shop'),
});
const buyMut = useMutation({
mutationFn: (itemId: string) => api.post<any>(`/shop/buy/${itemId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['shop'] });
qc.invalidateQueries({ queryKey: ['inventory'] });
},
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
// Group by zone
const zones = new Map<string, ShopItem[]>();
for (const item of (catalogue ?? [])) {
const zone = item.zone ?? 'general';
const list = zones.get(zone) ?? [];
list.push(item);
zones.set(zone, list);
}
// Order: general first, then by zone
const zoneOrder = ['general', 'marais', 'egouts', 'desert'];
const sortedZones = Array.from(zones.entries()).sort((a, b) =>
zoneOrder.indexOf(a[0]) - zoneOrder.indexOf(b[0])
);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, color: '#f4c94e', fontSize: 20 }}>
<ShoppingBag size={18} style={{ display: 'inline', marginRight: 8 }} />Boutique
</h2>
{char && (
<span style={{ fontSize: 14, fontWeight: 700, color: '#f4c94e', display: 'flex', alignItems: 'center', gap: 6 }}>
<Coins size={14} /> {char.gold} or
</span>
)}
</div>
{buyMut.isSuccess && (
<div className="card card-gold" style={{ marginBottom: '1rem', padding: '0.5rem 1rem', fontSize: 13, textAlign: 'center' }}>
{(buyMut.data as any)?.type === 'consumable'
? `🧪 ${(buyMut.data as any)?.item} utilisé ! +${(buyMut.data as any)?.effect?.healed} PV`
: `${(buyMut.data as any)?.item} acheté ! (-${(buyMut.data as any)?.goldSpent} or)`
}
</div>
)}
{buyMut.isError && (
<div style={{ marginBottom: '1rem', color: '#e84040', fontSize: 12 }}>{(buyMut.error as Error).message}</div>
)}
{sortedZones.map(([zone, items]) => (
<div key={zone} style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>
{zone === 'general' ? '🧪 Consommables' : ZONE_LABELS[zone] ?? zone}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{items.map(item => (
<ShopItemCard
key={item.id}
item={item}
onBuy={() => buyMut.mutate(item.id)}
buying={buyMut.isPending}
/>
))}
</div>
</div>
))}
</div>
);
}