import { useEffect, useMemo, useState } from 'react'; import { Link, Navigate } from 'react-router-dom'; import { api, formatCredits, creditsToCentsString, centsToCreditsString, compareCents, toCentsBigInt } from '../api'; import { useAuth } from '../AuthContext'; import ItemTile from '../components/ItemTile'; import { onInventoryChanged } from '../inventoryEvents'; import { getSocket, onSocketSessionOk } from '../socket'; import { loadInventorySort, saveInventorySort } from '../userPrefs'; const PAGE_SIZE = 50; function formatVaultCountdown(ms) { if (ms == null || !Number.isFinite(ms)) return '—'; const totalSec = Math.max(0, Math.ceil(ms / 1000)); const m = Math.floor(totalSec / 60); const s = totalSec % 60; return `${m}:${String(s).padStart(2, '0')}`; } export default function Dashboard() { const { user, loading, patchUser } = useAuth(); const [inventory, setInventory] = useState([]); const [vault, setVault] = useState(null); const [vaultMessage, setVaultMessage] = useState(''); const [error, setError] = useState(''); const [sort, setSort] = useState('value'); const [page, setPage] = useState(1); const [selected, setSelected] = useState([]); const [busy, setBusy] = useState(false); const [maxValue, setMaxValue] = useState('10'); const [autoSellEnabled, setAutoSellEnabled] = useState(false); const [settingsBusy, setSettingsBusy] = useState(false); const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!user?.id) return; const saved = loadInventorySort(user.id); if (saved) setSort(saved); }, [user?.id]); useEffect(() => { if (!user) return; setAutoSellEnabled(Boolean(user.autoSellEnabled)); if (user.autoSellThresholdCents != null) { setMaxValue(centsToCreditsString(user.autoSellThresholdCents)); } }, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]); const load = async () => { const inv = await api.inventory(); setInventory(inv.inventory); setVault(inv.vault || null); }; useEffect(() => { if (!user || user.role === 'admin') return; let cancelled = false; (async () => { try { await load(); } catch (err) { if (!cancelled) setError(err.message); } })(); return () => { cancelled = true; }; }, [user]); useEffect(() => { if (!user || user.role === 'admin') return undefined; return onInventoryChanged(() => { load().catch(() => {}); }); }, [user]); useEffect(() => { if (!user || user.role === 'admin') return undefined; const socket = getSocket(); const onYield = (payload) => { if (payload?.user) patchUser(payload.user); else if (payload?.balance != null) patchUser({ balance: payload.balance }); if (payload?.vault) setVault(payload.vault); const credited = toCentsBigInt(payload?.credited); if (credited > 0n) { setVaultMessage(`Vault yield +${formatCredits(payload.credited)} cr`); } load().catch(() => {}); }; const onSession = () => { load().catch(() => {}); }; socket.on('vault:yield', onYield); const offSession = onSocketSessionOk(onSession); return () => { socket.off('vault:yield', onYield); offSession(); }; }, [user, patchUser]); useEffect(() => { if (!(vault?.used > 0)) return undefined; const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, [vault?.used]); useEffect(() => { if (!(vault?.used > 0) || !vault?.nextYieldAt) return undefined; const dueAt = new Date(vault.nextYieldAt).getTime(); const delay = Math.max(500, dueAt - Date.now() + 800); const t = setTimeout(() => { load().catch(() => {}); }, delay); return () => clearTimeout(t); }, [vault?.nextYieldAt, vault?.used]); const vaultCountdownMs = useMemo(() => { if (!(vault?.used > 0) || !vault?.nextYieldAt) return null; return new Date(vault.nextYieldAt).getTime() - now; }, [vault?.used, vault?.nextYieldAt, now]); const vaultTickLabel = useMemo(() => { const ms = vault?.tickMs ?? (vault?.mode === 'offline' ? 600_000 : 300_000); return `${Math.round(ms / 60_000)} min`; }, [vault?.tickMs, vault?.mode]); const sorted = useMemo(() => { const list = inventory.filter((inv) => !inv.staked); if (sort === 'value') { list.sort((a, b) => compareCents( b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue, a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue ) ); } else { list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt)); } return list; }, [inventory, sort]); const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); const currentPage = Math.min(page, totalPages); const pageItems = useMemo(() => { const start = (currentPage - 1) * PAGE_SIZE; return sorted.slice(start, start + PAGE_SIZE); }, [sorted, currentPage]); useEffect(() => { setPage(1); }, [sort]); useEffect(() => { if (page > totalPages) setPage(totalPages); }, [page, totalPages]); const selectedValue = useMemo(() => { let sum = 0n; for (const inv of inventory) { if (!selected.includes(inv.id)) continue; sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents); } return sum.toString(); }, [inventory, selected]); const thresholdCents = useMemo(() => creditsToCentsString(maxValue), [maxValue]); const belowThreshold = useMemo(() => { if (thresholdCents == null) return []; return inventory.filter((inv) => { if (inv.favorite || inv.staked) return false; return compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0; }); }, [inventory, thresholdCents]); const selectedItems = useMemo( () => inventory.filter((inv) => selected.includes(inv.id)), [inventory, selected] ); const stakedItems = useMemo(() => inventory.filter((inv) => inv.staked), [inventory]); const selectedHasFavorite = selectedItems.some((inv) => inv.favorite); const selectedAllFavorite = selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite); const selectedCanSell = selectedItems.some((inv) => !inv.favorite && !inv.staked); const selectedStaked = selectedItems.filter((inv) => inv.staked); const selectedUnstaked = selectedItems.filter((inv) => !inv.staked); const vaultFree = Math.max(0, (vault?.slots ?? 3) - (vault?.used ?? 0)); const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree; const canUnstake = selectedStaked.length > 0; const belowValue = useMemo(() => { let sum = 0n; for (const inv of belowThreshold) { sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents); } return sum.toString(); }, [belowThreshold]); const toggle = (id) => { setSelected((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] ); }; const sellIds = async (ids) => { if (!ids.length) return; setBusy(true); setError(''); try { const data = await api.sellInventory(ids); patchUser({ balance: data.user.balance }); setSelected((prev) => prev.filter((id) => !ids.includes(id))); await load(); } catch (err) { setError(err.message); } finally { setBusy(false); } }; const sell = async () => { const ids = selectedItems .filter((inv) => !inv.favorite && !inv.staked) .map((inv) => inv.id); if (!ids.length) return; await sellIds(ids); }; const stakeSelected = async () => { const ids = selectedUnstaked.map((inv) => inv.id); if (!ids.length) return; setBusy(true); setError(''); setVaultMessage(''); try { const data = await api.vaultStake(ids); if (data.user) patchUser({ balance: data.user.balance }); setSelected([]); setVaultMessage(`Staked ${ids.length} item${ids.length === 1 ? '' : 's'} in vault`); await load(); } catch (err) { setError(err.message); } finally { setBusy(false); } }; const unstakeSelected = async () => { const ids = selectedStaked.map((inv) => inv.id); if (!ids.length) return; setBusy(true); setError(''); setVaultMessage(''); try { const data = await api.vaultUnstake(ids); if (data.user) patchUser({ balance: data.user.balance }); setSelected([]); const yieldPart = data.yieldCredited > 0 ? ` · +${formatCredits(data.yieldCredited)} cr yield` : ''; setVaultMessage( `Unstaked ${ids.length} item${ids.length === 1 ? '' : 's'}${yieldPart}` ); await load(); } catch (err) { setError(err.message); } finally { setBusy(false); } }; const setFavorite = async (favorite) => { if (!selected.length) return; setBusy(true); setError(''); try { await api.setInventoryFavorite(selected, favorite); await load(); } catch (err) { setError(err.message); } finally { setBusy(false); } }; const sellBelow = async () => { const ids = belowThreshold.map((inv) => inv.id); if (!ids.length || thresholdCents == null) return; const favSkipped = inventory.filter( (inv) => inv.favorite && compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0 ).length; const ok = window.confirm( `Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` + (favSkipped ? ` (${favSkipped} favorite item${favSkipped === 1 ? '' : 's'} excluded)` : '') ); if (!ok) return; await sellIds(ids); }; const saveAutoSell = async (enabled, thresholdCredits) => { setSettingsBusy(true); setError(''); try { const data = await api.updateAutoSell({ enabled, thresholdCredits }); patchUser(data.user); } catch (err) { setError(err.message); } finally { setSettingsBusy(false); } }; const onAutoSellToggle = async (enabled) => { setAutoSellEnabled(enabled); if (creditsToCentsString(maxValue) == null) return; await saveAutoSell(enabled, maxValue); }; const onThresholdBlur = async () => { const cents = creditsToCentsString(maxValue); if (cents == null) return; const savedCents = String(user?.autoSellThresholdCents ?? 1000); if (cents === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) { return; } await saveAutoSell(autoSellEnabled, maxValue); }; if (loading) return
Loading…
; if (!user) return