All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
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.
164 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|