Files
CaseGambling/client/src/pages/Dashboard.jsx
T
2026-07-25 20:04:34 +02:00

630 lines
20 KiB
React

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 <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<div>
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
{stakedItems.length > 0 && (
<section className="section">
<div className="vault-panel admin-block">
<div className="section-head">
<div>
<h2>Vault</h2>
</div>
<div className="vault-timer" title={`Shared ${vaultTickLabel} tick (${vault?.mode || 'online'})`}>
<span className="muted">Next payment</span>
<strong className={vaultCountdownMs != null && vaultCountdownMs <= 0 ? 'gold' : ''}>
{vaultCountdownMs != null && vaultCountdownMs <= 0
? 'paying…'
: formatVaultCountdown(vaultCountdownMs)}
</strong>
</div>
</div>
<div className="vault-stats">
<span>
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
</span>
<span>
Online / tick{' '}
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
</span>
<span>
Offline / tick{' '}
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
</span>
</div>
<div className="grid vault-grid">
{stakedItems.map((inv) => (
<ItemTile
key={`vault-${inv.id}`}
item={inv.item}
favorite={inv.favorite}
staked
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
</div>
</section>
)}
<section className="section">
<div className="section-head inv-section-head">
<h1>Inventory</h1>
<div className="inv-toolbar-left">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
type="button"
className={sort === 'value' ? 'active' : ''}
onClick={() => {
setSort('value');
saveInventorySort(user.id, 'value');
}}
>
Highest value
</button>
<button
type="button"
className={sort === 'recent' ? 'active' : ''}
onClick={() => {
setSort('recent');
saveInventorySort(user.id, 'recent');
}}
>
Most recent
</button>
</div>
{sorted.length > 0 && (
<span className="muted inv-count">
{sorted.length} item{sorted.length === 1 ? '' : 's'}
</span>
)}
</div>
</div>
{selected.length > 0 && (
<div className="inv-toolbar">
<div className="row-actions">
<button
type="button"
className="btn danger"
onClick={sell}
disabled={busy || !selectedCanSell}
title={
selectedHasFavorite || selectedStaked.length
? 'Favorites and vault items cannot be sold'
: undefined
}
>
Sell
</button>
<span className="muted">
{selected.length} selected ·{' '}
<span className="item-price">
{formatCredits(selectedValue)} cr
</span>
{selectedHasFavorite ? ' · includes favorites' : ''}
</span>
<button
type="button"
className="btn ghost"
onClick={() => setSelected([])}
disabled={busy}
>
Clear
</button>
{!selectedAllFavorite && (
<button
type="button"
className="btn secondary"
onClick={() => setFavorite(true)}
disabled={busy}
>
<span className="btn-ico" aria-hidden="true">
</span>
Favorite
</button>
)}
{selectedItems.some((inv) => inv.favorite) && (
<button
type="button"
className="btn ghost"
onClick={() => setFavorite(false)}
disabled={busy}
>
<span className="btn-ico" aria-hidden="true">
</span>
Unfavorite
</button>
)}
{canStake && (
<button
type="button"
className="btn secondary"
onClick={stakeSelected}
disabled={busy}
>
<span className="btn-ico btn-ico-vault" aria-hidden="true">
V
</span>
Stake ({selectedUnstaked.length})
</button>
)}
{canUnstake && (
<button
type="button"
className="btn secondary"
onClick={unstakeSelected}
disabled={busy}
>
<span className="btn-ico btn-ico-vault" aria-hidden="true">
V
</span>
Unstake ({selectedStaked.length})
</button>
)}
</div>
</div>
)}
<div className="inv-bulk-sell">
{sorted.length > 0 && (
<>
<button
type="button"
className="btn"
disabled={busy || belowThreshold.length === 0}
onClick={sellBelow}
>
Sell below
</button>
<label className="inv-bulk-label">
<input
type="number"
min="0"
step="0.01"
value={maxValue}
disabled={busy || settingsBusy}
onChange={(e) => setMaxValue(e.target.value)}
onBlur={onThresholdBlur}
aria-label="Maximum market value in credits"
/>
<span className="muted">cr</span>
</label>
<span className="muted inv-bulk-preview">
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
<span className="item-price">
{formatCredits(belowValue)} cr
</span>
</span>
</>
)}
{sorted.length === 0 && (
<label className="inv-bulk-label">
Threshold
<input
type="number"
min="0"
step="0.01"
value={maxValue}
disabled={busy || settingsBusy}
onChange={(e) => setMaxValue(e.target.value)}
onBlur={onThresholdBlur}
aria-label="Auto-sell threshold in credits"
/>
<span className="muted">cr</span>
</label>
)}
<div className="inv-auto-sell-box">
<label className="inv-auto-sell-toggle">
<input
type="checkbox"
checked={autoSellEnabled}
disabled={busy || settingsBusy}
onChange={(e) => onAutoSellToggle(e.target.checked)}
/>
<span>Auto-sell</span>
</label>
<span className="inv-auto-sell-at muted">
sell below {formatCredits(creditsToCentsString(maxValue) ?? 0)} cr
</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{sorted.length === 0 ? (
<div className="empty">
{stakedItems.length > 0 ? (
'All items are in the vault.'
) : (
<>
Empty for now {' '}
<Link to="/cases" style={{ color: 'var(--accent)' }}>
open a case
</Link>
.
</>
)}
</div>
) : (
<>
<div className="grid">
{pageItems.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
{totalPages > 1 && (
<div className="inv-pager">
<button
type="button"
className="btn ghost"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="muted">
Page {currentPage} / {totalPages}
</span>
<button
type="button"
className="btn ghost"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
</div>
)}
</>
)}
</section>
</div>
);
}