This commit is contained in:
gpatruno
2026-07-18 16:26:08 +02:00
parent 49c444e8b9
commit 2614d679c8
85 changed files with 8074 additions and 710 deletions
+3 -1
View File
@@ -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
View File
@@ -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>
+15 -5
View File
@@ -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
View File
@@ -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 '—';
}
}
+106 -21
View File
@@ -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 ? (
+68 -18
View File
@@ -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>
+36 -4
View File
@@ -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>
);
+193 -30
View File
@@ -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 isnt 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
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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);
}
+97
View File
@@ -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>
);
}
+2 -2
View File
@@ -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}
+257
View File
@@ -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>
);
}
-58
View File
@@ -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
View File
@@ -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;
/** 15, 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' }}>
+6
View File
@@ -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>
+125
View File
@@ -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>
);
}
+3 -3
View File
@@ -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
View File
@@ -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>
+4 -4
View File
@@ -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>
+25 -5
View File
@@ -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)}
/>
+148 -24
View File
@@ -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>
);
+44 -14
View File
@@ -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)}
/>
))}
+257
View File
@@ -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>
);
}
+199
View File
@@ -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>
);
}
+94 -25
View File
@@ -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>
);
}
+16
View File
@@ -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'}
+2 -2
View File
@@ -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);
+62
View File
@@ -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);
}
}
+63
View File
@@ -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];
}