update
This commit is contained in:
+3
-1
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" sizes="512x512" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 615 KiB |
+25
-7
@@ -1,4 +1,4 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import { useAuth } from './AuthContext';
|
||||
import Admin from './pages/Admin';
|
||||
@@ -8,12 +8,16 @@ import Dashboard from './pages/Dashboard';
|
||||
import Leaderboard from './pages/Leaderboard';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import BetHub from './pages/BetHub';
|
||||
import BattleHub from './pages/BattleHub';
|
||||
import MultiPlayersPage from './pages/MultiPlayersPage';
|
||||
import DuelListPage from './pages/DuelListPage';
|
||||
import DuelRoomPage from './pages/DuelRoomPage';
|
||||
import CoinflipStub from './pages/CoinflipStub';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import PlayerProfilePage from './pages/PlayerProfilePage';
|
||||
import CatalogPage from './pages/CatalogPage';
|
||||
import PrestigePage from './pages/PrestigePage';
|
||||
import AchievementsPage from './pages/AchievementsPage';
|
||||
import ShopPage from './pages/ShopPage';
|
||||
|
||||
function HomeRedirect() {
|
||||
@@ -24,6 +28,11 @@ function HomeRedirect() {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
function BetDuelRedirect() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/battle/1vx/${id}`} replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -35,12 +44,21 @@ export default function App() {
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/cases" element={<CasesPage />} />
|
||||
<Route path="/cases/:id" element={<CasePage />} />
|
||||
<Route path="/bet" element={<BetHub />} />
|
||||
<Route path="/bet/multi" element={<MultiPlayersPage />} />
|
||||
<Route path="/bet/1vx" element={<DuelListPage />} />
|
||||
<Route path="/bet/1vx/:id" element={<DuelRoomPage />} />
|
||||
<Route path="/bet/coinflip" element={<CoinflipStub />} />
|
||||
<Route path="/catalog" element={<CatalogPage />} />
|
||||
<Route path="/prestige" element={<PrestigePage />} />
|
||||
<Route path="/achievements" element={<AchievementsPage />} />
|
||||
<Route path="/battle" element={<BattleHub />} />
|
||||
<Route path="/battle/multi" element={<MultiPlayersPage />} />
|
||||
<Route path="/battle/1vx" element={<DuelListPage />} />
|
||||
<Route path="/battle/1vx/:id" element={<DuelRoomPage />} />
|
||||
<Route path="/battle/coinflip" element={<CoinflipStub />} />
|
||||
<Route path="/bet" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/bet/multi" element={<Navigate to="/battle/multi" replace />} />
|
||||
<Route path="/bet/1vx" element={<Navigate to="/battle/1vx" replace />} />
|
||||
<Route path="/bet/1vx/:id" element={<BetDuelRedirect />} />
|
||||
<Route path="/bet/coinflip" element={<Navigate to="/battle/coinflip" replace />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/player/:username" element={<PlayerProfilePage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
</Route>
|
||||
|
||||
@@ -45,13 +45,23 @@ export function AuthProvider({ children }) {
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const updateBalance = (balance) => {
|
||||
const updateBalance = useCallback((balance) => {
|
||||
setUser((prev) => (prev ? { ...prev, balance } : prev));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const patchUser = (partial) => {
|
||||
setUser((prev) => (prev ? { ...prev, ...partial } : prev));
|
||||
};
|
||||
const patchUser = useCallback((partial) => {
|
||||
setUser((prev) => {
|
||||
if (!prev) return prev;
|
||||
let changed = false;
|
||||
for (const key of Object.keys(partial)) {
|
||||
if (prev[key] !== partial[key]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return changed ? { ...prev, ...partial } : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
|
||||
+65
-2
@@ -32,13 +32,39 @@ export const api = {
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||
cases: () => request('/api/cases'),
|
||||
case: (id) => request(`/api/cases/${id}`),
|
||||
openCase: (id) => request(`/api/cases/${id}/open`, { method: 'POST' }),
|
||||
catalog: () => request('/api/catalog'),
|
||||
prestige: () => request('/api/prestige'),
|
||||
doPrestige: (choice) =>
|
||||
request('/api/prestige', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ choice }),
|
||||
}),
|
||||
openCase: (id, count = 1) =>
|
||||
request(`/api/cases/${id}/open`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ count }),
|
||||
}),
|
||||
inventory: () => request('/api/inventory'),
|
||||
sellInventory: (inventoryItemIds) =>
|
||||
request('/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
updateAutoSell: (body) =>
|
||||
request('/api/inventory/auto-sell', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
applyPendingAutoSell: (inventoryItemIds) =>
|
||||
request('/api/inventory/auto-sell/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
setInventoryFavorite: (inventoryItemIds, favorite) =>
|
||||
request('/api/inventory/favorite', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ inventoryItemIds, favorite }),
|
||||
}),
|
||||
shop: () => request('/api/shop'),
|
||||
shopBuy: (packageId) =>
|
||||
request('/api/shop/buy', {
|
||||
@@ -47,6 +73,15 @@ export const api = {
|
||||
}),
|
||||
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
|
||||
feed: () => request('/api/feed'),
|
||||
feedAnnounce: (inventoryItemIds, caseName) => {
|
||||
const ids = Array.isArray(inventoryItemIds)
|
||||
? inventoryItemIds
|
||||
: [inventoryItemIds];
|
||||
return request('/api/feed/announce', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds: ids, caseName }),
|
||||
});
|
||||
},
|
||||
leaderboard: () => request('/api/leaderboard'),
|
||||
jackpot: () => request('/api/jackpot'),
|
||||
jackpotBet: (inventoryItemIds) =>
|
||||
@@ -72,6 +107,9 @@ export const api = {
|
||||
duelCancel: (id) => request(`/api/duels/${id}/cancel`, { method: 'POST' }),
|
||||
duelStart: (id) => request(`/api/duels/${id}/start`, { method: 'POST' }),
|
||||
profile: () => request('/api/profile'),
|
||||
playerProfile: (username) =>
|
||||
request(`/api/profile/player/${encodeURIComponent(username)}`),
|
||||
achievements: () => request('/api/achievements'),
|
||||
updateProfile: (body) =>
|
||||
request('/api/profile', { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
changePassword: (currentPassword, newPassword) =>
|
||||
@@ -122,9 +160,34 @@ export const api = {
|
||||
};
|
||||
|
||||
export function formatCredits(cents) {
|
||||
return (Number(cents) / 100).toFixed(2);
|
||||
const fixed = (Number(cents) / 100).toFixed(2);
|
||||
const neg = fixed.startsWith('-');
|
||||
const body = neg ? fixed.slice(1) : fixed;
|
||||
const [intPart, dec] = body.split('.');
|
||||
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
|
||||
return `${neg ? '-' : ''}${grouped}.${dec}`;
|
||||
}
|
||||
|
||||
export function formatChance(pct) {
|
||||
return `${Number(pct || 0).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Format seconds as e.g. "2h 15m" or "45m". */
|
||||
export function formatPlayTime(seconds) {
|
||||
const s = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (h <= 0 && m <= 0) return s > 0 ? `${s}s` : '0m';
|
||||
if (h <= 0) return `${m}m`;
|
||||
if (m <= 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export function formatLastConnection(iso) {
|
||||
if (!iso) return 'Never';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,60 +2,142 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
const ITEM_WIDTH = 120;
|
||||
const ITEM_WIDTH_COMPACT = 72;
|
||||
const SPIN_DURATION_MS = 5500;
|
||||
const SPIN_SLOTS = 42;
|
||||
const TAIL_SLOTS = 8;
|
||||
|
||||
function buildTrack(items, winnerId, spins = 42) {
|
||||
const track = [];
|
||||
for (let i = 0; i < spins; i += 1) {
|
||||
track.push(items[i % items.length]);
|
||||
function pickRandom(items, avoidId) {
|
||||
if (!items.length) return null;
|
||||
if (items.length === 1) return items[0];
|
||||
let pick = items[Math.floor(Math.random() * items.length)];
|
||||
for (let i = 0; i < 10 && pick.id === avoidId; i += 1) {
|
||||
pick = items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
function shuffledPool(items) {
|
||||
const pool = [...items];
|
||||
for (let i = pool.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
function buildTrack(items, winnerId, spins = SPIN_SLOTS) {
|
||||
if (!items?.length) return { track: [], winnerIndex: 0 };
|
||||
|
||||
const winner = items.find((i) => i.id === winnerId) || items[0];
|
||||
track.push(winner);
|
||||
// pad a few after so the reel doesn't end abruptly
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
track.push(items[(i + 3) % items.length]);
|
||||
const track = [];
|
||||
let lastId = null;
|
||||
let bag = shuffledPool(items);
|
||||
let bagIndex = 0;
|
||||
|
||||
const nextItem = () => {
|
||||
// Draw from a reshuffled bag so the strip covers the pool, not a fixed cycle
|
||||
if (bagIndex >= bag.length) {
|
||||
bag = shuffledPool(items);
|
||||
bagIndex = 0;
|
||||
if (bag.length > 1 && bag[0].id === lastId) {
|
||||
const swap = 1 + Math.floor(Math.random() * (bag.length - 1));
|
||||
[bag[0], bag[swap]] = [bag[swap], bag[0]];
|
||||
}
|
||||
}
|
||||
let item = bag[bagIndex];
|
||||
bagIndex += 1;
|
||||
if (item.id === lastId && items.length > 1) {
|
||||
item = pickRandom(items, lastId);
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
for (let i = 0; i < spins; i += 1) {
|
||||
const item = nextItem();
|
||||
track.push(item);
|
||||
lastId = item.id;
|
||||
}
|
||||
|
||||
track.push(winner);
|
||||
lastId = winner.id;
|
||||
|
||||
for (let i = 0; i < TAIL_SLOTS; i += 1) {
|
||||
const item = nextItem();
|
||||
track.push(item);
|
||||
lastId = item.id;
|
||||
}
|
||||
|
||||
return { track, winnerIndex: spins };
|
||||
}
|
||||
|
||||
export default function CaseReel({ items, winnerId, spinning, onDone, showOwners = false }) {
|
||||
export default function CaseReel({
|
||||
items,
|
||||
winnerId,
|
||||
spinning,
|
||||
onDone,
|
||||
showOwners = false,
|
||||
compact = false,
|
||||
}) {
|
||||
const trackRef = useRef(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [locked, setLocked] = useState(null); // { track, winnerIndex, key }
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
const itemWidth = compact ? ITEM_WIDTH_COMPACT : ITEM_WIDTH;
|
||||
|
||||
const { track, winnerIndex } = useMemo(() => {
|
||||
if (!items?.length || !winnerId) {
|
||||
return { track: items || [], winnerIndex: 0 };
|
||||
}
|
||||
return buildTrack(items, winnerId);
|
||||
}, [items, winnerId]);
|
||||
const spinKey =
|
||||
winnerId && items?.length ? `${winnerId}:${items.map((i) => i.id).join(',')}` : '';
|
||||
|
||||
const idleTrack = useMemo(() => {
|
||||
if (!items?.length) return [];
|
||||
return shuffledPool(items);
|
||||
}, [items]);
|
||||
|
||||
const track =
|
||||
locked?.key === spinKey && locked.track.length ? locked.track : idleTrack;
|
||||
|
||||
useEffect(() => {
|
||||
if (!spinning || !winnerId || !items?.length) return undefined;
|
||||
if (!spinning) return undefined;
|
||||
if (!items?.length || !winnerId) return undefined;
|
||||
|
||||
const key = `${winnerId}:${items.map((i) => i.id).join(',')}`;
|
||||
const built = buildTrack(items, winnerId);
|
||||
setLocked({ ...built, key });
|
||||
let finished = false;
|
||||
|
||||
const viewportCenter = (trackRef.current?.parentElement?.clientWidth || 600) / 2;
|
||||
const target =
|
||||
winnerIndex * ITEM_WIDTH - viewportCenter + ITEM_WIDTH / 2 + (Math.random() * 40 - 20);
|
||||
built.winnerIndex * itemWidth -
|
||||
viewportCenter +
|
||||
itemWidth / 2 +
|
||||
(Math.random() * 40 - 20);
|
||||
|
||||
// force reflow then animate
|
||||
setOffset(0);
|
||||
const id = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setOffset(target));
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
onDoneRef.current?.();
|
||||
}, SPIN_DURATION_MS + 80);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(id);
|
||||
clearTimeout(timer);
|
||||
finished = true;
|
||||
};
|
||||
}, [spinning, winnerId, winnerIndex, items]);
|
||||
// intentionally only re-run when a new spin starts
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [spinning, winnerId, itemWidth]);
|
||||
|
||||
return (
|
||||
<div className={`reel-wrap${showOwners ? ' reel-wrap-owners' : ''}`} ref={trackRef}>
|
||||
<div
|
||||
className={`reel-wrap${showOwners ? ' reel-wrap-owners' : ''}${compact ? ' reel-wrap-compact' : ''}`}
|
||||
ref={trackRef}
|
||||
>
|
||||
<div className="reel-marker" />
|
||||
<div
|
||||
className="reel-track"
|
||||
@@ -70,7 +152,10 @@ export default function CaseReel({ items, winnerId, spinning, onDone, showOwners
|
||||
<div
|
||||
key={`${item.id}-${idx}`}
|
||||
className="reel-item"
|
||||
style={{ '--rarity': rarityColor(item.rarity) }}
|
||||
style={{
|
||||
'--rarity': rarityColor(item.rarity),
|
||||
...(compact ? { flex: `0 0 ${itemWidth}px`, width: itemWidth } : null),
|
||||
}}
|
||||
>
|
||||
<div className="glyph" style={{ color: rarityColor(item.rarity) }}>
|
||||
{item.imageUrl ? (
|
||||
|
||||
@@ -36,31 +36,57 @@ export default function DropFeed() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.feed();
|
||||
if (!cancelled) setDrops(pruneDrops(data.drops || []));
|
||||
} catch {
|
||||
/* ignore — socket history may fill */
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const onHistory = (list) => {
|
||||
setDrops(pruneDrops(Array.isArray(list) ? list.slice(0, MAX_BUFFER) : []));
|
||||
};
|
||||
const onDrop = (entry) => {
|
||||
setDrops((prev) => pruneDrops([entry, ...prev].slice(0, MAX_BUFFER)));
|
||||
|
||||
const applyList = (list) => {
|
||||
if (cancelled) return;
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const d of list || []) {
|
||||
if (!d?.id || seen.has(d.id)) continue;
|
||||
seen.add(d.id);
|
||||
unique.push(d);
|
||||
}
|
||||
setDrops(pruneDrops(unique.slice(0, MAX_BUFFER)));
|
||||
};
|
||||
|
||||
const onHistory = (list) => applyList(list);
|
||||
const onDrop = (entry) => {
|
||||
if (!entry?.id) return;
|
||||
setDrops((prev) => {
|
||||
if (prev.some((d) => d.id === entry.id)) return prev;
|
||||
// Also skip near-identical batch (same user + item + count) within a few seconds
|
||||
const nearDup = prev.find(
|
||||
(d) =>
|
||||
d.username === entry.username &&
|
||||
d.item?.id === entry.item?.id &&
|
||||
(d.count || 1) === (entry.count || 1) &&
|
||||
(d.openCount || 1) === (entry.openCount || 1) &&
|
||||
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
|
||||
);
|
||||
if (nearDup) return prev;
|
||||
return pruneDrops([entry, ...prev].slice(0, MAX_BUFFER));
|
||||
});
|
||||
};
|
||||
|
||||
// Prefer socket history; HTTP is fallback if socket is slow
|
||||
socket.emit('feed:subscribe');
|
||||
socket.on('feed:history', onHistory);
|
||||
socket.on('feed:drop', onDrop);
|
||||
socket.on('connect', () => socket.emit('feed:subscribe'));
|
||||
|
||||
const t = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
api.feed()
|
||||
.then((data) => {
|
||||
setDrops((prev) => (prev.length ? prev : pruneDrops(data.drops || [])));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 400);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
socket.emit('feed:unsubscribe');
|
||||
socket.off('feed:history', onHistory);
|
||||
socket.off('feed:drop', onDrop);
|
||||
@@ -100,7 +126,13 @@ export default function DropFeed() {
|
||||
)}
|
||||
</div>
|
||||
<div className="drop-feed-meta">
|
||||
<div className="drop-feed-item-name">{d.item?.name}</div>
|
||||
<div className="drop-feed-item-name">
|
||||
{d.item?.name}
|
||||
{d.count > 1 ? <span className="drop-feed-count"> x{d.count}</span> : null}
|
||||
</div>
|
||||
{d.item?.wear ? (
|
||||
<div className="drop-feed-wear muted">{d.item.wear}</div>
|
||||
) : null}
|
||||
<div className="drop-feed-player">
|
||||
{d.avatarUrl ? (
|
||||
<img src={d.avatarUrl} alt="" className="drop-feed-avatar" />
|
||||
@@ -109,11 +141,29 @@ export default function DropFeed() {
|
||||
{(d.username || '?').slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span>{d.username}</span>
|
||||
<span>
|
||||
{d.source === 'jackpot' ? (
|
||||
<>
|
||||
<span className="drop-feed-winner-label">Battle Winner</span>{' '}
|
||||
{d.username}
|
||||
</>
|
||||
) : (
|
||||
d.username
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="muted drop-feed-sub">
|
||||
{d.caseName ? `${d.caseName} · ` : ''}
|
||||
{d.source === 'jackpot'
|
||||
? 'MultiPlayers Case · '
|
||||
: d.caseName
|
||||
? `${d.caseName} · `
|
||||
: ''}
|
||||
{formatCredits(d.item?.marketValue || 0)} cr
|
||||
{d.openCount > 1
|
||||
? d.source === 'jackpot'
|
||||
? ` · ${d.openCount} items`
|
||||
: ` · ${d.openCount}× open`
|
||||
: ''}
|
||||
</div>
|
||||
<div className="muted drop-feed-time">{formatDropTime(d.at)}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
import { formatCredits } from '../api';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
/** Instance sell/bet price, else catalog FT reference */
|
||||
export function itemValueCents(item) {
|
||||
if (!item) return 0;
|
||||
if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) {
|
||||
return Number(item.valueCents);
|
||||
}
|
||||
return Number(item.marketValue) || 0;
|
||||
}
|
||||
|
||||
export function formatFloat(f) {
|
||||
const n = Number(f);
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return n.toFixed(6);
|
||||
}
|
||||
|
||||
export default function ItemTile({
|
||||
item,
|
||||
showValue = true,
|
||||
showWear = true,
|
||||
selected = false,
|
||||
favorite = false,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}) {
|
||||
const color = rarityColor(item.rarity);
|
||||
const clickable = typeof onClick === 'function';
|
||||
const hasImage = Boolean(item.imageUrl);
|
||||
const value = itemValueCents(item);
|
||||
const wear = item.wear;
|
||||
const floatTitle =
|
||||
wear && item.floatValue != null
|
||||
? `${wear} · ${formatFloat(item.floatValue)}${
|
||||
item.paintSeed != null ? ` · seed ${item.paintSeed}` : ''
|
||||
}`
|
||||
: wear || undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`item-tile${selected ? ' selected' : ''}${clickable ? ' selectable' : ''}${
|
||||
disabled ? ' disabled' : ''
|
||||
}`}
|
||||
className={`item-tile${selected ? ' selected' : ''}${favorite ? ' favorite' : ''}${
|
||||
clickable ? ' selectable' : ''
|
||||
}${disabled ? ' disabled' : ''}`}
|
||||
style={{ borderTopColor: color }}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable && !disabled ? 0 : undefined}
|
||||
title={favorite ? `Favorite · ${floatTitle || item.name}` : floatTitle}
|
||||
onKeyDown={
|
||||
clickable && !disabled
|
||||
? (e) => {
|
||||
@@ -33,6 +59,7 @@ export default function ItemTile({
|
||||
}
|
||||
>
|
||||
<div className={`item-visual${hasImage ? ' has-img' : ''}`} style={{ color }}>
|
||||
{favorite ? <span className="item-favorite-badge" aria-label="Favorite">★</span> : null}
|
||||
{hasImage ? (
|
||||
<img src={item.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
@@ -43,8 +70,13 @@ export default function ItemTile({
|
||||
<div className="item-meta" style={{ color }}>
|
||||
{item.rarity}
|
||||
</div>
|
||||
{showWear && wear ? (
|
||||
<div className="item-wear" title={floatTitle}>
|
||||
{wear}
|
||||
</div>
|
||||
) : null}
|
||||
{showValue && (
|
||||
<div className="item-meta">{formatCredits(item.marketValue)} cr</div>
|
||||
<div className="item-meta">{formatCredits(value)} cr</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { formatCredits } from '../api';
|
||||
import { api, formatCredits } from '../api';
|
||||
import DropFeed from './DropFeed';
|
||||
import SessionLimitOverlay from './SessionLimitOverlay';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket, onSessionLimitChange } from '../socket';
|
||||
|
||||
const FEED_KEY = 'caseforge-feed-drop';
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function countWaitingBattles(jackpotRound, duelOpen) {
|
||||
let n = 0;
|
||||
|
||||
// MultiPlayers: only if someone has already bet and round still waits for more players
|
||||
const jpPlayers = jackpotRound?.players?.length ?? 0;
|
||||
const jpDeposits = jackpotRound?.deposits?.length ?? 0;
|
||||
if (
|
||||
jackpotRound?.waitingForPlayers &&
|
||||
jackpotRound.status !== 'spinning' &&
|
||||
(jpPlayers > 0 || jpDeposits > 0)
|
||||
) {
|
||||
n += 1;
|
||||
}
|
||||
|
||||
// 1vX: each open room that already has at least one player with a bet
|
||||
for (const room of duelOpen || []) {
|
||||
if (room.status !== 'open') continue;
|
||||
const hasBet =
|
||||
(room.playerCount ?? room.players?.length ?? 0) > 0 ||
|
||||
(room.deposits?.length ?? 0) > 0;
|
||||
if (hasBet) n += 1;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const [feedOpen, setFeedOpen] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(FEED_KEY) === '1';
|
||||
@@ -15,6 +52,9 @@ export default function Layout() {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [waitingBattles, setWaitingBattles] = useState(0);
|
||||
const [prestigeVisible, setPrestigeVisible] = useState(false);
|
||||
const [sessionLimit, setSessionLimit] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -24,53 +64,176 @@ export default function Layout() {
|
||||
}
|
||||
}, [feedOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') {
|
||||
setPrestigeVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
if ((user.prestigeCount ?? 0) > 0) {
|
||||
setPrestigeVisible(true);
|
||||
}
|
||||
let cancelled = false;
|
||||
const refreshVisibility = async () => {
|
||||
try {
|
||||
const data = await api.prestige();
|
||||
if (!cancelled) setPrestigeVisible(Boolean(data.pageVisible));
|
||||
} catch {
|
||||
/* nav link optional */
|
||||
}
|
||||
};
|
||||
refreshVisibility();
|
||||
const offInv = onInventoryChanged(() => {
|
||||
refreshVisibility();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offInv();
|
||||
};
|
||||
}, [user?.id, user?.role, user?.balance, user?.prestigeCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') {
|
||||
setWaitingBattles(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let jackpotRound = null;
|
||||
let duelOpen = [];
|
||||
let cancelled = false;
|
||||
|
||||
const sync = () => {
|
||||
if (cancelled) return;
|
||||
setWaitingBattles((prev) => {
|
||||
const next = countWaitingBattles(jackpotRound, duelOpen);
|
||||
return prev === next ? prev : next;
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
|
||||
if (cancelled) return;
|
||||
jackpotRound = jp.round;
|
||||
duelOpen = duels.open || [];
|
||||
sync();
|
||||
} catch {
|
||||
/* nav badge is optional */
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const offLimitChange = onSessionLimitChange((payload) => {
|
||||
setSessionLimit(payload);
|
||||
});
|
||||
const onJackpot = (state) => {
|
||||
jackpotRound = state;
|
||||
sync();
|
||||
};
|
||||
const onDuels = (list) => {
|
||||
duelOpen = list.open || [];
|
||||
sync();
|
||||
};
|
||||
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
socket.on('jackpot:state', onJackpot);
|
||||
socket.on('jackpot:spin', onJackpot);
|
||||
socket.on('jackpot:result', onJackpot);
|
||||
socket.on('duels:list', onDuels);
|
||||
socket.on('connect', () => {
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offLimitChange();
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.emit('duels:unsubscribe');
|
||||
socket.off('jackpot:state', onJackpot);
|
||||
socket.off('jackpot:spin', onJackpot);
|
||||
socket.off('jackpot:result', onJackpot);
|
||||
socket.off('duels:list', onDuels);
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
const isPlayer = user && user.role !== 'admin';
|
||||
|
||||
return (
|
||||
<div className={`app-shell${feedOpen ? ' feed-open' : ''}`}>
|
||||
<div
|
||||
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
|
||||
>
|
||||
<ScrollToTop />
|
||||
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 10} />}
|
||||
<header className="topnav">
|
||||
<NavLink to="/dashboard" className="brand">
|
||||
CaseForge
|
||||
</NavLink>
|
||||
<div className="topnav-left">
|
||||
<NavLink to="/dashboard" className="brand">
|
||||
CaseForge
|
||||
</NavLink>
|
||||
{isPlayer && (
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-toggle${feedOpen ? ' active' : ''}`}
|
||||
onClick={() => setFeedOpen((v) => !v)}
|
||||
aria-pressed={feedOpen}
|
||||
>
|
||||
Feed Drop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className="nav-links">
|
||||
{user && user.role !== 'admin' && (
|
||||
{isPlayer && (
|
||||
<>
|
||||
<NavLink to="/dashboard">Inventory</NavLink>
|
||||
<NavLink to="/cases">Cases</NavLink>
|
||||
<NavLink to="/bet">Bet Item</NavLink>
|
||||
<NavLink to="/shop">Shop</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-toggle${feedOpen ? ' active' : ''}`}
|
||||
onClick={() => setFeedOpen((v) => !v)}
|
||||
aria-pressed={feedOpen}
|
||||
<NavLink to="/catalog">Catalog</NavLink>
|
||||
{prestigeVisible && <NavLink to="/prestige">Prestige</NavLink>}
|
||||
<NavLink
|
||||
to="/battle"
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'nav-battle',
|
||||
isActive ? 'active' : '',
|
||||
waitingBattles > 0 ? 'nav-battle-waiting' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
>
|
||||
Feed Drop
|
||||
</button>
|
||||
Battle
|
||||
{waitingBattles > 0 && (
|
||||
<span className="nav-battle-badge" aria-label={`${waitingBattles} waiting`}>
|
||||
{waitingBattles}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
<NavLink to="/leaderboard">Leaderboard</NavLink>
|
||||
{user?.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
|
||||
{user && (
|
||||
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
|
||||
isPlayer ? (
|
||||
<NavLink to="/shop" className="balance-pill balance-pill-link">
|
||||
{formatCredits(user.balance)} cr
|
||||
</NavLink>
|
||||
) : (
|
||||
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
|
||||
)
|
||||
)}
|
||||
{user ? (
|
||||
<>
|
||||
<NavLink to="/profile" className="nav-user" title="Profile">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="nav-avatar" />
|
||||
) : null}
|
||||
<span className="muted">{user.username}</span>
|
||||
</NavLink>
|
||||
<button type="button" className="btn ghost" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
<NavLink to="/profile" className="nav-user" title="Profile">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="nav-avatar" />
|
||||
) : null}
|
||||
<span className="muted">{user.username}</span>
|
||||
</NavLink>
|
||||
) : (
|
||||
<NavLink to="/login">Login</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
<div className="app-body">
|
||||
{feedOpen && user && user.role !== 'admin' && <DropFeed />}
|
||||
{feedOpen && isPlayer && <DropFeed />}
|
||||
<main className="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function ProfileAchievementBadges({
|
||||
achievements = [],
|
||||
linkToAchievements = false,
|
||||
}) {
|
||||
if (!achievements.length) return null;
|
||||
|
||||
return (
|
||||
<div className="achievement-badges-block">
|
||||
<div className="catalog-badges-head">
|
||||
<h3>Achievements</h3>
|
||||
{linkToAchievements && (
|
||||
<Link to="/achievements" className="achievement-badges-link">
|
||||
View all
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="achievement-badges" aria-label="Unlocked achievements">
|
||||
{achievements.map((a) => {
|
||||
const inner = (
|
||||
<>
|
||||
<span className="achievement-badge-icon" aria-hidden>
|
||||
{a.icon || '★'}
|
||||
</span>
|
||||
<span className="achievement-badge-name">{a.name}</span>
|
||||
</>
|
||||
);
|
||||
return linkToAchievements ? (
|
||||
<Link
|
||||
key={a.key}
|
||||
to="/achievements"
|
||||
className="achievement-badge"
|
||||
title={a.description}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={a.key}
|
||||
className="achievement-badge"
|
||||
title={a.description}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function ProfileCaseStats({ caseOpens = [] }) {
|
||||
if (!caseOpens.length) {
|
||||
return <p className="muted case-stats-empty">No cases opened yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="case-stat-list">
|
||||
{caseOpens.map((row) => (
|
||||
<li key={row.caseId}>
|
||||
<Link to={`/cases/${row.caseId}`} className="case-stat-row">
|
||||
<span className="case-stat-visual">
|
||||
{row.imageUrl ? (
|
||||
<img src={row.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
'▣'
|
||||
)}
|
||||
</span>
|
||||
<span className="case-stat-name">{row.caseName}</span>
|
||||
<span className="case-stat-count">
|
||||
<strong>{row.count}</strong>
|
||||
<span className="muted"> opened</span>
|
||||
</span>
|
||||
<span className="case-stat-keys muted">
|
||||
{row.keys} key{row.keys === 1 ? '' : 's'}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function ProfileCatalogBadges({
|
||||
completed = [],
|
||||
luckPercent = 0,
|
||||
linkToCatalog = false,
|
||||
}) {
|
||||
if (!completed.length) return null;
|
||||
|
||||
return (
|
||||
<div className="catalog-badges-block">
|
||||
<div className="catalog-badges-head">
|
||||
<h3>Catalog</h3>
|
||||
{luckPercent > 0 && (
|
||||
<span className="catalog-badges-luck">+{luckPercent}% luck</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="catalog-badges" aria-label="Completed catalog chapters">
|
||||
{completed.map((ch) => {
|
||||
const inner = (
|
||||
<>
|
||||
{ch.imageUrl ? (
|
||||
<img src={ch.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="catalog-badge-fallback">▣</span>
|
||||
)}
|
||||
<span className="catalog-badge-check" aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
<span className="catalog-badge-name">{ch.name}</span>
|
||||
</>
|
||||
);
|
||||
return linkToCatalog ? (
|
||||
<Link
|
||||
key={ch.caseId}
|
||||
to="/catalog"
|
||||
className="catalog-badge"
|
||||
title={`${ch.name} · chapter complete`}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={ch.caseId}
|
||||
className="catalog-badge"
|
||||
title={`${ch.name} · chapter complete`}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export default function ProfilePrestigeStats({ prestige }) {
|
||||
if (!prestige || !prestige.count) return null;
|
||||
|
||||
return (
|
||||
<div className="prestige-profile-block">
|
||||
<div className="catalog-badges-head">
|
||||
<h3>Prestige</h3>
|
||||
<span className="catalog-badges-luck">×{prestige.count}</span>
|
||||
</div>
|
||||
<ul className="stat-list prestige-profile-stats">
|
||||
<li>
|
||||
Chance <strong>+{prestige.chanceBonus}%</strong>
|
||||
</li>
|
||||
<li>
|
||||
Sell gains <strong>+{prestige.gainBonus}%</strong>
|
||||
</li>
|
||||
<li>
|
||||
Key opens <strong>−{prestige.keyOpenReduction}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const FAKE_ADS = [
|
||||
{
|
||||
brand: 'ServerFuel™',
|
||||
tagline: 'Keep the forge warm',
|
||||
blurb: 'Fictional energy drink for fictional servers. 0 calories, 100% vibes.',
|
||||
},
|
||||
{
|
||||
brand: 'CloudCrate Hosting',
|
||||
tagline: 'Boxes in the sky',
|
||||
blurb: 'Not a real host. Just a polite reminder that bandwidth isn’t free.',
|
||||
},
|
||||
{
|
||||
brand: 'FairPlay Shield',
|
||||
tagline: 'One account, few tabs',
|
||||
blurb: 'Demo ad. Thanks for not multi-boxing the backend into orbit.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function SessionLimitOverlay({ max = 10 }) {
|
||||
const [ad] = useState(
|
||||
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
|
||||
);
|
||||
const [phase, setPhase] = useState('idle'); // idle | watching | done
|
||||
const [secondsLeft, setSecondsLeft] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'watching') return undefined;
|
||||
if (secondsLeft <= 0) {
|
||||
setPhase('done');
|
||||
return undefined;
|
||||
}
|
||||
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [phase, secondsLeft]);
|
||||
|
||||
const startAd = () => {
|
||||
setSecondsLeft(5);
|
||||
setPhase('watching');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="session-limit-overlay" role="alertdialog" aria-modal="true" aria-labelledby="session-limit-title">
|
||||
<div className="session-limit-box">
|
||||
<p className="session-limit-kicker">Connection limit</p>
|
||||
<h2 id="session-limit-title">Too many windows</h2>
|
||||
<p className="session-limit-lead">
|
||||
This account already has <strong>{max}</strong> tabs/windows connected.
|
||||
Close another one, then reload this page.
|
||||
</p>
|
||||
<p className="session-limit-respect">
|
||||
Merci de ne pas abuser ni exploiter de bugs pour multiplier les sessions —
|
||||
ça charge le serveur pour tout le monde. Un compte, un usage raisonnable.
|
||||
</p>
|
||||
|
||||
<div className="session-limit-ad">
|
||||
<div className="session-limit-ad-label">Sponsored · fictif</div>
|
||||
<div className={`session-limit-ad-screen${phase === 'watching' ? ' playing' : ''}`}>
|
||||
{phase === 'idle' && (
|
||||
<>
|
||||
<div className="session-limit-ad-brand">{ad.brand}</div>
|
||||
<div className="session-limit-ad-tag">{ad.tagline}</div>
|
||||
<p className="muted">{ad.blurb}</p>
|
||||
<button type="button" className="btn" onClick={startAd}>
|
||||
Watch ad (5s) · support the servers
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{phase === 'watching' && (
|
||||
<>
|
||||
<div className="session-limit-ad-brand">{ad.brand}</div>
|
||||
<div className="session-limit-ad-progress" aria-hidden>
|
||||
<div
|
||||
className="session-limit-ad-progress-fill"
|
||||
style={{ width: `${((5 - secondsLeft) / 5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="session-limit-ad-countdown">Ad playing… {secondsLeft}s</p>
|
||||
<p className="muted">Fictional spot — thanks for the support gesture.</p>
|
||||
</>
|
||||
)}
|
||||
{phase === 'done' && (
|
||||
<>
|
||||
<div className="session-limit-ad-brand">Thanks!</div>
|
||||
<p>
|
||||
Pub fictive terminée. Pour débloquer cette page : ferme un autre onglet,
|
||||
puis recharge.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1602
-3
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
export function notifyInventoryChanged() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new Event('inventory:changed'));
|
||||
}
|
||||
|
||||
export function onInventoryChanged(handler) {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
window.addEventListener('inventory:changed', handler);
|
||||
return () => window.removeEventListener('inventory:changed', handler);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
function formatUnlockedAt(iso) {
|
||||
if (!iso) return null;
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const [achievements, setAchievements] = useState([]);
|
||||
const [unlocked, setUnlocked] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.achievements();
|
||||
if (cancelled) return;
|
||||
setAchievements(data.achievements || []);
|
||||
setUnlocked(data.unlocked || 0);
|
||||
setTotal(data.total || 0);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
if (loading || busy) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Achievements</h1>
|
||||
<p className="muted">
|
||||
Unlock badges by playing. Earned badges show on your{' '}
|
||||
<Link to="/profile">profile</Link>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="catalog-luck-pill">
|
||||
{unlocked}/{total} unlocked
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{achievements.length === 0 && !error && (
|
||||
<div className="empty">No achievements defined yet.</div>
|
||||
)}
|
||||
|
||||
<div className="achievement-grid">
|
||||
{achievements.map((a) => {
|
||||
const done = Boolean(a.unlockedAt);
|
||||
return (
|
||||
<article
|
||||
key={a.key}
|
||||
className={`achievement-card${done ? ' unlocked' : ' locked'}`}
|
||||
>
|
||||
<div className="achievement-card-icon" aria-hidden>
|
||||
{a.icon || '★'}
|
||||
</div>
|
||||
<div className="achievement-card-body">
|
||||
<h2>{a.name}</h2>
|
||||
<p className="muted">{a.description}</p>
|
||||
{done ? (
|
||||
<span className="achievement-card-meta">
|
||||
Unlocked {formatUnlockedAt(a.unlockedAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="achievement-card-meta locked-label">Locked</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -432,7 +432,7 @@ export default function Admin() {
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value (cents)
|
||||
Value (cents, Field-Tested ref)
|
||||
<input
|
||||
type="number"
|
||||
value={itemForm.marketValue}
|
||||
@@ -480,7 +480,7 @@ export default function Admin() {
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value (cents)
|
||||
Value (cents, Field-Tested ref)
|
||||
<input
|
||||
type="number"
|
||||
value={itemEdit.marketValue}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import { useCountdown } from '../hooks/useCountdown';
|
||||
import { rarityColor } from '../rarities';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
const TOP_ITEMS = 5;
|
||||
|
||||
function waitingDuelRooms(open) {
|
||||
return (open || []).filter(
|
||||
(r) =>
|
||||
r.status === 'open' &&
|
||||
((r.playerCount ?? r.players?.length ?? 0) > 0 || (r.deposits?.length ?? 0) > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function depositsToReelItems(deposits) {
|
||||
return (deposits || [])
|
||||
.filter((d) => d.item)
|
||||
.map((d) => ({
|
||||
...d.item,
|
||||
id: d.id,
|
||||
ownerUsername: d.username,
|
||||
ownerAvatarUrl: d.avatarUrl || '',
|
||||
}));
|
||||
}
|
||||
|
||||
export default function BattleHub() {
|
||||
const { user, loading } = useAuth();
|
||||
const [round, setRound] = useState(null);
|
||||
const [duelOpen, setDuelOpen] = useState([]);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [spinActive, setSpinActive] = useState(false);
|
||||
const [spinDeposits, setSpinDeposits] = useState([]);
|
||||
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
|
||||
const [lastWinner, setLastWinner] = useState(null);
|
||||
const [revealWinner, setRevealWinner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
|
||||
if (cancelled) return;
|
||||
setRound(jp.round);
|
||||
setDuelOpen(duels.open || []);
|
||||
if (jp.round?.status === 'spinning') {
|
||||
setSpinDeposits(jp.round.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setReelWinnerItemId(jp.round.reelWinnerItemId || null);
|
||||
setLastWinner(jp.round.winnerUsername || null);
|
||||
}
|
||||
} catch {
|
||||
/* hub still usable without live preview */
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const onState = (state) => setRound(state);
|
||||
const onSpin = (state) => {
|
||||
setRound(state);
|
||||
setSpinDeposits(state.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setRevealWinner(false);
|
||||
setReelWinnerItemId(state.reelWinnerItemId);
|
||||
setLastWinner(state.winnerUsername);
|
||||
};
|
||||
const onResult = (state) => setRound(state);
|
||||
const onDuels = (list) => setDuelOpen(list.open || []);
|
||||
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
socket.on('jackpot:state', onState);
|
||||
socket.on('jackpot:spin', onSpin);
|
||||
socket.on('jackpot:result', onResult);
|
||||
socket.on('duels:list', onDuels);
|
||||
socket.on('connect', () => {
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.emit('duels:unsubscribe');
|
||||
socket.off('jackpot:state', onState);
|
||||
socket.off('jackpot:spin', onSpin);
|
||||
socket.off('jackpot:result', onResult);
|
||||
socket.off('duels:list', onDuels);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealWinner) return undefined;
|
||||
const t = setTimeout(() => {
|
||||
setSpinActive(false);
|
||||
setRevealWinner(false);
|
||||
}, 4000);
|
||||
return () => clearTimeout(t);
|
||||
}, [revealWinner]);
|
||||
|
||||
const topItems = useMemo(() => {
|
||||
const deposits = [...(round?.deposits || [])].filter((d) => d.item);
|
||||
deposits.sort((a, b) => (b.valueCents || 0) - (a.valueCents || 0));
|
||||
return deposits.slice(0, TOP_ITEMS);
|
||||
}, [round]);
|
||||
|
||||
const potItems = useMemo(
|
||||
() => depositsToReelItems(spinActive && spinDeposits.length ? spinDeposits : round?.deposits),
|
||||
[round, spinDeposits, spinActive]
|
||||
);
|
||||
|
||||
const showReel = spinActive && potItems.length > 0;
|
||||
const waitingRooms = useMemo(() => waitingDuelRooms(duelOpen), [duelOpen]);
|
||||
const waitingRoomCount = waitingRooms.length;
|
||||
|
||||
const liveTimerEndsAt =
|
||||
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers && !spinning
|
||||
? round.endsAt
|
||||
: null;
|
||||
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
|
||||
|
||||
const playerCount = round?.players?.length ?? 0;
|
||||
const potValue = round?.totalValue ?? 0;
|
||||
const itemCount = round?.deposits?.length ?? 0;
|
||||
const statusLabel = !round
|
||||
? '…'
|
||||
: round.status === 'spinning' || spinning
|
||||
? 'Spinning'
|
||||
: round.waitingForPlayers
|
||||
? 'Waiting for players'
|
||||
: round.status === 'open'
|
||||
? liveTimerEndsAt
|
||||
? `Live ${liveTimerLabel}`
|
||||
: 'Live'
|
||||
: round.status;
|
||||
|
||||
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 (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Battle</h1>
|
||||
<p className="muted">Multiplayer item gambling modes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mode-grid">
|
||||
<Link to="/battle/multi" className="mode-card mode-card-live">
|
||||
<div className="mode-card-top">
|
||||
<h2>MultiPlayers Case</h2>
|
||||
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted mode-blurb">
|
||||
Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items — chance scales with
|
||||
value.
|
||||
</p>
|
||||
|
||||
<div className="mode-pot-stats">
|
||||
<div>
|
||||
<span className="muted">Pot</span>
|
||||
<strong>{formatCredits(potValue)} cr</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Players</span>
|
||||
<strong>{playerCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Items</span>
|
||||
<strong>{itemCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showReel ? (
|
||||
<div className="mode-card-reel">
|
||||
<CaseReel
|
||||
items={potItems}
|
||||
winnerId={reelWinnerItemId || potItems[0]?.id}
|
||||
spinning={spinning || round?.status === 'spinning'}
|
||||
showOwners
|
||||
compact
|
||||
onDone={() => {
|
||||
setSpinning(false);
|
||||
setRevealWinner(true);
|
||||
}}
|
||||
/>
|
||||
{revealWinner && lastWinner && (
|
||||
<p className="mode-card-winner">
|
||||
Winner: <strong>{lastWinner}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : topItems.length > 0 ? (
|
||||
<div className="mode-pot-items" aria-label="Top items in pot">
|
||||
{topItems.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="mode-pot-thumb"
|
||||
style={{ borderColor: rarityColor(d.item.rarity) }}
|
||||
title={`${d.item.name} · ${formatCredits(d.valueCents)} cr`}
|
||||
>
|
||||
{d.item.imageUrl ? (
|
||||
<img src={d.item.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span>{d.item.name.slice(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{itemCount > topItems.length && (
|
||||
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted mode-pot-empty">No items in the pot yet — be the first.</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<Link to="/battle/1vx" className="mode-card">
|
||||
<div className="mode-card-top">
|
||||
<h2>1vX Case</h2>
|
||||
{waitingRoomCount > 0 && (
|
||||
<span className="mode-status">
|
||||
{waitingRoomCount} waiting
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted mode-blurb">
|
||||
Create or join custom rooms. Set player/item limits and min value. Spectate live duels.
|
||||
</p>
|
||||
<div className="mode-pot-stats">
|
||||
<div>
|
||||
<span className="muted">Open rooms</span>
|
||||
<strong>{waitingRoomCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="mode-card stub">
|
||||
<h2>Coinflip</h2>
|
||||
<p className="muted">Classic 1v1 item flip. Coming soon.</p>
|
||||
<span className="badge">Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
to: '/bet/multi',
|
||||
title: 'MultiPlayers Case',
|
||||
blurb: 'Join the live pot. Bet up to 3 items — chance scales with value. Winner takes all.',
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
to: '/bet/1vx',
|
||||
title: '1vX Case',
|
||||
blurb: 'Create or join custom rooms. Set player/item limits and min value. Spectate live duels.',
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
to: '/bet/coinflip',
|
||||
title: 'Coinflip',
|
||||
blurb: 'Classic 1v1 item flip. Coming soon.',
|
||||
ready: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function BetHub() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
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 (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Bet Item</h1>
|
||||
<p className="muted">Multiplayer item gambling modes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mode-grid">
|
||||
{MODES.map((m) =>
|
||||
m.ready ? (
|
||||
<Link key={m.to} to={m.to} className="mode-card">
|
||||
<h2>{m.title}</h2>
|
||||
<p className="muted">{m.blurb}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div key={m.to} className="mode-card stub">
|
||||
<h2>{m.title}</h2>
|
||||
<p className="muted">{m.blurb}</p>
|
||||
<span className="badge">Soon</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+409
-42
@@ -1,27 +1,159 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import { notifyInventoryChanged } from '../inventoryEvents';
|
||||
import { rarityColor } from '../rarities';
|
||||
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
|
||||
|
||||
const DEFAULT_MAX_OPENS = 10;
|
||||
|
||||
/** 1–5, then steps of 5 (10, 15…), plus max if not already listed. */
|
||||
function buildOpenQuantityOptions(maxOpens) {
|
||||
const cap = Math.max(1, maxOpens);
|
||||
const options = [];
|
||||
for (let n = 1; n <= Math.min(5, cap); n += 1) options.push(n);
|
||||
for (let n = 10; n <= cap; n += 5) options.push(n);
|
||||
if (cap > 5 && !options.includes(cap)) options.push(cap);
|
||||
return options;
|
||||
}
|
||||
|
||||
export default function CasePage() {
|
||||
const { id } = useParams();
|
||||
const { user, loading, updateBalance } = useAuth();
|
||||
const [crate, setCrate] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [winnerId, setWinnerId] = useState(null);
|
||||
const [wonItem, setWonItem] = useState(null);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [keyMessage, setKeyMessage] = useState('');
|
||||
const [catalogMessage, setCatalogMessage] = useState('');
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [spins, setSpins] = useState([]);
|
||||
const [caseName, setCaseName] = useState('');
|
||||
const announcedRef = useRef(false);
|
||||
const finishingRef = useRef(false);
|
||||
const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
|
||||
const pendingAutoSellRef = useRef([]);
|
||||
const quantityRef = useRef(1);
|
||||
const caseIdRef = useRef(null);
|
||||
const prefReadyRef = useRef(false);
|
||||
|
||||
const keyProgress = crate?.keyProgress ?? null;
|
||||
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
|
||||
|
||||
quantityRef.current = quantity;
|
||||
|
||||
const persistQty = (caseId, qty) => {
|
||||
if (user?.id && caseId != null) saveCaseOpenQty(user.id, caseId, qty);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (user?.id && caseIdRef.current != null) {
|
||||
saveCaseOpenQty(user.id, caseIdRef.current, quantityRef.current);
|
||||
}
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const announcePendingDrop = async (useKeepalive = false) => {
|
||||
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
|
||||
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
|
||||
if (!ids.length || announcedRef.current) return;
|
||||
|
||||
announcedRef.current = true;
|
||||
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
|
||||
|
||||
if (useKeepalive) {
|
||||
fetch('/api/feed/announce', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
}).catch(() => {
|
||||
announcedRef.current = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.feedAnnounce(ids, announceCaseName);
|
||||
} catch {
|
||||
announcedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingAutoSellKeepalive = () => {
|
||||
const ids = pendingAutoSellRef.current.filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
pendingAutoSellRef.current = [];
|
||||
fetch('/api/inventory/auto-sell/apply', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventoryItemIds: ids }),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const finalizeOpens = async (completedSpins, announceCaseName) => {
|
||||
if (finishingRef.current) return;
|
||||
finishingRef.current = true;
|
||||
|
||||
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
|
||||
pendingAnnounceRef.current = { ids, caseName: announceCaseName };
|
||||
|
||||
try {
|
||||
await announcePendingDrop(false);
|
||||
|
||||
const toSell = completedSpins
|
||||
.filter((s) => s.item?.willAutoSell)
|
||||
.map((s) => s.item.inventoryId)
|
||||
.filter(Boolean);
|
||||
|
||||
pendingAutoSellRef.current = [];
|
||||
|
||||
if (toSell.length) {
|
||||
const data = await api.applyPendingAutoSell(toSell);
|
||||
updateBalance(data.user.balance);
|
||||
notifyInventoryChanged();
|
||||
const soldSet = new Set(data.soldIds);
|
||||
setSpins((curr) =>
|
||||
curr.map((s) =>
|
||||
soldSet.has(s.item.inventoryId)
|
||||
? {
|
||||
...s,
|
||||
item: {
|
||||
...s.item,
|
||||
autoSold: true,
|
||||
soldForCents: s.item.valueCents,
|
||||
},
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
announcedRef.current = false;
|
||||
} finally {
|
||||
finishingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
prefReadyRef.current = false;
|
||||
caseIdRef.current = null;
|
||||
setCrate(null);
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.case(id);
|
||||
if (!cancelled) setCrate(data.case);
|
||||
if (!cancelled) {
|
||||
setCrate(data.case);
|
||||
caseIdRef.current = data.case.id;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
@@ -31,33 +163,185 @@ export default function CasePage() {
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const maxAffordable = useMemo(() => {
|
||||
if (!crate || !user) return 1;
|
||||
if (crate.price <= 0) return maxOpens;
|
||||
const affordable = Math.floor(user.balance / crate.price);
|
||||
return Math.min(maxOpens, Math.max(affordable, 0));
|
||||
}, [crate, user, maxOpens]);
|
||||
|
||||
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!crate || !user?.id) return;
|
||||
|
||||
const saved = loadCaseOpenQty(user.id, crate.id);
|
||||
const resolved = resolveOpenQty(saved, openOptions, maxAffordable);
|
||||
|
||||
if (!prefReadyRef.current) {
|
||||
prefReadyRef.current = true;
|
||||
setQuantity(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
setQuantity((q) => {
|
||||
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
|
||||
if (!allowed.length) return 1;
|
||||
if (allowed.includes(q)) return q;
|
||||
const below = allowed.filter((n) => n <= q);
|
||||
return below.length ? below[below.length - 1] : allowed[0];
|
||||
});
|
||||
}, [crate, maxAffordable, openOptions, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
|
||||
persistQty(crate.id, quantity);
|
||||
}, [quantity, user?.id, crate?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
announcePendingDrop(true);
|
||||
applyPendingAutoSellKeepalive();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const possibleDrops = useMemo(() => {
|
||||
if (!crate?.items?.length) return [];
|
||||
return [...crate.items].sort((a, b) => {
|
||||
const ca = Number(a.chance ?? a.dropRate ?? 0);
|
||||
const cb = Number(b.chance ?? b.dropRate ?? 0);
|
||||
if (ca !== cb) return ca - cb;
|
||||
return (b.marketValue || 0) - (a.marketValue || 0);
|
||||
});
|
||||
}, [crate]);
|
||||
|
||||
const progressPct = useMemo(() => {
|
||||
if (!keyProgress?.opensRequired) return 0;
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((keyProgress.opensSinceLastKey / keyProgress.opensRequired) * 100)
|
||||
);
|
||||
}, [keyProgress]);
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
const totalCost = crate ? crate.price * quantity : 0;
|
||||
const canOpen =
|
||||
crate && !spinning && user.balance >= crate.price && crate.items.length > 0;
|
||||
crate &&
|
||||
!opening &&
|
||||
crate.items.length > 0 &&
|
||||
quantity >= 1 &&
|
||||
quantity <= maxOpens &&
|
||||
user.balance >= totalCost;
|
||||
|
||||
const allDone = spins.length > 0 && spins.every((s) => s.done);
|
||||
|
||||
const openCase = async () => {
|
||||
if (!canOpen) return;
|
||||
setError('');
|
||||
setWonItem(null);
|
||||
setShowResult(false);
|
||||
setWinnerId(null);
|
||||
setSpinning(false);
|
||||
setKeyMessage('');
|
||||
setCatalogMessage('');
|
||||
setSpins([]);
|
||||
setOpening(true);
|
||||
announcedRef.current = false;
|
||||
finishingRef.current = false;
|
||||
|
||||
try {
|
||||
const result = await api.openCase(crate.id);
|
||||
const result = await api.openCase(crate.id, quantity);
|
||||
updateBalance(result.user.balance);
|
||||
setWonItem(result.item);
|
||||
setWinnerId(result.item.id);
|
||||
// start spin on next frame so reel rebuilds with winner
|
||||
requestAnimationFrame(() => setSpinning(true));
|
||||
notifyInventoryChanged();
|
||||
persistQty(crate.id, quantity);
|
||||
if (result.keyProgress) {
|
||||
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
||||
}
|
||||
if (result.keysGained > 0) {
|
||||
setKeyMessage(
|
||||
`+${result.keysGained} key${result.keysGained === 1 ? '' : 's'} unlocked · now x${result.keyProgress.maxOpens} max`
|
||||
);
|
||||
}
|
||||
if (result.catalog) {
|
||||
const parts = [];
|
||||
if (result.catalog.newlyFilled?.length) {
|
||||
const n = result.catalog.newlyFilled.length;
|
||||
parts.push(
|
||||
`${n} catalog slot${n === 1 ? '' : 's'} filled`
|
||||
);
|
||||
}
|
||||
if (result.catalog.chapterJustCompleted) {
|
||||
parts.push('chapter complete · +1% luck');
|
||||
} else if (result.catalog.luckPercent > 0 && result.catalog.newlyFilled?.length) {
|
||||
parts.push(`luck +${result.catalog.luckPercent}%`);
|
||||
}
|
||||
if (parts.length) setCatalogMessage(parts.join(' · '));
|
||||
if (result.catalog.luckPercent != null) {
|
||||
setCrate((prev) =>
|
||||
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
|
||||
);
|
||||
if (result.catalog.chapterJustCompleted) {
|
||||
api.case(crate.id).then((data) => {
|
||||
setCrate((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
...data.case,
|
||||
keyProgress: result.keyProgress || data.case.keyProgress,
|
||||
}
|
||||
: data.case
|
||||
);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolvedCaseName = result.caseName || crate.name;
|
||||
setCaseName(resolvedCaseName);
|
||||
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
|
||||
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
|
||||
pendingAnnounceRef.current = {
|
||||
ids: inventoryIds,
|
||||
caseName: resolvedCaseName || '',
|
||||
};
|
||||
pendingAutoSellRef.current = items
|
||||
.filter((item) => item?.willAutoSell)
|
||||
.map((item) => item.inventoryId)
|
||||
.filter(Boolean);
|
||||
const next = items.map((item, idx) => ({
|
||||
key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`,
|
||||
item,
|
||||
spinning: false,
|
||||
done: false,
|
||||
}));
|
||||
setSpins(next);
|
||||
requestAnimationFrame(() => {
|
||||
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onReelDone = (key) => {
|
||||
setSpins((prev) => {
|
||||
const alreadyDone = prev.find((s) => s.key === key)?.done;
|
||||
if (alreadyDone) return prev;
|
||||
|
||||
const next = prev.map((s) =>
|
||||
s.key === key ? { ...s, spinning: false, done: true } : s
|
||||
);
|
||||
|
||||
const wasComplete = prev.length > 0 && prev.every((s) => s.done);
|
||||
const nowComplete = next.length > 0 && next.every((s) => s.done);
|
||||
|
||||
if (nowComplete && !wasComplete) {
|
||||
setOpening(false);
|
||||
void finalizeOpens(next, caseName || crate?.name || '');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="case-page">
|
||||
<Link to="/cases" className="muted">
|
||||
@@ -66,14 +350,63 @@ export default function CasePage() {
|
||||
|
||||
{!crate && !error && <p className="muted">Loading case…</p>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
|
||||
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
|
||||
|
||||
{crate && (
|
||||
<>
|
||||
<div className="case-hero">
|
||||
<h1>{crate.name}</h1>
|
||||
<p className="muted">
|
||||
Cost {formatCredits(crate.price)} cr · Balance {formatCredits(user.balance)} cr
|
||||
Cost {formatCredits(crate.price)} cr each · Balance{' '}
|
||||
{formatCredits(user.balance)} cr
|
||||
{crate.catalogLuckPercent > 0
|
||||
? ` · Catalog luck +${crate.catalogLuckPercent}%`
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
{keyProgress && (
|
||||
<div className="case-key-progress">
|
||||
<div className="case-key-head">
|
||||
<span>
|
||||
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
|
||||
<strong>x{keyProgress.maxOpens}</strong>
|
||||
</span>
|
||||
<span className="muted">
|
||||
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
|
||||
</span>
|
||||
</div>
|
||||
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
|
||||
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="open-qty">
|
||||
<label className="muted">
|
||||
Open
|
||||
<select
|
||||
value={quantity}
|
||||
disabled={opening}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
setQuantity(next);
|
||||
persistQty(crate.id, next);
|
||||
}}
|
||||
>
|
||||
{openOptions.map((n) => (
|
||||
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
|
||||
{n}×
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="muted">
|
||||
Total {formatCredits(totalCost)} cr
|
||||
{maxAffordable < quantity ? ' · insufficient balance' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
|
||||
@@ -84,34 +417,62 @@ export default function CasePage() {
|
||||
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
|
||||
{spinning ? 'Opening…' : `Open for ${formatCredits(crate.price)} cr`}
|
||||
{opening
|
||||
? `Opening ${quantity}×…`
|
||||
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(spinning || winnerId) && (
|
||||
<CaseReel
|
||||
items={crate.items}
|
||||
winnerId={winnerId}
|
||||
spinning={spinning}
|
||||
onDone={() => {
|
||||
setSpinning(false);
|
||||
setShowResult(true);
|
||||
}}
|
||||
/>
|
||||
{spins.length > 0 && (
|
||||
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
|
||||
{spins.map((spin, idx) => (
|
||||
<div key={spin.key} className="multi-reel-slot">
|
||||
{spins.length > 1 && (
|
||||
<div className="muted multi-reel-label">#{idx + 1}</div>
|
||||
)}
|
||||
<CaseReel
|
||||
items={crate.items}
|
||||
winnerId={spin.item.id}
|
||||
spinning={spin.spinning}
|
||||
onDone={() => onReelDone(spin.key)}
|
||||
/>
|
||||
{spin.done && (
|
||||
<div
|
||||
className="won-banner compact"
|
||||
style={{ '--rarity': rarityColor(spin.item.rarity) }}
|
||||
>
|
||||
{spin.item.imageUrl ? (
|
||||
<img src={spin.item.imageUrl} alt="" className="won-img" />
|
||||
) : null}
|
||||
<strong>{spin.item.name}</strong>
|
||||
<div style={{ color: rarityColor(spin.item.rarity) }}>
|
||||
{spin.item.rarity}
|
||||
</div>
|
||||
<div className="muted">
|
||||
{spin.item.autoSold ? (
|
||||
<>Auto-sold for {formatCredits(spin.item.soldForCents)} cr</>
|
||||
) : (
|
||||
<>
|
||||
{formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr
|
||||
{spin.item.wear ? ` · ${spin.item.wear}` : ''}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showResult && wonItem && (
|
||||
<div
|
||||
className="won-banner"
|
||||
style={{ '--rarity': rarityColor(wonItem.rarity) }}
|
||||
>
|
||||
{wonItem.imageUrl ? (
|
||||
<img src={wonItem.imageUrl} alt="" className="won-img" />
|
||||
) : null}
|
||||
<div className="muted">You unboxed</div>
|
||||
<strong>{wonItem.name}</strong>
|
||||
<div style={{ color: rarityColor(wonItem.rarity) }}>{wonItem.rarity}</div>
|
||||
<div className="muted">{formatCredits(wonItem.marketValue)} cr</div>
|
||||
{allDone && spins.length > 1 && (
|
||||
<div className="won-banner section">
|
||||
<div className="muted">You unboxed {spins.length} items</div>
|
||||
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
||||
{spins.map((s) => (
|
||||
<ItemTile key={s.key} item={s.item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -119,11 +480,17 @@ export default function CasePage() {
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Possible drops</h2>
|
||||
<p className="muted">Relative drop odds for this case.</p>
|
||||
<p className="muted">
|
||||
Relative drop odds
|
||||
{crate.catalogLuckPercent > 0
|
||||
? ` (with +${crate.catalogLuckPercent}% catalog luck)`
|
||||
: ''}{' '}
|
||||
· prices shown at Field-Tested reference.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{crate.items.map((item) => (
|
||||
{possibleDrops.map((item) => (
|
||||
<div key={item.id}>
|
||||
<ItemTile item={item} />
|
||||
<div className="item-meta" style={{ marginTop: '0.35rem' }}>
|
||||
|
||||
@@ -46,6 +46,12 @@ export default function CasesPage() {
|
||||
<div className="case-name">{c.name}</div>
|
||||
<div className="case-meta">{formatCredits(c.price)} cr</div>
|
||||
<div className="case-meta">{c.items.length} possible drops</div>
|
||||
{c.keyProgress && (
|
||||
<div className="case-meta case-key-badge">
|
||||
{c.keyProgress.keys} key{c.keyProgress.keys === 1 ? '' : 's'} · x
|
||||
{c.keyProgress.maxOpens}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
export default function CatalogPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const [chapters, setChapters] = useState([]);
|
||||
const [luckPercent, setLuckPercent] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.catalog();
|
||||
if (cancelled) return;
|
||||
setChapters(data.chapters || []);
|
||||
setLuckPercent(data.luckPercent || 0);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
if (loading || busy) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Catalog</h1>
|
||||
<p className="muted">
|
||||
Fill every slot in a case chapter. Each completed chapter grants +1% catalog luck on
|
||||
opens.
|
||||
</p>
|
||||
</div>
|
||||
<div className="catalog-luck-pill">
|
||||
{luckPercent > 0 ? `+${luckPercent}% luck` : 'No luck bonus yet'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{chapters.length === 0 && !error && (
|
||||
<div className="empty">No active cases to collect.</div>
|
||||
)}
|
||||
|
||||
<div className="catalog-chapters">
|
||||
{chapters.map((ch) => (
|
||||
<article
|
||||
key={ch.caseId}
|
||||
className={`catalog-chapter${ch.completed ? ' completed' : ''}`}
|
||||
>
|
||||
<div className="catalog-chapter-head">
|
||||
<div className="catalog-chapter-title">
|
||||
{ch.imageUrl ? (
|
||||
<img src={ch.imageUrl} alt="" className="catalog-case-thumb" />
|
||||
) : (
|
||||
<span className="catalog-case-thumb placeholder">▣</span>
|
||||
)}
|
||||
<div>
|
||||
<h2>
|
||||
<Link to={`/cases/${ch.caseId}`}>{ch.name}</Link>
|
||||
</h2>
|
||||
<p className="muted">
|
||||
{ch.filled}/{ch.total} slots · {formatCredits(ch.price)} cr
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{ch.completed ? (
|
||||
<span className="catalog-complete-badge">Complete · +1%</span>
|
||||
) : (
|
||||
<div className="catalog-progress-bar" aria-hidden>
|
||||
<div
|
||||
className="catalog-progress-fill"
|
||||
style={{
|
||||
width: `${ch.total ? Math.round((ch.filled / ch.total) * 100) : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="catalog-slots">
|
||||
{ch.slots.map((slot) => (
|
||||
<div
|
||||
key={slot.itemId}
|
||||
className={`catalog-slot${slot.filled ? ' filled' : ' empty'}`}
|
||||
style={slot.filled ? { '--rarity': rarityColor(slot.rarity) } : undefined}
|
||||
title={slot.filled ? slot.name : 'Not collected yet'}
|
||||
>
|
||||
{slot.filled ? (
|
||||
<>
|
||||
{slot.imageUrl ? (
|
||||
<img src={slot.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="catalog-slot-fallback">
|
||||
{slot.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="catalog-slot-name">{slot.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="catalog-slot-empty">?</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -15,17 +15,17 @@ export default function CoinflipStub() {
|
||||
<h1>Coinflip</h1>
|
||||
<p className="muted">1v1 item flip — coming soon.</p>
|
||||
</div>
|
||||
<Link to="/bet" className="btn secondary">
|
||||
<Link to="/battle" className="btn secondary">
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
<div className="empty stub-panel">
|
||||
<p>This mode is not playable yet. Use MultiPlayers Case or 1vX Case in the meantime.</p>
|
||||
<div className="row-actions" style={{ marginTop: '1rem' }}>
|
||||
<Link to="/bet/multi" className="btn">
|
||||
<Link to="/battle/multi" className="btn">
|
||||
MultiPlayers
|
||||
</Link>
|
||||
<Link to="/bet/1vx" className="btn secondary">
|
||||
<Link to="/battle/1vx" className="btn secondary">
|
||||
1vX Case
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+314
-34
@@ -3,14 +3,36 @@ import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { loadInventorySort, saveInventorySort } from '../userPrefs';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [sort, setSort] = useState('recent');
|
||||
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);
|
||||
|
||||
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((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
|
||||
}
|
||||
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
|
||||
|
||||
const load = async () => {
|
||||
const inv = await api.inventory();
|
||||
@@ -32,38 +54,98 @@ export default function Dashboard() {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
return onInventoryChanged(() => {
|
||||
load().catch(() => {});
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...inventory];
|
||||
if (sort === 'value') {
|
||||
list.sort((a, b) => b.item.marketValue - a.item.marketValue);
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(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(
|
||||
() =>
|
||||
inventory
|
||||
.filter((inv) => selected.includes(inv.id))
|
||||
.reduce((sum, inv) => sum + inv.item.marketValue, 0),
|
||||
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
|
||||
[inventory, selected]
|
||||
);
|
||||
|
||||
const thresholdCents = useMemo(() => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return null;
|
||||
return Math.round(credits * 100);
|
||||
}, [maxValue]);
|
||||
|
||||
const belowThreshold = useMemo(() => {
|
||||
if (thresholdCents == null) return [];
|
||||
return inventory.filter(
|
||||
(inv) =>
|
||||
!inv.favorite &&
|
||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
);
|
||||
}, [inventory, thresholdCents]);
|
||||
|
||||
const selectedItems = useMemo(
|
||||
() => inventory.filter((inv) => selected.includes(inv.id)),
|
||||
[inventory, selected]
|
||||
);
|
||||
|
||||
const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
|
||||
const selectedAllFavorite =
|
||||
selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite);
|
||||
const selectedCanSell = selectedItems.some((inv) => !inv.favorite);
|
||||
|
||||
const belowValue = useMemo(
|
||||
() =>
|
||||
belowThreshold.reduce(
|
||||
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
|
||||
0
|
||||
),
|
||||
[belowThreshold]
|
||||
);
|
||||
|
||||
const toggle = (id) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const sell = async () => {
|
||||
if (!selected.length) return;
|
||||
const sellIds = async (ids) => {
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.sellInventory(selected);
|
||||
const data = await api.sellInventory(ids);
|
||||
patchUser({ balance: data.user.balance });
|
||||
setSelected([]);
|
||||
setSelected((prev) => prev.filter((id) => !ids.includes(id)));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -72,6 +154,71 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const sell = async () => {
|
||||
const ids = selectedItems.filter((inv) => !inv.favorite).map((inv) => inv.id);
|
||||
if (!ids.length) return;
|
||||
await sellIds(ids);
|
||||
};
|
||||
|
||||
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 && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
).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);
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
await saveAutoSell(enabled, credits);
|
||||
};
|
||||
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
await saveAutoSell(autoSellEnabled, credits);
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
@@ -84,31 +231,44 @@ export default function Dashboard() {
|
||||
<h1>Inventory</h1>
|
||||
<p className="muted">Select items to sell at market value.</p>
|
||||
</div>
|
||||
<div className="balance-pill">{formatCredits(user.balance)} cr</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-toolbar">
|
||||
<div className="sort-toggle" role="group" aria-label="Sort inventory">
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'recent' ? 'active' : ''}
|
||||
onClick={() => setSort('recent')}
|
||||
>
|
||||
Most recent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'value' ? 'active' : ''}
|
||||
onClick={() => setSort('value')}
|
||||
>
|
||||
Highest value
|
||||
</button>
|
||||
<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>
|
||||
{inventory.length > 0 && (
|
||||
<span className="muted inv-count">
|
||||
{inventory.length} item{inventory.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"
|
||||
@@ -118,7 +278,33 @@ export default function Dashboard() {
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={sell} disabled={busy}>
|
||||
{!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 ? 'Favorites cannot be sold' : undefined}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
</>
|
||||
@@ -126,6 +312,74 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-bulk-sell">
|
||||
{inventory.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'} ·{' '}
|
||||
{formatCredits(belowValue)} cr
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{inventory.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">
|
||||
at{' '}
|
||||
{formatCredits(
|
||||
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
|
||||
? Math.round(Number(maxValue) * 100)
|
||||
: 0
|
||||
)}{' '}
|
||||
cr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">
|
||||
@@ -136,16 +390,42 @@ export default function Dashboard() {
|
||||
.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{sorted.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function DuelListPage() {
|
||||
minItemValue: Math.round(Number(form.minItemValue) * 100),
|
||||
minPlayersToStart: Number(form.minPlayersToStart),
|
||||
});
|
||||
navigate(`/bet/1vx/${data.room.id}`);
|
||||
navigate(`/battle/1vx/${data.room.id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -82,7 +82,7 @@ export default function DuelListPage() {
|
||||
<h1>1vX Case</h1>
|
||||
<p className="muted">Create a room or join an open duel.</p>
|
||||
</div>
|
||||
<Link to="/bet" className="btn secondary">
|
||||
<Link to="/battle" className="btn secondary">
|
||||
Modes
|
||||
</Link>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ export default function DuelListPage() {
|
||||
) : (
|
||||
<div className="room-list">
|
||||
{open.map((r) => (
|
||||
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card">
|
||||
<Link key={r.id} to={`/battle/1vx/${r.id}`} className="room-card">
|
||||
<div className="room-title">Room #{r.id}</div>
|
||||
<div className="muted">
|
||||
by {r.creatorUsername} · {r.playerCount}/{r.maxPlayers} players ·{' '}
|
||||
@@ -165,7 +165,7 @@ export default function DuelListPage() {
|
||||
) : (
|
||||
<div className="room-list">
|
||||
{inProgress.map((r) => (
|
||||
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card live">
|
||||
<Link key={r.id} to={`/battle/1vx/${r.id}`} className="room-card live">
|
||||
<div className="room-title">
|
||||
Room #{r.id} <span className="badge">Live</span>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { api, formatCredits, formatChance } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
export default function DuelRoomPage() {
|
||||
@@ -22,6 +23,16 @@ export default function DuelRoomPage() {
|
||||
setInventory(inv.inventory);
|
||||
}, []);
|
||||
|
||||
const inventoryByValue = useMemo(
|
||||
() =>
|
||||
[...inventory].sort(
|
||||
(a, b) =>
|
||||
itemValueCents(b.item) - itemValueCents(a.item) ||
|
||||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
|
||||
),
|
||||
[inventory]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
@@ -75,6 +86,14 @@ export default function DuelRoomPage() {
|
||||
};
|
||||
}, [user, id, loadInventory, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
return onInventoryChanged(() => {
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
});
|
||||
}, [user, loadInventory, refresh]);
|
||||
|
||||
const isSpectator =
|
||||
room &&
|
||||
(room.status === 'in_progress' || room.status === 'spinning' || room.status === 'resolved') &&
|
||||
@@ -176,7 +195,7 @@ export default function DuelRoomPage() {
|
||||
{isSpectator ? ' · Spectating' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/bet/1vx" className="btn secondary">
|
||||
<Link to="/battle/1vx" className="btn secondary">
|
||||
All rooms
|
||||
</Link>
|
||||
</div>
|
||||
@@ -307,20 +326,21 @@ export default function DuelRoomPage() {
|
||||
Deposit
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length === 0 ? (
|
||||
{inventoryByValue.length === 0 ? (
|
||||
<div className="empty">
|
||||
No free items — <Link to="/cases">open a case</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{inventory.map((inv) => (
|
||||
{inventoryByValue.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
selected={selected.includes(inv.id)}
|
||||
disabled={
|
||||
(slotsLeft === 0 && !selected.includes(inv.id)) ||
|
||||
inv.item.marketValue < (room.minItemValue || 0)
|
||||
itemValueCents(inv.item) < (room.minItemValue || 0)
|
||||
}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
|
||||
const PODIUM_META = {
|
||||
1: {
|
||||
label: 'Champion',
|
||||
bonus: 'Crown of the Forge',
|
||||
placeClass: 'lb-place-1',
|
||||
},
|
||||
2: {
|
||||
label: 'Challenger',
|
||||
bonus: 'Silver Crest',
|
||||
placeClass: 'lb-place-2',
|
||||
},
|
||||
3: {
|
||||
label: 'Contender',
|
||||
bonus: 'Bronze Mark',
|
||||
placeClass: 'lb-place-3',
|
||||
},
|
||||
};
|
||||
|
||||
function PlayerAvatar({ username, avatarUrl, size = 'md' }) {
|
||||
const initials = (username || '?').slice(0, 2).toUpperCase();
|
||||
return (
|
||||
<div className={`lb-avatar lb-avatar-${size}`} aria-hidden>
|
||||
{avatarUrl ? <img src={avatarUrl} alt="" /> : <span>{initials}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WealthBreakdown({ balance, inventoryValue, totalWealth, compact }) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="lb-wealth-compact">
|
||||
<strong>{formatCredits(totalWealth)} cr</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="lb-wealth">
|
||||
<div className="lb-wealth-total">
|
||||
<span className="lb-wealth-label">Total</span>
|
||||
<strong>{formatCredits(totalWealth)} cr</strong>
|
||||
</div>
|
||||
<div className="lb-wealth-parts">
|
||||
<span>
|
||||
Balance <em>{formatCredits(balance)}</em>
|
||||
</span>
|
||||
<span>
|
||||
Inventory <em>{formatCredits(inventoryValue)}</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Leaderboard() {
|
||||
const navigate = useNavigate();
|
||||
const [rows, setRows] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const openPlayer = (username) => {
|
||||
navigate(`/player/${encodeURIComponent(username)}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -20,40 +79,105 @@ export default function Leaderboard() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const top3 = rows.slice(0, 3);
|
||||
const rest = rows.slice(3);
|
||||
// Podium visual order: 2nd | 1st | 3rd
|
||||
const podiumOrder = [top3[1], top3[0], top3[2]].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="lb-page">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Leaderboard</h1>
|
||||
<p className="muted">Richest players by balance + inventory value.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{rows.length === 0 && !error ? (
|
||||
<div className="empty">No players yet.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Player</th>
|
||||
<th>Balance</th>
|
||||
<th>Inventory</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="rank">{row.rank}</td>
|
||||
<td>{row.username}</td>
|
||||
<td>{formatCredits(row.balance)}</td>
|
||||
<td>{formatCredits(row.inventoryValue)}</td>
|
||||
<td>{formatCredits(row.totalWealth)} cr</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<>
|
||||
{top3.length > 0 && (
|
||||
<section className="lb-podium" aria-label="Top three">
|
||||
{podiumOrder.map((row) => {
|
||||
const meta = PODIUM_META[row.rank];
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
className={`lb-podium-card ${meta.placeClass}`}
|
||||
onClick={() => openPlayer(row.username)}
|
||||
aria-label={`View ${row.username}'s profile, rank ${row.rank}`}
|
||||
>
|
||||
{row.rank === 1 && (
|
||||
<span className="lb-fire" aria-hidden>
|
||||
<span className="lb-fire-ring" />
|
||||
</span>
|
||||
)}
|
||||
<div className="lb-podium-rank" aria-hidden>
|
||||
#{row.rank}
|
||||
</div>
|
||||
<div className="lb-podium-frame">
|
||||
<PlayerAvatar
|
||||
username={row.username}
|
||||
avatarUrl={row.avatarUrl}
|
||||
size={row.rank === 1 ? 'xl' : 'lg'}
|
||||
/>
|
||||
</div>
|
||||
<div className="lb-podium-title">{meta.label}</div>
|
||||
<div className="lb-podium-name">@{row.username}</div>
|
||||
<div className="lb-podium-bonus">{meta.bonus}</div>
|
||||
<WealthBreakdown
|
||||
balance={row.balance}
|
||||
inventoryValue={row.inventoryValue}
|
||||
totalWealth={row.totalWealth}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{rest.length > 0 && (
|
||||
<section className="lb-list" aria-label="Rankings">
|
||||
<div className="lb-list-head" aria-hidden>
|
||||
<span>Rank</span>
|
||||
<span>Player</span>
|
||||
<span className="lb-list-head-wealth">Total</span>
|
||||
</div>
|
||||
<ul className="lb-list-rows">
|
||||
{rest.map((row) => (
|
||||
<li key={row.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="lb-row"
|
||||
onClick={() => openPlayer(row.username)}
|
||||
aria-label={`View ${row.username}'s profile, rank ${row.rank}`}
|
||||
>
|
||||
<span className="lb-row-rank">{row.rank}</span>
|
||||
<PlayerAvatar
|
||||
username={row.username}
|
||||
avatarUrl={row.avatarUrl}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="lb-row-name">@{row.username}</span>
|
||||
<span className="lb-row-meta muted">
|
||||
{row.itemCount} item{row.itemCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<WealthBreakdown
|
||||
balance={row.balance}
|
||||
inventoryValue={row.inventoryValue}
|
||||
totalWealth={row.totalWealth}
|
||||
compact
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits, formatChance } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import { useCountdown } from '../hooks/useCountdown';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
export default function MultiPlayersPage() {
|
||||
@@ -22,7 +23,9 @@ export default function MultiPlayersPage() {
|
||||
const [spinActive, setSpinActive] = useState(false);
|
||||
|
||||
const { label: timerLabel, remaining } = useCountdown(
|
||||
round?.status === 'open' ? round.endsAt : null
|
||||
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers
|
||||
? round.endsAt
|
||||
: null
|
||||
);
|
||||
|
||||
const loadInventory = useCallback(async () => {
|
||||
@@ -30,6 +33,16 @@ export default function MultiPlayersPage() {
|
||||
setInventory(inv.inventory);
|
||||
}, []);
|
||||
|
||||
const inventoryByValue = useMemo(
|
||||
() =>
|
||||
[...inventory].sort(
|
||||
(a, b) =>
|
||||
itemValueCents(b.item) - itemValueCents(a.item) ||
|
||||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
|
||||
),
|
||||
[inventory]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
@@ -46,8 +59,6 @@ export default function MultiPlayersPage() {
|
||||
const onState = (state) => {
|
||||
setRound(state);
|
||||
if (state?.status === 'open') {
|
||||
setSpinning(false);
|
||||
setReelWinnerItemId(null);
|
||||
loadInventory().catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -62,7 +73,6 @@ export default function MultiPlayersPage() {
|
||||
};
|
||||
const onResult = (state) => {
|
||||
setRound(state);
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
};
|
||||
@@ -82,6 +92,14 @@ export default function MultiPlayersPage() {
|
||||
};
|
||||
}, [user, loadInventory, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
return onInventoryChanged(() => {
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
});
|
||||
}, [user, loadInventory, refresh]);
|
||||
|
||||
const maxItems = round?.maxItemsPerPlayer || 3;
|
||||
const myDeposits = useMemo(
|
||||
() => (round?.deposits || []).filter((d) => d.userId === user?.id),
|
||||
@@ -158,7 +176,7 @@ export default function MultiPlayersPage() {
|
||||
<h1>MultiPlayers Case</h1>
|
||||
<p className="muted">Live shared pot — win chance = your value / total pot.</p>
|
||||
</div>
|
||||
<Link to="/bet" className="btn secondary">
|
||||
<Link to="/battle" className="btn secondary">
|
||||
Modes
|
||||
</Link>
|
||||
</div>
|
||||
@@ -174,11 +192,24 @@ export default function MultiPlayersPage() {
|
||||
<div className="timer-block">
|
||||
<span className="muted">Timer</span>
|
||||
<strong className="timer-digits">
|
||||
{round?.status === 'open' ? timerLabel : round?.status === 'spinning' ? 'SPIN' : '—'}
|
||||
{round?.status === 'spinning' || spinning
|
||||
? 'SPIN'
|
||||
: round?.waitingForPlayers
|
||||
? 'WAIT'
|
||||
: round?.status === 'open' && round?.endsAt
|
||||
? timerLabel
|
||||
: '—'}
|
||||
</strong>
|
||||
{round?.status === 'open' && remaining <= 5 && remaining > 0 && (
|
||||
<span className="muted">closing…</span>
|
||||
{round?.waitingForPlayers && round?.status === 'open' && (
|
||||
<span className="muted">
|
||||
Need {round.minPlayers || 2} players ({round.players?.length || 0}/
|
||||
{round.minPlayers || 2})
|
||||
</span>
|
||||
)}
|
||||
{round?.status === 'open' &&
|
||||
!round?.waitingForPlayers &&
|
||||
remaining <= 5 &&
|
||||
remaining > 0 && <span className="muted">closing…</span>}
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Pot</span>
|
||||
@@ -289,20 +320,19 @@ export default function MultiPlayersPage() {
|
||||
Place bet
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length === 0 ? (
|
||||
{inventoryByValue.length === 0 ? (
|
||||
<div className="empty">
|
||||
No free items — <Link to="/cases">open a case</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{inventory.map((inv) => (
|
||||
{inventoryByValue.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
selected={selected.includes(inv.id)}
|
||||
disabled={
|
||||
mySlotsLeft === 0 && !selected.includes(inv.id)
|
||||
}
|
||||
disabled={mySlotsLeft === 0 && !selected.includes(inv.id)}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import ProfileCaseStats from '../components/ProfileCaseStats';
|
||||
import ProfileCatalogBadges from '../components/ProfileCatalogBadges';
|
||||
import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
|
||||
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function PlayerProfilePage() {
|
||||
const { username } = useParams();
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [sort, setSort] = useState('value');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await api.playerProfile(username);
|
||||
if (!cancelled) setData(result);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [username]);
|
||||
|
||||
const inventory = data?.inventory ?? [];
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...inventory];
|
||||
if (sort === 'value') {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(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, username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
if (user?.username === username) {
|
||||
return <Navigate to="/profile" replace />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Player profile</h1>
|
||||
<p className="muted">@{username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="error">{error}</div>
|
||||
<Link to="/leaderboard" className="btn secondary" style={{ marginTop: '1rem' }}>
|
||||
Back to leaderboard
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return <p className="muted">Loading…</p>;
|
||||
|
||||
const { user: player, profile } = data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>{player.username}</h1>
|
||||
<p className="muted">Player profile</p>
|
||||
</div>
|
||||
<Link to="/leaderboard" className="btn ghost">
|
||||
Leaderboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="profile-layout">
|
||||
<div className="profile-avatar-block admin-block">
|
||||
<div className="avatar-preview">
|
||||
{player.avatarUrl ? (
|
||||
<img src={player.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{player.username.slice(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
{player.bio ? (
|
||||
<p style={{ marginTop: '0.75rem' }}>{player.bio}</p>
|
||||
) : (
|
||||
<p className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
No bio yet.
|
||||
</p>
|
||||
)}
|
||||
<div className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
Balance {formatCredits(player.balance)} cr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-stats admin-block">
|
||||
<h2>Stats</h2>
|
||||
<ul className="stat-list">
|
||||
<li>
|
||||
Status{' '}
|
||||
<strong
|
||||
className={
|
||||
profile.presence?.isConnected
|
||||
? 'presence-online'
|
||||
: 'presence-offline'
|
||||
}
|
||||
>
|
||||
{profile.presence?.isConnected ? 'Online' : 'Offline'}
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Play time{' '}
|
||||
<strong>{formatPlayTime(profile.presence?.playTimeSeconds)}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Last connection{' '}
|
||||
<strong>
|
||||
{formatLastConnection(profile.presence?.lastConnection)}
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Joined <strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Inventory{' '}
|
||||
<strong>
|
||||
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Cases opened <strong>{profile.casesOpened}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Duel wins <strong>{profile.duelWins}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<ProfileAchievementBadges achievements={profile.achievements} />
|
||||
<ProfileCatalogBadges
|
||||
completed={profile.catalogCompleted}
|
||||
luckPercent={profile.catalogLuckPercent}
|
||||
/>
|
||||
<ProfilePrestigeStats prestige={profile.prestige} />
|
||||
{profile.caseOpens?.length > 0 && (
|
||||
<div className="case-stats-block">
|
||||
<h3>Opens by case</h3>
|
||||
<ProfileCaseStats caseOpens={profile.caseOpens} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Inventory</h2>
|
||||
<p className="muted">Items currently held by this player.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inventory.length > 0 && (
|
||||
<div className="inv-toolbar">
|
||||
<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')}
|
||||
>
|
||||
Highest value
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'recent' ? 'active' : ''}
|
||||
onClick={() => setSort('recent')}
|
||||
>
|
||||
Most recent
|
||||
</button>
|
||||
</div>
|
||||
<span className="muted inv-count">
|
||||
{inventory.length} item{inventory.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">No items in inventory.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid">
|
||||
{pageItems.map((inv) => (
|
||||
<ItemTile key={inv.id} item={inv.item} favorite={inv.favorite} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
const CHOICE_LABELS = {
|
||||
chance: 'Chance',
|
||||
gain: 'Gain',
|
||||
key: 'Key progress',
|
||||
};
|
||||
|
||||
export default function PrestigePage() {
|
||||
const { user, loading, patchUser, refresh } = useAuth();
|
||||
const [data, setData] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const result = await api.prestige();
|
||||
setData(result);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await api.prestige();
|
||||
if (!cancelled) setData(result);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
if (!data && !error) return <p className="muted">Loading…</p>;
|
||||
if (data && data.pageVisible === false) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const cost = data?.prestige?.costCents ?? 0;
|
||||
const p = data?.prestige;
|
||||
const unlockAt = data?.prestige?.visibleAtCents ?? 0;
|
||||
|
||||
const onPrestige = async () => {
|
||||
if (!selected || confirmText !== 'PRESTIGE') return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await api.doPrestige(selected);
|
||||
patchUser(result.user);
|
||||
await refresh();
|
||||
setMessage(
|
||||
`Prestige #${result.prestige.count} complete · chose ${CHOICE_LABELS[result.choice] || result.choice}. Account reset.`
|
||||
);
|
||||
setSelected(null);
|
||||
setConfirmText('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Prestige</h1>
|
||||
<p className="muted">
|
||||
Pay {formatCredits(cost)} cr, pick one permanent boost, then reset balance, inventory,
|
||||
keys, and catalog. History is kept. Unlocks at {formatCredits(unlockAt)} cr total wealth.
|
||||
</p>
|
||||
</div>
|
||||
{p && (
|
||||
<div className="prestige-count-pill">
|
||||
Prestige <strong>{p.count}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="ok-msg">{message}</div>}
|
||||
|
||||
{p && (
|
||||
<div className="prestige-totals">
|
||||
<div>
|
||||
<span className="muted">Chance</span>
|
||||
<strong>+{p.chanceBonus}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Gains</span>
|
||||
<strong>+{p.gainBonus}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Key opens</span>
|
||||
<strong>−{p.keyOpenReduction}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prestige-cost-row">
|
||||
<span className="muted">Cost</span>
|
||||
<strong className="gold">{formatCredits(cost)} cr</strong>
|
||||
<span className="muted">Your balance</span>
|
||||
<strong>{formatCredits(user.balance)} cr</strong>
|
||||
</div>
|
||||
|
||||
{!data?.canAfford && (
|
||||
<p className="muted">You need {formatCredits(cost)} cr to prestige.</p>
|
||||
)}
|
||||
{data?.lockedItems > 0 && (
|
||||
<div className="error">
|
||||
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles
|
||||
before prestiging.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prestige-choices">
|
||||
{(data?.choices || []).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`prestige-choice${selected === c.id ? ' selected' : ''}`}
|
||||
onClick={() => setSelected(c.id)}
|
||||
disabled={busy || !data?.canPrestige}
|
||||
>
|
||||
<h2>{c.title}</h2>
|
||||
<p className="muted">{c.description}</p>
|
||||
<div className="prestige-choice-meta">
|
||||
<span>
|
||||
Now <strong>+{c.current}{c.id === 'key' ? '' : '%'}</strong>
|
||||
{c.id === 'key' ? ' opens reduced' : ''}
|
||||
</span>
|
||||
<span className="prestige-delta">
|
||||
→ +{c.current + c.delta}
|
||||
{c.id === 'key' ? '' : '%'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="prestige-confirm admin-block">
|
||||
<p>
|
||||
This permanently spends {formatCredits(cost)} cr and wipes your balance, inventory, case
|
||||
keys, and catalog. Type <strong>PRESTIGE</strong> to confirm.
|
||||
</p>
|
||||
<label>
|
||||
Confirmation
|
||||
<input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="PRESTIGE"
|
||||
disabled={busy || !selected}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={
|
||||
busy || !data?.canPrestige || !selected || confirmText !== 'PRESTIGE'
|
||||
}
|
||||
onClick={onPrestige}
|
||||
>
|
||||
{busy ? 'Prestiging…' : 'Prestige now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data?.history?.length > 0 && (
|
||||
<div className="prestige-history">
|
||||
<h2>History</h2>
|
||||
<ul className="stat-list">
|
||||
{data.history.map((h) => (
|
||||
<li key={h.id}>
|
||||
{CHOICE_LABELS[h.choice] || h.choice}{' '}
|
||||
<span className="muted">
|
||||
· {new Date(h.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ProfileCaseStats from '../components/ProfileCaseStats';
|
||||
import ProfileCatalogBadges from '../components/ProfileCatalogBadges';
|
||||
import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
|
||||
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const { user, loading, refresh, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [bio, setBio] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
@@ -83,6 +88,17 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onLogout = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
@@ -95,6 +111,9 @@ export default function ProfilePage() {
|
||||
<h1>Profile</h1>
|
||||
<p className="muted">@{user.username}</p>
|
||||
</div>
|
||||
<Link to="/achievements" className="btn ghost">
|
||||
Achievements
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
@@ -121,27 +140,67 @@ export default function ProfilePage() {
|
||||
<div className="profile-stats admin-block">
|
||||
<h2>Stats</h2>
|
||||
{profile ? (
|
||||
<ul className="stat-list">
|
||||
<li>
|
||||
Joined{' '}
|
||||
<strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Inventory{' '}
|
||||
<strong>
|
||||
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Cases opened <strong>{profile.casesOpened}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Duel wins <strong>{profile.duelWins}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<>
|
||||
<ul className="stat-list">
|
||||
<li>
|
||||
Status{' '}
|
||||
<strong
|
||||
className={
|
||||
profile.presence?.isConnected
|
||||
? 'presence-online'
|
||||
: 'presence-offline'
|
||||
}
|
||||
>
|
||||
{profile.presence?.isConnected ? 'Online' : 'Offline'}
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Play time{' '}
|
||||
<strong>{formatPlayTime(profile.presence?.playTimeSeconds)}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Last connection{' '}
|
||||
<strong>
|
||||
{formatLastConnection(profile.presence?.lastConnection)}
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Joined{' '}
|
||||
<strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Inventory{' '}
|
||||
<strong>
|
||||
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Cases opened <strong>{profile.casesOpened}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Duel wins <strong>{profile.duelWins}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<ProfileAchievementBadges
|
||||
achievements={profile.achievements}
|
||||
linkToAchievements
|
||||
/>
|
||||
<ProfileCatalogBadges
|
||||
completed={profile.catalogCompleted}
|
||||
luckPercent={profile.catalogLuckPercent}
|
||||
linkToCatalog
|
||||
/>
|
||||
<ProfilePrestigeStats prestige={profile.prestige} />
|
||||
{profile.caseOpens?.length > 0 && (
|
||||
<div className="case-stats-block">
|
||||
<h3>Opens by case</h3>
|
||||
<ProfileCaseStats caseOpens={profile.caseOpens} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">Loading stats…</p>
|
||||
)}
|
||||
@@ -194,6 +253,16 @@ export default function ProfilePage() {
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="section admin-block">
|
||||
<h2>Session</h2>
|
||||
<p className="muted" style={{ marginBottom: '0.75rem' }}>
|
||||
Sign out of CaseForge on this device.
|
||||
</p>
|
||||
<button type="button" className="btn danger" onClick={onLogout} disabled={busy}>
|
||||
Logout
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -17,6 +18,10 @@ export default function Register() {
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await register(username, password);
|
||||
@@ -56,6 +61,17 @@ export default function Register() {
|
||||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button className="btn" type="submit" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create account'}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ShopPage() {
|
||||
}, [patchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -58,7 +58,7 @@ export default function ShopPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user, load]);
|
||||
}, [user?.id, user?.role, load]);
|
||||
|
||||
const buy = async (packageId) => {
|
||||
setBusy(true);
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
let socket = null;
|
||||
let limited = false;
|
||||
let limitPayload = null;
|
||||
const limitListeners = new Set();
|
||||
|
||||
function notifyLimit(payload) {
|
||||
limited = true;
|
||||
limitPayload = payload || { max: 10 };
|
||||
for (const fn of limitListeners) {
|
||||
try {
|
||||
fn(limitPayload);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notifyOk() {
|
||||
limited = false;
|
||||
limitPayload = null;
|
||||
for (const fn of limitListeners) {
|
||||
try {
|
||||
fn(null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getSocket() {
|
||||
if (!socket) {
|
||||
@@ -12,6 +39,41 @@ export function getSocket() {
|
||||
reconnectionDelay: 500,
|
||||
reconnectionAttempts: Infinity,
|
||||
});
|
||||
|
||||
socket.on('session:limit', (payload) => {
|
||||
socket.io.reconnection(false);
|
||||
notifyLimit(payload);
|
||||
});
|
||||
|
||||
socket.on('session:ok', () => {
|
||||
if (!socket.io.reconnection()) {
|
||||
socket.io.reconnection(true);
|
||||
}
|
||||
notifyOk();
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function isSessionLimited() {
|
||||
return limited;
|
||||
}
|
||||
|
||||
export function getSessionLimitPayload() {
|
||||
return limitPayload;
|
||||
}
|
||||
|
||||
/** Subscribe to limit changes. Callback receives payload or null when cleared. */
|
||||
export function onSessionLimitChange(fn) {
|
||||
limitListeners.add(fn);
|
||||
if (limited) fn(limitPayload);
|
||||
return () => limitListeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Allow reconnect after the user closes other tabs and reloads. */
|
||||
export function clearSessionLimit() {
|
||||
notifyOk();
|
||||
if (socket) {
|
||||
socket.io.reconnection(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const PREFIX = 'caseforge-pref';
|
||||
|
||||
function storageKey(userId, suffix) {
|
||||
return `${PREFIX}-${userId}-${suffix}`;
|
||||
}
|
||||
|
||||
export function loadPref(userId, suffix, fallback = null) {
|
||||
if (!userId || typeof window === 'undefined') return fallback;
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(userId, suffix));
|
||||
return raw ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePref(userId, suffix, value) {
|
||||
if (!userId || typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(storageKey(userId, suffix), String(value));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadInventorySort(userId) {
|
||||
const v = loadPref(userId, 'inventory-sort');
|
||||
return v === 'value' || v === 'recent' ? v : null;
|
||||
}
|
||||
|
||||
export function saveInventorySort(userId, sort) {
|
||||
if (sort === 'value' || sort === 'recent') {
|
||||
savePref(userId, 'inventory-sort', sort);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCaseOpenQty(userId, caseId) {
|
||||
const id = Number(caseId);
|
||||
if (!Number.isFinite(id)) return null;
|
||||
const raw = loadPref(userId, `case-qty-${id}`);
|
||||
if (raw == null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : null;
|
||||
}
|
||||
|
||||
export function saveCaseOpenQty(userId, caseId, qty) {
|
||||
const id = Number(caseId);
|
||||
if (!Number.isFinite(id)) return;
|
||||
savePref(userId, `case-qty-${id}`, Math.floor(qty));
|
||||
}
|
||||
|
||||
/** Pick best allowed open qty from saved preference. */
|
||||
export function resolveOpenQty(saved, openOptions, maxAffordable) {
|
||||
const affordable = Math.max(1, maxAffordable || 1);
|
||||
const allowed = openOptions.filter((n) => n <= affordable);
|
||||
if (!allowed.length) return 1;
|
||||
if (saved == null) {
|
||||
return allowed.includes(1) ? 1 : allowed[0];
|
||||
}
|
||||
if (allowed.includes(saved)) return saved;
|
||||
const below = allowed.filter((n) => n <= saved);
|
||||
return below.length ? below[below.length - 1] : allowed[0];
|
||||
}
|
||||
Reference in New Issue
Block a user