update nice visuel
This commit is contained in:
+253
-211
@@ -1,13 +1,22 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
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([]);
|
||||
@@ -21,6 +30,7 @@ export default function Dashboard() {
|
||||
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;
|
||||
@@ -32,7 +42,7 @@ export default function Dashboard() {
|
||||
if (!user) return;
|
||||
setAutoSellEnabled(Boolean(user.autoSellEnabled));
|
||||
if (user.autoSellThresholdCents != null) {
|
||||
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
|
||||
setMaxValue(centsToCreditsString(user.autoSellThresholdCents));
|
||||
}
|
||||
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
|
||||
|
||||
@@ -64,13 +74,64 @@ export default function Dashboard() {
|
||||
});
|
||||
}, [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];
|
||||
const list = inventory.filter((inv) => !inv.staked);
|
||||
if (sort === 'value') {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
|
||||
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
|
||||
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));
|
||||
@@ -93,28 +154,23 @@ export default function Dashboard() {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const selectedValue = useMemo(
|
||||
() =>
|
||||
inventory
|
||||
.filter((inv) => selected.includes(inv.id))
|
||||
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
|
||||
[inventory, selected]
|
||||
);
|
||||
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(() => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return null;
|
||||
return Math.round(credits * 100);
|
||||
}, [maxValue]);
|
||||
const thresholdCents = useMemo(() => creditsToCentsString(maxValue), [maxValue]);
|
||||
|
||||
const belowThreshold = useMemo(() => {
|
||||
if (thresholdCents == null) return [];
|
||||
return inventory.filter(
|
||||
(inv) =>
|
||||
!inv.favorite &&
|
||||
!inv.staked &&
|
||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
);
|
||||
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(
|
||||
@@ -134,14 +190,13 @@ export default function Dashboard() {
|
||||
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
|
||||
const canUnstake = selectedStaked.length > 0;
|
||||
|
||||
const belowValue = useMemo(
|
||||
() =>
|
||||
belowThreshold.reduce(
|
||||
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
|
||||
0
|
||||
),
|
||||
[belowThreshold]
|
||||
);
|
||||
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) =>
|
||||
@@ -217,27 +272,6 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const claimVault = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setVaultMessage('');
|
||||
try {
|
||||
const data = await api.vaultClaim();
|
||||
if (data.user) patchUser({ balance: data.user.balance });
|
||||
if (data.vault) setVault(data.vault);
|
||||
setVaultMessage(
|
||||
data.credited > 0
|
||||
? `Vault yield +${formatCredits(data.credited)} cr`
|
||||
: 'No vault yield ready yet'
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setFavorite = async (favorite) => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
@@ -257,7 +291,8 @@ export default function Dashboard() {
|
||||
if (!ids.length || thresholdCents == null) return;
|
||||
const favSkipped = inventory.filter(
|
||||
(inv) =>
|
||||
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
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?` +
|
||||
@@ -282,19 +317,18 @@ export default function Dashboard() {
|
||||
|
||||
const onAutoSellToggle = async (enabled) => {
|
||||
setAutoSellEnabled(enabled);
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
await saveAutoSell(enabled, credits);
|
||||
if (creditsToCentsString(maxValue) == null) return;
|
||||
await saveAutoSell(enabled, maxValue);
|
||||
};
|
||||
|
||||
const onThresholdBlur = async () => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
const savedCents = user?.autoSellThresholdCents ?? 1000;
|
||||
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
|
||||
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, credits);
|
||||
await saveAutoSell(autoSellEnabled, maxValue);
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
@@ -303,15 +337,56 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Inventory</h1>
|
||||
<p className="muted">Select items to sell at market value.</p>
|
||||
</div>
|
||||
</div>
|
||||
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
||||
|
||||
<div className="inv-toolbar">
|
||||
{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
|
||||
@@ -335,87 +410,103 @@ export default function Dashboard() {
|
||||
Most recent
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length > 0 && (
|
||||
{sorted.length > 0 && (
|
||||
<span className="muted inv-count">
|
||||
{inventory.length} item{inventory.length === 1 ? '' : 's'}
|
||||
{sorted.length} item{sorted.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span className="muted">
|
||||
{selected.length} selected · {formatCredits(selectedValue)} cr
|
||||
{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}
|
||||
>
|
||||
Favorite
|
||||
</button>
|
||||
)}
|
||||
{selectedItems.some((inv) => inv.favorite) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setFavorite(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
Unfavorite
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={sell}
|
||||
disabled={busy || !selectedCanSell}
|
||||
title={
|
||||
selectedHasFavorite || selectedStaked.length
|
||||
? 'Favorites and vault items cannot be sold'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
{canStake && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={stakeSelected}
|
||||
disabled={busy}
|
||||
>
|
||||
Stake ({selectedUnstaked.length})
|
||||
</button>
|
||||
)}
|
||||
{canUnstake && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={unstakeSelected}
|
||||
disabled={busy}
|
||||
>
|
||||
Unstake ({selectedStaked.length})
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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">
|
||||
{inventory.length > 0 && (
|
||||
{sorted.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -440,11 +531,13 @@ export default function Dashboard() {
|
||||
</label>
|
||||
<span className="muted inv-bulk-preview">
|
||||
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
|
||||
{formatCredits(belowValue)} cr
|
||||
<span className="item-price">
|
||||
{formatCredits(belowValue)} cr
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{inventory.length === 0 && (
|
||||
{sorted.length === 0 && (
|
||||
<label className="inv-bulk-label">
|
||||
Threshold
|
||||
<input
|
||||
@@ -471,76 +564,26 @@ export default function Dashboard() {
|
||||
<span>Auto-sell</span>
|
||||
</label>
|
||||
<span className="inv-auto-sell-at muted">
|
||||
at{' '}
|
||||
{formatCredits(
|
||||
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
|
||||
? Math.round(Number(maxValue) * 100)
|
||||
: 0
|
||||
)}{' '}
|
||||
cr
|
||||
sell below {formatCredits(creditsToCentsString(maxValue) ?? 0)} cr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
||||
|
||||
<div className="vault-panel admin-block">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Vault</h2>
|
||||
<p className="muted">
|
||||
Stake up to {vault?.slots ?? 3} items. Online: 10% / 10 min · Offline: 1% / 10 min
|
||||
(scales with wear & rarity).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={claimVault}
|
||||
disabled={busy || !(vault?.used > 0)}
|
||||
>
|
||||
Claim yield
|
||||
</button>
|
||||
</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>
|
||||
{stakedItems.length > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<p className="muted">No items staked — select items below and Stake.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inventory.length === 0 ? (
|
||||
{sorted.length === 0 ? (
|
||||
<div className="empty">
|
||||
Empty for now —{' '}
|
||||
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
||||
open a case
|
||||
</Link>
|
||||
.
|
||||
{stakedItems.length > 0 ? (
|
||||
'All items are in the vault.'
|
||||
) : (
|
||||
<>
|
||||
Empty for now —{' '}
|
||||
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
||||
open a case
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -550,7 +593,6 @@ export default function Dashboard() {
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked={inv.staked}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user