fix: RequisitionShop — RP tracking, spending, and GM management panel
- Fetch player RP/renown/gear from GET /api/players/:name (public full data) instead of the name-only player list, so RP is never 0 - Player status bar: RP (amber), renown badge, collapsible gear inventory - Purchase flow posts to /api/shop/purchase and reads newRp from response - Affordable filter, renown filter, item cards show affordability state - GM panel: all-player RP/renown/gear table, Set RP, Set Renown forms Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,345 +1,471 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const RANK_ORDER = ['None', 'Respected', 'Distinguished', 'Famed', 'Hero']
|
||||
|
||||
|
||||
const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero']
|
||||
function normalizeRank(r) {
|
||||
const s = String(r||'').trim()
|
||||
const found = RANK_ORDER.find(x=>x.toLowerCase()===s.toLowerCase())
|
||||
return found || (s? s : 'None')
|
||||
}
|
||||
function renownClass(r) {
|
||||
const rr = normalizeRank(r)
|
||||
if (rr==='Respected') return 'bg-emerald-700'
|
||||
if (rr==='Distinguished') return 'bg-indigo-700'
|
||||
if (rr==='Famed') return 'bg-purple-700'
|
||||
if (rr==='Hero') return 'bg-rose-700'
|
||||
return 'bg-slate-700'
|
||||
function normalizeRenown(r) {
|
||||
const s = String(r || '').trim()
|
||||
return RANK_ORDER.find(x => x.toLowerCase() === s.toLowerCase()) || 'None'
|
||||
}
|
||||
|
||||
// ...existing code...
|
||||
function renownBadge(r) {
|
||||
const rn = normalizeRenown(r)
|
||||
if (rn === 'Respected') return 'bg-emerald-700 text-emerald-100'
|
||||
if (rn === 'Distinguished') return 'bg-indigo-700 text-indigo-100'
|
||||
if (rn === 'Famed') return 'bg-purple-700 text-purple-100'
|
||||
if (rn === 'Hero') return 'bg-rose-700 text-rose-100'
|
||||
return 'bg-slate-700 text-slate-300'
|
||||
}
|
||||
|
||||
function canAfford(playerRp, playerRenown, item) {
|
||||
const rpOk = (playerRp || 0) >= item.req
|
||||
const renownRequired = normalizeRenown(item.renown)
|
||||
const renownOk = renownRequired === 'Any' ||
|
||||
RANK_ORDER.indexOf(normalizeRenown(playerRenown)) >= RANK_ORDER.indexOf(renownRequired)
|
||||
return { rpOk, renownOk, ok: rpOk && renownOk }
|
||||
}
|
||||
|
||||
export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
const isGM = authedPlayer === 'gm'
|
||||
|
||||
// ── Player state ─────────────────────────────────────────────────
|
||||
const [playerRp, setPlayerRp] = useState(0)
|
||||
const [playerRenown, setPlayerRenown] = useState('None')
|
||||
const [playerGear, setPlayerGear] = useState([])
|
||||
const [playerLoaded, setPlayerLoaded] = useState(false)
|
||||
|
||||
// ── Shop state ───────────────────────────────────────────────────
|
||||
const [items, setItems] = useState([])
|
||||
const [players, setPlayers] = useState([]);
|
||||
// authedPlayer and sessionId come from props
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const categories = useMemo(()=>{
|
||||
const set = new Set()
|
||||
for (const it of items) { if (it && it.category) set.add(String(it.category)) }
|
||||
return ['All', ...Array.from(set).sort((a,b)=>String(a).localeCompare(String(b)))]
|
||||
},[items])
|
||||
const [categoryFilter, setCategoryFilter] = useState('All')
|
||||
const [renownFilter, setRenownFilter] = useState('All')
|
||||
const [showAffordableOnly, setShowAffordableOnly] = useState(false)
|
||||
|
||||
// Fetch both players and shop items from the backend
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Fetch players (for admin/GM functionality)
|
||||
console.log('Fetching players with sessionId:', sessionId);
|
||||
try {
|
||||
const playersResponse = await axios.get('/api/players', {
|
||||
headers: { 'x-session-id': sessionId || '' }
|
||||
});
|
||||
console.log('Fetched players:', playersResponse.data);
|
||||
setPlayers(playersResponse.data);
|
||||
} catch (playerError) {
|
||||
console.log('Could not fetch full player data (likely not GM), using basic names:', playerError.message);
|
||||
// Fall back to public player names if full player data is not available
|
||||
try {
|
||||
const playersResponse = await axios.get('/api/players/names');
|
||||
setPlayers(playersResponse.data);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch even basic player names:', e);
|
||||
setPlayers([]);
|
||||
}
|
||||
}
|
||||
// Fetch shop items from the new database (public endpoint, no session needed)
|
||||
console.log('Fetching shop items');
|
||||
const itemsResponse = await axios.get('/api/shop');
|
||||
console.log('Fetched shop items:', itemsResponse.data);
|
||||
// ── GM panel state ───────────────────────────────────────────────
|
||||
const [allPlayers, setAllPlayers] = useState([])
|
||||
const [gmRpTarget, setGmRpTarget] = useState('')
|
||||
const [gmRpPlayer, setGmRpPlayer] = useState('')
|
||||
const [gmRenownPlayer, setGmRenownPlayer] = useState('')
|
||||
const [gmRenownValue, setGmRenownValue] = useState('Respected')
|
||||
|
||||
if (!itemsResponse.data || !itemsResponse.data.items) {
|
||||
console.log('Warning: Shop items response was empty or missing items')
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
// ── UI state ─────────────────────────────────────────────────────
|
||||
const [message, setMessage] = useState('') // success (green)
|
||||
const [error, setError] = useState('') // error (red)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showGear, setShowGear] = useState(false)
|
||||
|
||||
// Flatten the categorized items into a single array
|
||||
const allItems = [];
|
||||
const itemsByCategory = itemsResponse.data.items;
|
||||
|
||||
for (const category in itemsByCategory) {
|
||||
if (Array.isArray(itemsByCategory[category])) {
|
||||
itemsByCategory[category].forEach(item => {
|
||||
// Only include items that have a cost > 0 (purchasable items)
|
||||
const reqCost = item.req || 0;
|
||||
if (reqCost > 0) {
|
||||
allItems.push({
|
||||
id: item.id || `${category}-${item.name}`,
|
||||
name: item.name,
|
||||
category: category,
|
||||
req: reqCost,
|
||||
renown: item.renown || 'Any',
|
||||
stats: item.stats || {},
|
||||
itemType: item.itemType || 'equipment'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setItems(allItems);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
// If shop API fails, we can't load items. Keep players fallback behavior.
|
||||
setPlayers([]);
|
||||
}
|
||||
}
|
||||
// Always fetch shop items; fetch players only if we have a sessionId
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const currentPlayer = useMemo(() => {
|
||||
const p = players.find(p => p.name === authedPlayer);
|
||||
if (!p) return null;
|
||||
|
||||
// Player data is stored in tabInfo structure
|
||||
const tabInfo = p.tabInfo || {};
|
||||
return {
|
||||
...p,
|
||||
id: p.id,
|
||||
requisition_points: Number(tabInfo.rp || 0),
|
||||
renown_level: tabInfo.renown || 'None',
|
||||
// Keep gear from tabInfo for inventory system
|
||||
gear: Array.isArray(tabInfo.gear) ? tabInfo.gear : []
|
||||
};
|
||||
}, [players, authedPlayer]);
|
||||
|
||||
// Helper function to check if an item can be purchased
|
||||
const canPurchaseItem = (item) => {
|
||||
if (!currentPlayer) return false;
|
||||
|
||||
const playerRP = currentPlayer.requisition_points || 0;
|
||||
const playerRenown = currentPlayer.renown_level || 'None';
|
||||
const requiredRenown = item.renown || 'Any';
|
||||
|
||||
const hasEnoughRP = playerRP >= item.req;
|
||||
const hasEnoughRenown = requiredRenown === 'Any' ||
|
||||
RANK_ORDER.indexOf(playerRenown) >= RANK_ORDER.indexOf(requiredRenown);
|
||||
|
||||
return hasEnoughRP && hasEnoughRenown;
|
||||
};
|
||||
|
||||
// Helper function to get purchase button text and styling
|
||||
const getPurchaseButtonInfo = (item) => {
|
||||
if (!currentPlayer) return { text: 'Buy', disabled: true, className: 'px-3 py-1 rounded bg-slate-600 text-sm cursor-not-allowed' };
|
||||
|
||||
const playerRP = currentPlayer.requisition_points || 0;
|
||||
const playerRenown = currentPlayer.renown_level || 'None';
|
||||
const requiredRenown = item.renown || 'Any';
|
||||
|
||||
const hasEnoughRP = playerRP >= item.req;
|
||||
const hasEnoughRenown = requiredRenown === 'Any' ||
|
||||
RANK_ORDER.indexOf(playerRenown) >= RANK_ORDER.indexOf(requiredRenown);
|
||||
|
||||
if (!hasEnoughRP && !hasEnoughRenown) {
|
||||
return { text: 'Need RP & Renown', disabled: true, className: 'px-3 py-1 rounded bg-red-600 text-xs cursor-not-allowed' };
|
||||
} else if (!hasEnoughRP) {
|
||||
return { text: 'Need RP', disabled: true, className: 'px-3 py-1 rounded bg-red-600 text-xs cursor-not-allowed' };
|
||||
} else if (!hasEnoughRenown) {
|
||||
return { text: 'Need Renown', disabled: true, className: 'px-3 py-1 rounded bg-orange-600 text-xs cursor-not-allowed' };
|
||||
} else {
|
||||
return { text: 'Buy', disabled: false, className: 'px-3 py-1 rounded bg-blue-600 hover:bg-blue-500 text-sm' };
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = useMemo(()=>{
|
||||
const q = search.trim().toLowerCase()
|
||||
return items.filter(i => {
|
||||
const inQuery = !q || i.name.toLowerCase().includes(q) || (i.category||'').toLowerCase().includes(q)
|
||||
const inCat = categoryFilter==='All' || String(i.category||'').toLowerCase()===String(categoryFilter).toLowerCase()
|
||||
return inQuery && inCat
|
||||
})
|
||||
},[items, search, categoryFilter])
|
||||
|
||||
// Updated purchaseItem to use the new database transaction system
|
||||
async function purchaseItem(item) {
|
||||
if (!currentPlayer) {
|
||||
setErrorMsg('No player logged in');
|
||||
return;
|
||||
}
|
||||
if (!sessionId) {
|
||||
setErrorMsg('Session expired. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player has enough RP
|
||||
const currentRp = currentPlayer.requisition_points || 0;
|
||||
if (currentRp < item.req) {
|
||||
setErrorMsg(`Not enough Requisition Points. Need ${item.req} RP but only have ${currentRp} RP.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player meets renown requirement
|
||||
const playerRenown = currentPlayer.renown_level || 'None';
|
||||
const requiredRenown = item.renown;
|
||||
const playerRenownIndex = RANK_ORDER.indexOf(playerRenown);
|
||||
const requiredRenownIndex = RANK_ORDER.indexOf(requiredRenown);
|
||||
|
||||
if (playerRenownIndex < requiredRenownIndex) {
|
||||
setErrorMsg(`This item requires ${requiredRenown} renown level. Your current renown is ${playerRenown}.`);
|
||||
return;
|
||||
}
|
||||
// ─── Flash helpers ───────────────────────────────────────────────
|
||||
function flashMsg(msg) { setMessage(msg); setError(''); setTimeout(() => setMessage(''), 3500) }
|
||||
function flashErr(msg) { setError(msg); setMessage(''); setTimeout(() => setError(''), 4000) }
|
||||
|
||||
// ─── Load current player ─────────────────────────────────────────
|
||||
const loadPlayer = useCallback(async () => {
|
||||
if (!authedPlayer || isGM) return
|
||||
try {
|
||||
console.log('Purchasing item:', item);
|
||||
|
||||
// Use the new shop purchase endpoint
|
||||
await axios.post(
|
||||
'/api/shop/purchase',
|
||||
{
|
||||
playerId: currentPlayer.name, // Use player name instead of id
|
||||
itemId: item.id,
|
||||
quantity: 1
|
||||
},
|
||||
{ headers: { 'x-session-id': sessionId } }
|
||||
);
|
||||
const res = await axios.get(`/api/players/${authedPlayer}`)
|
||||
const tab = res.data?.tabInfo || {}
|
||||
setPlayerRp(Number(tab.rp) || 0)
|
||||
setPlayerRenown(tab.renown || 'None')
|
||||
setPlayerGear(Array.isArray(tab.gear) ? tab.gear : [])
|
||||
setPlayerLoaded(true)
|
||||
} catch (e) {
|
||||
setPlayerLoaded(true)
|
||||
}
|
||||
}, [authedPlayer, isGM])
|
||||
|
||||
// Refetch players to get updated RP and inventory
|
||||
const response = await axios.get('/api/players', {
|
||||
headers: { 'x-session-id': sessionId }
|
||||
});
|
||||
setPlayers(response.data);
|
||||
|
||||
setErrorMsg(`Successfully purchased ${item.name} for ${item.req} RP`);
|
||||
} catch (error) {
|
||||
console.error('Failed to purchase item:', error);
|
||||
setErrorMsg(error.response?.data?.error || 'Failed to make purchase');
|
||||
// ─── Load all players (GM) ───────────────────────────────────────
|
||||
const loadAllPlayers = useCallback(async () => {
|
||||
if (!isGM) return
|
||||
try {
|
||||
const res = await axios.get('/api/players', {
|
||||
headers: { 'x-session-id': sessionId || '' }
|
||||
})
|
||||
setAllPlayers(Array.isArray(res.data) ? res.data.filter(p => p.name !== 'gm') : [])
|
||||
} catch {}
|
||||
}, [isGM, sessionId])
|
||||
|
||||
// ─── Load shop items ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
axios.get('/api/shop').then(res => {
|
||||
const byCategory = res.data?.items || {}
|
||||
const flat = []
|
||||
for (const cat in byCategory) {
|
||||
if (!Array.isArray(byCategory[cat])) continue
|
||||
for (const item of byCategory[cat]) {
|
||||
if ((item.req || 0) > 0) {
|
||||
flat.push({
|
||||
id: `${cat}-${item.name}`,
|
||||
name: item.name,
|
||||
category: cat,
|
||||
req: item.req,
|
||||
renown: item.renown || 'Any',
|
||||
stats: item.stats || {},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setItems(flat)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadPlayer() }, [loadPlayer])
|
||||
useEffect(() => { loadAllPlayers() }, [loadAllPlayers])
|
||||
|
||||
// ─── Purchase ────────────────────────────────────────────────────
|
||||
async function purchaseItem(item) {
|
||||
if (!authedPlayer || isGM) return
|
||||
if (!sessionId) { flashErr('Session expired — please log in again.'); return }
|
||||
|
||||
const { rpOk, renownOk } = canAfford(playerRp, playerRenown, item)
|
||||
if (!rpOk) { flashErr(`Not enough RP. Need ${item.req}, have ${playerRp}.`); return }
|
||||
if (!renownOk) { flashErr(`Requires ${item.renown} renown. You are ${playerRenown}.`); return }
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await axios.post('/api/shop/purchase',
|
||||
{ playerId: authedPlayer, itemId: item.id, quantity: 1 },
|
||||
{ headers: { 'x-session-id': sessionId } }
|
||||
)
|
||||
// Optimistic update from server response, then re-fetch for accuracy
|
||||
if (typeof res.data?.newRp === 'number') setPlayerRp(res.data.newRp)
|
||||
flashMsg(`Purchased ${item.name} for ${item.req} RP`)
|
||||
await loadPlayer()
|
||||
} catch (e) {
|
||||
flashErr(e.response?.data?.error || 'Purchase failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Display saveMsg in the UI
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMsg) {
|
||||
const timer = setTimeout(() => setErrorMsg(''), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
// ─── GM: set RP ─────────────────────────────────────────────────
|
||||
async function gmSetRp(playerName, rp) {
|
||||
if (!isGM) return
|
||||
try {
|
||||
await axios.post('/api/players/gm/set-rp',
|
||||
{ playerName, requisitionPoints: Number(rp) },
|
||||
{ headers: { 'x-gm-secret': process.env.REACT_APP_GM_SECRET || '', 'x-session-id': sessionId } }
|
||||
)
|
||||
flashMsg(`Set ${playerName} to ${rp} RP`)
|
||||
await loadAllPlayers()
|
||||
} catch (e) {
|
||||
flashErr(e.response?.data?.error || 'Failed to set RP')
|
||||
}
|
||||
}, [errorMsg]);
|
||||
}
|
||||
|
||||
// ─── GM: set renown ──────────────────────────────────────────────
|
||||
async function gmSetRenown(playerName, renown) {
|
||||
if (!isGM) return
|
||||
try {
|
||||
await axios.post('/api/players/gm/set-renown',
|
||||
{ playerName, renown },
|
||||
{ headers: { 'x-gm-secret': process.env.REACT_APP_GM_SECRET || '', 'x-session-id': sessionId } }
|
||||
)
|
||||
flashMsg(`Set ${playerName} renown to ${renown}`)
|
||||
await loadAllPlayers()
|
||||
} catch (e) {
|
||||
flashErr(e.response?.data?.error || 'Failed to set renown')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Derived ─────────────────────────────────────────────────────
|
||||
const categories = useMemo(() => {
|
||||
const s = new Set(items.map(i => i.category))
|
||||
return ['All', ...Array.from(s).sort()]
|
||||
}, [items])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return items.filter(item => {
|
||||
if (q && !item.name.toLowerCase().includes(q) && !item.category.toLowerCase().includes(q)) return false
|
||||
if (categoryFilter !== 'All' && item.category !== categoryFilter) return false
|
||||
if (renownFilter !== 'All' && normalizeRenown(item.renown) !== renownFilter) return false
|
||||
if (showAffordableOnly && !isGM) {
|
||||
const { ok } = canAfford(playerRp, playerRenown, item)
|
||||
if (!ok) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [items, search, categoryFilter, renownFilter, showAffordableOnly, playerRp, playerRenown, isGM])
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────
|
||||
if (!authedPlayer) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-slate-800 border border-slate-700 p-6">
|
||||
<h2 className="text-2xl font-bold mb-3">Requisition Shop</h2>
|
||||
<p className="text-amber-300">Log in to access the shop.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-slate-800 border border-slate-700 p-5 shadow-xl space-y-4">
|
||||
<div className="text-2xl font-bold">Requisition Shop</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="p-3 rounded-lg bg-red-800 border border-red-600">
|
||||
<p className="text-red-300">{errorMsg}</p>
|
||||
<h2 className="text-2xl font-bold">Requisition Shop</h2>
|
||||
|
||||
{message && (
|
||||
<div className="rounded-lg border border-emerald-500/40 bg-emerald-950/30 px-4 py-2 text-sm text-emerald-200">{message}</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-950/30 px-4 py-2 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
|
||||
{/* ── Player status bar ── */}
|
||||
{!isGM && playerLoaded && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-blue-500/30 bg-blue-950/20 px-4 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-blue-300">Battle-Brother</div>
|
||||
<div className="text-lg font-bold capitalize">{authedPlayer}</div>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-blue-800/60" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-blue-300">Requisition Points</div>
|
||||
<div className="text-2xl font-bold text-amber-300 tabular-nums">{playerRp} RP</div>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-blue-800/60" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-blue-300">Renown</div>
|
||||
<span className={`mt-0.5 inline-block rounded px-2 py-0.5 text-xs font-semibold ${renownBadge(playerRenown)}`}>
|
||||
{playerRenown}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowGear(v => !v)}
|
||||
className="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
|
||||
>
|
||||
{showGear ? 'Hide Gear' : `Gear (${playerGear.length})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!authedPlayer ? (
|
||||
<div className="p-4 rounded-lg bg-amber-800 border border-amber-600">
|
||||
<p className="text-amber-300">Please log in using the header above to access the shop.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Player Info */}
|
||||
<div className="p-3 rounded-lg bg-blue-800 border border-blue-600">
|
||||
<div className="text-lg font-semibold text-blue-300">
|
||||
Current Player: {authedPlayer}
|
||||
</div>
|
||||
{currentPlayer && (
|
||||
<div className="text-sm text-blue-200">
|
||||
Requisition Points: {currentPlayer.requisition_points || 0} | Renown: {currentPlayer.renown_level || 'None'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input
|
||||
className="rounded-xl border border-slate-600 bg-slate-800 px-3 py-2"
|
||||
placeholder="Search items..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="rounded-xl border border-slate-600 bg-slate-800 px-3 py-2"
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
{/* ── Gear inventory ── */}
|
||||
{!isGM && showGear && (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-900/60 p-4">
|
||||
<div className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-300">Requisitioned Gear</div>
|
||||
{playerGear.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No items requisitioned yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{playerGear.map((g, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded border border-slate-700 bg-slate-800 px-3 py-2 text-sm">
|
||||
<span className="font-medium text-slate-100">{g.name}</span>
|
||||
{g.qty > 1 && <span className="rounded bg-slate-700 px-1.5 py-0.5 text-xs text-slate-300">×{g.qty}</span>}
|
||||
</div>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── GM panel: manage all players ── */}
|
||||
{isGM && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-950/10 p-4 space-y-4">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-amber-300">GM — Manage RP & Renown</div>
|
||||
|
||||
{/* Player RP table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700 text-left text-xs uppercase tracking-wide text-slate-400">
|
||||
<th className="pb-2 pr-4">Player</th>
|
||||
<th className="pb-2 pr-4">RP</th>
|
||||
<th className="pb-2 pr-4">Renown</th>
|
||||
<th className="pb-2">Gear</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allPlayers.map(p => {
|
||||
const tab = p.tabInfo || {}
|
||||
return (
|
||||
<tr key={p.name} className="border-b border-slate-800">
|
||||
<td className="py-2 pr-4 font-medium capitalize">{p.name}</td>
|
||||
<td className="py-2 pr-4 text-amber-300 font-bold tabular-nums">{tab.rp ?? 0}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs ${renownBadge(tab.renown)}`}>{tab.renown || 'None'}</span>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-slate-400">
|
||||
{Array.isArray(tab.gear) && tab.gear.length > 0
|
||||
? tab.gear.map(g => g.name).join(', ')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Items Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredItems.map(item => (
|
||||
<div key={item.id} className="p-3 rounded-lg bg-slate-800 border border-slate-700">
|
||||
<div className="font-semibold text-white">{item.name}</div>
|
||||
<div className="text-xs text-slate-300 mb-2">{item.category}</div>
|
||||
|
||||
{/* Display item stats */}
|
||||
{item.stats && Object.keys(item.stats).length > 0 && (
|
||||
<div className="text-sm text-slate-200 mb-3 space-y-1">
|
||||
{item.stats.damage && (
|
||||
<div className="text-xs"><span className="text-slate-400">Damage:</span> {item.stats.damage}</div>
|
||||
)}
|
||||
{item.stats.class && (
|
||||
<div className="text-xs"><span className="text-slate-400">Class:</span> {item.stats.class}</div>
|
||||
)}
|
||||
{item.stats.type && (
|
||||
<div className="text-xs"><span className="text-slate-400">Type:</span> {item.stats.type}</div>
|
||||
)}
|
||||
{item.stats.protection && (
|
||||
<div className="text-xs">
|
||||
<span className="text-slate-400">Protection:</span>
|
||||
{' '}Head: {item.stats.protection.head},
|
||||
Arms: {item.stats.protection.arms},
|
||||
Body: {item.stats.protection.body},
|
||||
Legs: {item.stats.protection.legs}
|
||||
</div>
|
||||
)}
|
||||
{item.stats.source && (
|
||||
<div className="text-xs text-slate-500">Source: {item.stats.source}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-xs text-slate-400">Cost: {item.req} RP</div>
|
||||
<div className={`text-xs px-2 py-1 rounded ${renownClass(item.renown)}`}>
|
||||
{/* Set RP form */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-slate-400">Set RP</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={gmRpPlayer}
|
||||
onChange={e => setGmRpPlayer(e.target.value)}
|
||||
className="flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">Select player</option>
|
||||
{allPlayers.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="999"
|
||||
value={gmRpTarget}
|
||||
onChange={e => setGmRpTarget(e.target.value)}
|
||||
placeholder="RP"
|
||||
className="w-20 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { if (gmRpPlayer && gmRpTarget !== '') gmSetRp(gmRpPlayer, gmRpTarget) }}
|
||||
disabled={!gmRpPlayer || gmRpTarget === ''}
|
||||
className="rounded bg-amber-700 px-3 py-1.5 text-sm hover:bg-amber-600 disabled:opacity-40"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-slate-400">Set Renown</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={gmRenownPlayer}
|
||||
onChange={e => setGmRenownPlayer(e.target.value)}
|
||||
className="flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">Select player</option>
|
||||
{allPlayers.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={gmRenownValue}
|
||||
onChange={e => setGmRenownValue(e.target.value)}
|
||||
className="rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{RANK_ORDER.filter(r => r !== 'None').map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => { if (gmRenownPlayer) gmSetRenown(gmRenownPlayer, gmRenownValue) }}
|
||||
disabled={!gmRenownPlayer}
|
||||
className="rounded bg-indigo-700 px-3 py-1.5 text-sm hover:bg-indigo-600 disabled:opacity-40"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filters ── */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="flex-1 min-w-40 rounded border border-slate-600 bg-slate-800 px-3 py-2 text-sm"
|
||||
placeholder="Search items..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="rounded border border-slate-600 bg-slate-800 px-3 py-2 text-sm"
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
{categories.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="rounded border border-slate-600 bg-slate-800 px-3 py-2 text-sm"
|
||||
value={renownFilter}
|
||||
onChange={e => setRenownFilter(e.target.value)}
|
||||
>
|
||||
<option value="All">All Renown</option>
|
||||
{RANK_ORDER.filter(r => r !== 'None').concat(['Any']).map(r => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
{!isGM && (
|
||||
<label className="flex items-center gap-2 rounded border border-slate-600 bg-slate-800 px-3 py-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAffordableOnly}
|
||||
onChange={e => setShowAffordableOnly(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Affordable only
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">{filteredItems.length} items</div>
|
||||
|
||||
{/* ── Items grid ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredItems.map(item => {
|
||||
const { rpOk, renownOk, ok } = canAfford(playerRp, playerRenown, item)
|
||||
const btnClass = isGM
|
||||
? 'rounded bg-slate-700 px-3 py-1.5 text-xs text-slate-300 cursor-default'
|
||||
: ok
|
||||
? 'rounded bg-blue-600 px-3 py-1.5 text-sm hover:bg-blue-500 disabled:opacity-50'
|
||||
: 'rounded bg-slate-700 px-3 py-1.5 text-xs text-slate-400 cursor-not-allowed'
|
||||
|
||||
let btnLabel = 'Buy'
|
||||
if (isGM) btnLabel = `${item.req} RP`
|
||||
else if (!rpOk && !renownOk) btnLabel = 'Need RP + Renown'
|
||||
else if (!rpOk) btnLabel = `Need ${item.req} RP`
|
||||
else if (!renownOk) btnLabel = `Need ${item.renown}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`rounded-lg border p-3 flex flex-col gap-2 ${
|
||||
!isGM && ok ? 'border-blue-700/40 bg-slate-800' : 'border-slate-700 bg-slate-800/70'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-white leading-tight">{item.name}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">{item.category}</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{item.stats?.damage && (
|
||||
<div className="text-xs text-slate-300 leading-relaxed">{item.stats.damage}</div>
|
||||
)}
|
||||
{item.stats?.class && (
|
||||
<div className="text-xs text-slate-400">{item.stats.class}</div>
|
||||
)}
|
||||
{item.stats?.source && (
|
||||
<div className="text-xs text-slate-600">{item.stats.source}</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-amber-300">{item.req} RP</span>
|
||||
{normalizeRenown(item.renown) !== 'None' && item.renown !== 'Any' && (
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs ${renownBadge(item.renown)}`}>
|
||||
{item.renown}
|
||||
</div>
|
||||
</div>
|
||||
{currentPlayer && (
|
||||
<button
|
||||
onClick={() => purchaseItem(item)}
|
||||
disabled={getPurchaseButtonInfo(item).disabled}
|
||||
className={getPurchaseButtonInfo(item).className}
|
||||
>
|
||||
{getPurchaseButtonInfo(item).text}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isGM && (
|
||||
<button
|
||||
onClick={() => purchaseItem(item)}
|
||||
disabled={!ok || saving}
|
||||
className={btnClass}
|
||||
>
|
||||
{btnLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
</>
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="py-8 text-center text-slate-500">
|
||||
{showAffordableOnly ? 'No items you can currently afford.' : 'No items match your filters.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user