diff --git a/src/components/RequisitionShop.jsx b/src/components/RequisitionShop.jsx index b99fd69..2eaf930 100755 --- a/src/components/RequisitionShop.jsx +++ b/src/components/RequisitionShop.jsx @@ -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 ( +
+

Requisition Shop

+

Log in to access the shop.

+
+ ) + } return (
-
Requisition Shop
- - {errorMsg && ( -
-

{errorMsg}

+

Requisition Shop

+ + {message && ( +
{message}
+ )} + {error && ( +
{error}
+ )} + + {/* ── Player status bar ── */} + {!isGM && playerLoaded && ( +
+
+
+
Battle-Brother
+
{authedPlayer}
+
+
+
+
Requisition Points
+
{playerRp} RP
+
+
+
+
Renown
+ + {playerRenown} + +
+
+
)} - {!authedPlayer ? ( -
-

Please log in using the header above to access the shop.

-
- ) : ( - <> - {/* Player Info */} -
-
- Current Player: {authedPlayer} -
- {currentPlayer && ( -
- Requisition Points: {currentPlayer.requisition_points || 0} | Renown: {currentPlayer.renown_level || 'None'} -
- )} -
- - {/* Search and Filter */} -
- setSearch(e.target.value)} - /> - +
+ )} +
+ )} + + {/* ── GM panel: manage all players ── */} + {isGM && ( +
+
GM — Manage RP & Renown
+ + {/* Player RP table */} +
+ + + + + + + + + + + {allPlayers.map(p => { + const tab = p.tabInfo || {} + return ( + + + + + + + ) + })} + +
PlayerRPRenownGear
{p.name}{tab.rp ?? 0} + {tab.renown || 'None'} + + {Array.isArray(tab.gear) && tab.gear.length > 0 + ? tab.gear.map(g => g.name).join(', ') + : '—'} +
- {/* Items Grid */} -
- {filteredItems.map(item => ( -
-
{item.name}
-
{item.category}
- - {/* Display item stats */} - {item.stats && Object.keys(item.stats).length > 0 && ( -
- {item.stats.damage && ( -
Damage: {item.stats.damage}
- )} - {item.stats.class && ( -
Class: {item.stats.class}
- )} - {item.stats.type && ( -
Type: {item.stats.type}
- )} - {item.stats.protection && ( -
- Protection: - {' '}Head: {item.stats.protection.head}, - Arms: {item.stats.protection.arms}, - Body: {item.stats.protection.body}, - Legs: {item.stats.protection.legs} -
- )} - {item.stats.source && ( -
Source: {item.stats.source}
- )} -
- )} - -
-
-
Cost: {item.req} RP
-
+ {/* Set RP form */} +
+
+
Set RP
+
+ + setGmRpTarget(e.target.value)} + placeholder="RP" + className="w-20 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm" + /> + +
+
+
+
Set Renown
+
+ + + +
+
+
+
+ )} + + {/* ── Filters ── */} +
+ setSearch(e.target.value)} + /> + + + {!isGM && ( + + )} +
+ +
{filteredItems.length} items
+ + {/* ── Items grid ── */} +
+ {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 ( +
+
+
{item.name}
+
{item.category}
+
+ + {/* Stats */} + {item.stats?.damage && ( +
{item.stats.damage}
+ )} + {item.stats?.class && ( +
{item.stats.class}
+ )} + {item.stats?.source && ( +
{item.stats.source}
+ )} + +
+
+ {item.req} RP + {normalizeRenown(item.renown) !== 'None' && item.renown !== 'Any' && ( + {item.renown} -
-
- {currentPlayer && ( - + )}
+ {!isGM && ( + + )}
- ))} -
+
+ ) + })} +
- - + {filteredItems.length === 0 && ( +
+ {showAffordableOnly ? 'No items you can currently afford.' : 'No items match your filters.'} +
)}
- ); + ) } - -