update nice visuel
This commit is contained in:
+12
-18
@@ -1,4 +1,4 @@
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import { useAuth } from './AuthContext';
|
||||
import Admin from './pages/Admin';
|
||||
@@ -9,10 +9,7 @@ import Leaderboard from './pages/Leaderboard';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
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 BattleRoomPage from './pages/BattleRoomPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import PlayerProfilePage from './pages/PlayerProfilePage';
|
||||
import CatalogPage from './pages/CatalogPage';
|
||||
@@ -28,11 +25,6 @@ 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>
|
||||
@@ -48,15 +40,17 @@ export default function App() {
|
||||
<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="/battle/create" element={<BattleRoomPage create />} />
|
||||
<Route path="/battle/multi" element={<Navigate to="/battle/create" replace />} />
|
||||
<Route path="/battle/1vx" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/battle/1vx/:id" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/battle/coinflip" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/battle/:id" element={<BattleRoomPage />} />
|
||||
<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="/bet/multi" element={<Navigate to="/battle/create" replace />} />
|
||||
<Route path="/bet/1vx" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/bet/1vx/:id" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/bet/coinflip" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/player/:username" element={<PlayerProfilePage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { getSocket, refreshSocketSession } from './socket';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
@@ -22,6 +23,23 @@ export function AuthProvider({ children }) {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Keep Socket.IO presence in sync with HTTP session (login / logout / boot).
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
refreshSocketSession();
|
||||
}, [loading, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
const onReset = () => {
|
||||
refresh();
|
||||
};
|
||||
socket.on('server:prestige-reset', onReset);
|
||||
return () => {
|
||||
socket.off('server:prestige-reset', onReset);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const login = async (username, password) => {
|
||||
const data = await api.login(username, password);
|
||||
setUser(data.user);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, centsJson, toCentsBigInt } from '../api';
|
||||
import ImageField from './ImageField';
|
||||
|
||||
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
|
||||
@@ -62,7 +62,7 @@ export default function AdminCases() {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.createCase({ ...caseForm, price: Number(caseForm.price) });
|
||||
await api.createCase({ ...caseForm, price: centsJson(toCentsBigInt(caseForm.price)) });
|
||||
setCaseForm(emptyCase);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -112,7 +112,7 @@ export default function AdminCases() {
|
||||
try {
|
||||
await api.updateCase(editingId, {
|
||||
name: edit.name,
|
||||
price: Number(edit.price),
|
||||
price: centsJson(toCentsBigInt(edit.price)),
|
||||
imageUrl: edit.imageUrl,
|
||||
active: edit.active,
|
||||
});
|
||||
|
||||
@@ -78,6 +78,12 @@ export default function AdminDashboard() {
|
||||
<Link to="/admin/drops" className="btn secondary">
|
||||
Drop rates
|
||||
</Link>
|
||||
<Link to="/admin/shop" className="btn secondary">
|
||||
Shop osu packs
|
||||
</Link>
|
||||
<Link to="/admin/prestige-server" className="btn secondary">
|
||||
Prestige Server
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="admin-block" style={{ marginTop: '1.25rem' }}>
|
||||
|
||||
+139
-15
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, compareCents } from '../api';
|
||||
|
||||
function dropPercents(items) {
|
||||
const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0);
|
||||
@@ -7,12 +7,54 @@ function dropPercents(items) {
|
||||
return items.map((ci) => (Number(ci.dropRate) / total) * 100);
|
||||
}
|
||||
|
||||
function sortCatalog(list, sort) {
|
||||
const next = [...list];
|
||||
next.sort((a, b) => {
|
||||
if (sort === 'value-asc') return compareCents(a.marketValue, b.marketValue);
|
||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||
if (sort === 'rarity') {
|
||||
const r = a.rarity.localeCompare(b.rarity);
|
||||
if (r !== 0) return r;
|
||||
return compareCents(b.marketValue, a.marketValue);
|
||||
}
|
||||
// value-desc (default)
|
||||
return compareCents(b.marketValue, a.marketValue);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function sortPool(list, sort) {
|
||||
const next = [...list];
|
||||
next.sort((a, b) => {
|
||||
if (sort === 'value-desc') {
|
||||
return compareCents(b.item.marketValue, a.item.marketValue);
|
||||
}
|
||||
if (sort === 'value-asc') {
|
||||
return compareCents(a.item.marketValue, b.item.marketValue);
|
||||
}
|
||||
if (sort === 'name') return a.item.name.localeCompare(b.item.name);
|
||||
if (sort === 'rarity') {
|
||||
const r = a.item.rarity.localeCompare(b.item.rarity);
|
||||
if (r !== 0) return r;
|
||||
return compareCents(b.item.marketValue, a.item.marketValue);
|
||||
}
|
||||
if (sort === 'rate-desc') return Number(b.dropRate) - Number(a.dropRate);
|
||||
// rate-asc (default)
|
||||
return Number(a.dropRate) - Number(b.dropRate);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function AdminDrops() {
|
||||
const [cases, setCases] = useState([]);
|
||||
const [items, setItems] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedCaseId, setSelectedCaseId] = useState('');
|
||||
const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 });
|
||||
const [itemSort, setItemSort] = useState('value-desc');
|
||||
const [itemQuery, setItemQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [poolSort, setPoolSort] = useState('rate-asc');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]);
|
||||
@@ -22,10 +64,6 @@ export default function AdminDrops() {
|
||||
if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev;
|
||||
return c.cases[0] ? String(c.cases[0].id) : '';
|
||||
});
|
||||
setDropForm((f) => ({
|
||||
...f,
|
||||
itemId: f.itemId || (i.items[0] ? String(i.items[0].id) : ''),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,10 +75,43 @@ export default function AdminDrops() {
|
||||
[cases, selectedCaseId]
|
||||
);
|
||||
|
||||
const usedCategories = useMemo(() => {
|
||||
const set = new Set(items.map((item) => item.category || 'Misc'));
|
||||
return [...set].sort((a, b) => a.localeCompare(b));
|
||||
}, [items]);
|
||||
|
||||
const catalogItems = useMemo(() => {
|
||||
const q = itemQuery.trim().toLowerCase();
|
||||
const filtered = items.filter((item) => {
|
||||
const cat = item.category || 'Misc';
|
||||
if (categoryFilter !== 'all' && cat !== categoryFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.rarity.toLowerCase().includes(q) ||
|
||||
cat.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
return sortCatalog(filtered, itemSort);
|
||||
}, [items, itemQuery, itemSort, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalogItems.length) {
|
||||
setDropForm((f) => (f.itemId ? { ...f, itemId: '' } : f));
|
||||
return;
|
||||
}
|
||||
setDropForm((f) => {
|
||||
if (f.itemId && catalogItems.some((item) => String(item.id) === String(f.itemId))) {
|
||||
return f;
|
||||
}
|
||||
return { ...f, itemId: String(catalogItems[0].id) };
|
||||
});
|
||||
}, [catalogItems]);
|
||||
|
||||
const poolItems = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate));
|
||||
}, [selected]);
|
||||
return sortPool(selected.items, poolSort);
|
||||
}, [selected, poolSort]);
|
||||
|
||||
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
|
||||
|
||||
@@ -76,6 +147,37 @@ export default function AdminDrops() {
|
||||
Add drop · {selected.name}{' '}
|
||||
<span className="muted">({formatCredits(selected.price)} cr)</span>
|
||||
</h3>
|
||||
<div className="admin-toolbar" style={{ marginBottom: '0.75rem' }}>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
aria-label="Filter category"
|
||||
>
|
||||
<option value="all">All categories</option>
|
||||
{usedCategories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={itemSort}
|
||||
onChange={(e) => setItemSort(e.target.value)}
|
||||
aria-label="Sort catalog items"
|
||||
>
|
||||
<option value="value-desc">Sort: value ↓</option>
|
||||
<option value="value-asc">Sort: value ↑</option>
|
||||
<option value="name">Sort: name</option>
|
||||
<option value="rarity">Sort: rarity</option>
|
||||
</select>
|
||||
<input
|
||||
className="admin-search"
|
||||
placeholder="Search items…"
|
||||
value={itemQuery}
|
||||
onChange={(e) => setItemQuery(e.target.value)}
|
||||
aria-label="Search catalog items"
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
className="admin-row"
|
||||
onSubmit={async (e) => {
|
||||
@@ -95,12 +197,18 @@ export default function AdminDrops() {
|
||||
<select
|
||||
value={dropForm.itemId}
|
||||
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
|
||||
disabled={!catalogItems.length}
|
||||
aria-label="Select item to add"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.rarity})
|
||||
</option>
|
||||
))}
|
||||
{catalogItems.length === 0 ? (
|
||||
<option value="">No matching items</option>
|
||||
) : (
|
||||
catalogItems.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.rarity}) · {formatCredits(item.marketValue)} cr
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
@@ -111,14 +219,28 @@ export default function AdminDrops() {
|
||||
placeholder="Weight"
|
||||
required
|
||||
/>
|
||||
<button className="btn" type="submit">
|
||||
<button className="btn" type="submit" disabled={!dropForm.itemId}>
|
||||
Add drop
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-block">
|
||||
<h3>Pool ({poolItems.length})</h3>
|
||||
<div className="section-head" style={{ marginBottom: '0.75rem' }}>
|
||||
<h3 style={{ margin: 0 }}>Pool ({poolItems.length})</h3>
|
||||
<select
|
||||
value={poolSort}
|
||||
onChange={(e) => setPoolSort(e.target.value)}
|
||||
aria-label="Sort pool"
|
||||
>
|
||||
<option value="rate-asc">Sort: weight ↑</option>
|
||||
<option value="rate-desc">Sort: weight ↓</option>
|
||||
<option value="value-desc">Sort: value ↓</option>
|
||||
<option value="value-asc">Sort: value ↑</option>
|
||||
<option value="name">Sort: name</option>
|
||||
<option value="rarity">Sort: rarity</option>
|
||||
</select>
|
||||
</div>
|
||||
{poolItems.length === 0 ? (
|
||||
<div className="muted">No drops yet.</div>
|
||||
) : (
|
||||
@@ -130,7 +252,9 @@ export default function AdminDrops() {
|
||||
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
|
||||
) : null}
|
||||
{ci.item.name}{' '}
|
||||
<span className="muted">({ci.item.rarity})</span>
|
||||
<span className="muted">
|
||||
({ci.item.rarity}) · {formatCredits(ci.item.marketValue)} cr
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, compareCents, centsJson, toCentsBigInt } from '../api';
|
||||
import ImageField from './ImageField';
|
||||
|
||||
const emptyItem = {
|
||||
@@ -10,23 +10,21 @@ const emptyItem = {
|
||||
imageUrl: '',
|
||||
};
|
||||
|
||||
function CategoryField({ value, onChange, categories, id }) {
|
||||
const listId = id || 'item-categories';
|
||||
function CategoryField({ value, onChange, categories }) {
|
||||
const options =
|
||||
value && !categories.includes(value) ? [...categories, value] : categories;
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
list={listId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="e.g. Rifle, Knife…"
|
||||
required
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} required>
|
||||
{options.length === 0 ? (
|
||||
<option value="">No categories — create one first</option>
|
||||
) : (
|
||||
options.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,16 +49,23 @@ export default function AdminItems() {
|
||||
]);
|
||||
setItems(i.items);
|
||||
setRarities(r.rarities);
|
||||
setCategories(
|
||||
(c.names || c.categories || []).map((entry) =>
|
||||
typeof entry === 'string' ? entry : entry.name
|
||||
)
|
||||
const names = (c.names || c.categories || []).map((entry) =>
|
||||
typeof entry === 'string' ? entry : entry.name
|
||||
);
|
||||
setItemForm((f) => ({
|
||||
...f,
|
||||
rarity: f.rarity || r.rarities[0] || 'MilSpec',
|
||||
category: f.category || 'Misc',
|
||||
}));
|
||||
setCategories(names);
|
||||
setItemForm((f) => {
|
||||
const nextCategory =
|
||||
f.category && names.includes(f.category)
|
||||
? f.category
|
||||
: names.includes('Misc')
|
||||
? 'Misc'
|
||||
: names[0] || '';
|
||||
return {
|
||||
...f,
|
||||
rarity: f.rarity || r.rarities[0] || 'MilSpec',
|
||||
category: nextCategory,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +94,7 @@ export default function AdminItems() {
|
||||
list = [...list];
|
||||
list.sort((a, b) => {
|
||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||
if (sort === 'value') return Number(b.marketValue) - Number(a.marketValue);
|
||||
if (sort === 'value') return compareCents(b.marketValue, a.marketValue);
|
||||
if (sort === 'rarity') return a.rarity.localeCompare(b.rarity);
|
||||
// category (default): group by category, then name
|
||||
const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc');
|
||||
@@ -164,12 +169,14 @@ export default function AdminItems() {
|
||||
try {
|
||||
await api.createItem({
|
||||
...itemForm,
|
||||
marketValue: Number(itemForm.marketValue),
|
||||
marketValue: centsJson(toCentsBigInt(itemForm.marketValue)),
|
||||
});
|
||||
setItemForm({
|
||||
...emptyItem,
|
||||
rarity: rarities[0] || 'MilSpec',
|
||||
category: 'Misc',
|
||||
category: categories.includes('Misc')
|
||||
? 'Misc'
|
||||
: categories[0] || '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -188,7 +195,6 @@ export default function AdminItems() {
|
||||
<label>
|
||||
Category
|
||||
<CategoryField
|
||||
id="create-item-categories"
|
||||
value={itemForm.category}
|
||||
categories={categories}
|
||||
onChange={(category) => setItemForm({ ...itemForm, category })}
|
||||
@@ -247,7 +253,7 @@ export default function AdminItems() {
|
||||
name: edit.name,
|
||||
rarity: edit.rarity,
|
||||
category: edit.category,
|
||||
marketValue: Number(edit.marketValue),
|
||||
marketValue: centsJson(toCentsBigInt(edit.marketValue)),
|
||||
imageUrl: edit.imageUrl,
|
||||
});
|
||||
setEditingId(null);
|
||||
@@ -270,7 +276,6 @@ export default function AdminItems() {
|
||||
<label>
|
||||
Category
|
||||
<CategoryField
|
||||
id={`edit-item-categories-${item.id}`}
|
||||
value={edit.category}
|
||||
categories={categories}
|
||||
onChange={(category) => setEdit({ ...edit, category })}
|
||||
|
||||
@@ -8,6 +8,8 @@ const NAV = [
|
||||
{ to: '/admin/items', label: 'Items' },
|
||||
{ to: '/admin/categories', label: 'Categories' },
|
||||
{ to: '/admin/drops', label: 'Drops' },
|
||||
{ to: '/admin/shop', label: 'Shop' },
|
||||
{ to: '/admin/prestige-server', label: 'Prestige Server' },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
@@ -4,8 +4,105 @@ import {
|
||||
formatCredits,
|
||||
formatLastConnection,
|
||||
formatPlayTime,
|
||||
compareCents,
|
||||
creditsToCentsString,
|
||||
toCentsBigInt,
|
||||
} from '../api';
|
||||
|
||||
/** Credits delta (e.g. "15.5" or "-1000") → signed cents string. */
|
||||
function parseDeltaCents(raw) {
|
||||
const s = String(raw ?? '').trim().replace(',', '.');
|
||||
if (!s || s === '0' || s === '+0' || s === '-0') return null;
|
||||
const neg = s.startsWith('-');
|
||||
const body = s.startsWith('+') || s.startsWith('-') ? s.slice(1) : s;
|
||||
const cents = creditsToCentsString(body);
|
||||
if (cents == null || cents === '0') return null;
|
||||
return neg ? `-${cents}` : cents;
|
||||
}
|
||||
|
||||
/** Whole-unit delta (Pr / osu) — integers only. */
|
||||
function parseDeltaUnits(raw) {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!/^-?\d+$/.test(s) || s === '0' || s === '-0') return null;
|
||||
try {
|
||||
return BigInt(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function balanceAfterDelta(balance, delta) {
|
||||
try {
|
||||
const next = toCentsBigInt(balance) + toCentsBigInt(delta);
|
||||
return (next < 0n ? 0n : next).toString();
|
||||
} catch {
|
||||
return toCentsBigInt(balance).toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact display for whole units (Pr / osu) via credit formatter. */
|
||||
function formatUnits(n) {
|
||||
try {
|
||||
return formatCredits((BigInt(n) * 100n).toString());
|
||||
} catch {
|
||||
return String(n);
|
||||
}
|
||||
}
|
||||
|
||||
function previewClass(delta) {
|
||||
if (delta == null) return 'admin-adjust-preview muted';
|
||||
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
|
||||
return n > 0n ? 'admin-adjust-preview pos' : 'admin-adjust-preview neg';
|
||||
}
|
||||
|
||||
function formatSignedPreview(delta, suffix, isCents = false) {
|
||||
if (delta == null) return '—';
|
||||
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
|
||||
const abs = n < 0n ? -n : n;
|
||||
const formatted = isCents ? formatCredits(abs.toString()) : formatUnits(abs);
|
||||
return `${n > 0n ? '+' : '−'}${formatted} ${suffix}`;
|
||||
}
|
||||
|
||||
function AdjustRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
step = '1',
|
||||
delta,
|
||||
preview,
|
||||
result,
|
||||
onSubmit,
|
||||
busy,
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
className="admin-adjust-row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
{label}
|
||||
<span className="admin-adjust-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className={previewClass(delta)}>{preview}</span>
|
||||
</span>
|
||||
</label>
|
||||
{result != null && <span className="admin-adjust-result muted">{result}</span>}
|
||||
<button className="btn" type="submit" disabled={busy || delta == null}>
|
||||
Apply
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPlayers() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
@@ -13,8 +110,14 @@ export default function AdminPlayers() {
|
||||
const [sort, setSort] = useState('wealth');
|
||||
const [adjustId, setAdjustId] = useState(null);
|
||||
const [deltaCredits, setDeltaCredits] = useState('100');
|
||||
const [deltaPr, setDeltaPr] = useState('1000');
|
||||
const [deltaOsu, setDeltaOsu] = useState('100');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const deltaCents = useMemo(() => parseDeltaCents(deltaCredits), [deltaCredits]);
|
||||
const prDelta = useMemo(() => parseDeltaUnits(deltaPr), [deltaPr]);
|
||||
const osuDelta = useMemo(() => parseDeltaUnits(deltaOsu), [deltaOsu]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.adminUsers();
|
||||
setUsers(data.users);
|
||||
@@ -39,23 +142,24 @@ export default function AdminPlayers() {
|
||||
new Date(a.lastConnection || 0).getTime()
|
||||
);
|
||||
}
|
||||
return Number(b.totalWealth) - Number(a.totalWealth);
|
||||
return compareCents(b.totalWealth, a.totalWealth);
|
||||
});
|
||||
return list;
|
||||
}, [users, query, sort]);
|
||||
|
||||
const applyDelta = async (userId, credits) => {
|
||||
const cents = Math.round(Number(credits) * 100);
|
||||
if (!Number.isInteger(cents) || cents === 0) {
|
||||
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
|
||||
return;
|
||||
}
|
||||
const resetDeltas = () => {
|
||||
setDeltaCredits('100');
|
||||
setDeltaPr('1000');
|
||||
setDeltaOsu('100');
|
||||
};
|
||||
|
||||
const applyPatch = async (userId, body) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.adminUpdateUser(userId, { balanceDelta: cents });
|
||||
await api.adminUpdateUser(userId, body);
|
||||
setAdjustId(null);
|
||||
setDeltaCredits('100');
|
||||
resetDeltas();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -64,12 +168,44 @@ export default function AdminPlayers() {
|
||||
}
|
||||
};
|
||||
|
||||
const applyCredits = async (userId) => {
|
||||
const cents = parseDeltaCents(deltaCredits);
|
||||
if (cents == null) {
|
||||
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
|
||||
return;
|
||||
}
|
||||
await applyPatch(userId, { balanceDelta: cents });
|
||||
};
|
||||
|
||||
const applyPr = async (userId) => {
|
||||
const delta = parseDeltaUnits(deltaPr);
|
||||
if (delta == null) {
|
||||
setError('Enter a non-zero Pr amount (e.g. 1000 or -100)');
|
||||
return;
|
||||
}
|
||||
await applyPatch(userId, { prBalanceDelta: delta.toString() });
|
||||
};
|
||||
|
||||
const applyOsu = async (userId) => {
|
||||
const delta = parseDeltaUnits(deltaOsu);
|
||||
if (delta == null) {
|
||||
setError('Enter a non-zero osu amount (e.g. 100 or -50)');
|
||||
return;
|
||||
}
|
||||
// osuBalance is Int — stay within safe integer range for the JSON number API
|
||||
if (delta > BigInt(Number.MAX_SAFE_INTEGER) || delta < BigInt(Number.MIN_SAFE_INTEGER)) {
|
||||
setError('osu delta is too large');
|
||||
return;
|
||||
}
|
||||
await applyPatch(userId, { osuBalanceDelta: Number(delta) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Players</h1>
|
||||
<p className="muted">{users.length} accounts · adjust credits when needed</p>
|
||||
<p className="muted">{users.length} accounts · adjust credits, Pr & osu</p>
|
||||
</div>
|
||||
<div className="admin-toolbar">
|
||||
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
|
||||
@@ -108,8 +244,9 @@ export default function AdminPlayers() {
|
||||
{u.inventoryCount})
|
||||
</div>
|
||||
<div className="muted">
|
||||
Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '}
|
||||
{u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '}
|
||||
{formatUnits(u.prBalance ?? 0)} Pr · {formatUnits(u.osuBalance ?? 0)} osu · opens{' '}
|
||||
{u.casesOpened} · prestige {u.prestigeCount} · achievements {u.achievements} · play{' '}
|
||||
{formatPlayTime(u.playTimeSeconds)} · last{' '}
|
||||
{formatLastConnection(u.lastConnection)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,50 +255,62 @@ export default function AdminPlayers() {
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setAdjustId(adjustId === u.id ? null : u.id);
|
||||
setDeltaCredits('100');
|
||||
resetDeltas();
|
||||
}}
|
||||
>
|
||||
Adjust credits
|
||||
Adjust
|
||||
</button>
|
||||
</div>
|
||||
{adjustId === u.id && (
|
||||
<form
|
||||
className="admin-adjust-row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
applyDelta(u.id, deltaCredits);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Δ credits (e.g. 100 or -50)
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={deltaCredits}
|
||||
onChange={(e) => setDeltaCredits(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit" disabled={busy}>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => applyDelta(u.id, '1000')}
|
||||
disabled={busy}
|
||||
>
|
||||
+1000
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => applyDelta(u.id, '-100')}
|
||||
disabled={busy}
|
||||
>
|
||||
−100
|
||||
</button>
|
||||
</form>
|
||||
<div className="admin-adjust-panel">
|
||||
<AdjustRow
|
||||
label="Δ credits (e.g. 100 or -50)"
|
||||
value={deltaCredits}
|
||||
onChange={setDeltaCredits}
|
||||
step="0.01"
|
||||
delta={deltaCents == null ? null : BigInt(deltaCents)}
|
||||
preview={formatSignedPreview(
|
||||
deltaCents == null ? null : BigInt(deltaCents),
|
||||
'cr',
|
||||
true
|
||||
)}
|
||||
result={
|
||||
deltaCents == null
|
||||
? null
|
||||
: `→ bal ${formatCredits(balanceAfterDelta(u.balance, deltaCents))} cr`
|
||||
}
|
||||
onSubmit={() => applyCredits(u.id)}
|
||||
busy={busy}
|
||||
/>
|
||||
<AdjustRow
|
||||
label="Δ Pr (e.g. 1000 or -100)"
|
||||
value={deltaPr}
|
||||
onChange={setDeltaPr}
|
||||
delta={prDelta}
|
||||
preview={formatSignedPreview(prDelta, 'Pr')}
|
||||
result={
|
||||
prDelta == null
|
||||
? null
|
||||
: `→ ${formatUnits(balanceAfterDelta(u.prBalance ?? 0, prDelta))} Pr`
|
||||
}
|
||||
onSubmit={() => applyPr(u.id)}
|
||||
busy={busy}
|
||||
/>
|
||||
<AdjustRow
|
||||
label="Δ osu (e.g. 100 or -50)"
|
||||
value={deltaOsu}
|
||||
onChange={setDeltaOsu}
|
||||
delta={osuDelta}
|
||||
preview={formatSignedPreview(osuDelta, 'osu')}
|
||||
result={
|
||||
osuDelta == null
|
||||
? null
|
||||
: `→ ${formatUnits(balanceAfterDelta(u.osuBalance ?? 0, osuDelta))} osu`
|
||||
}
|
||||
onSubmit={() => applyOsu(u.id)}
|
||||
busy={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
function slugifyKey(raw) {
|
||||
return String(raw || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
export default function AdminPrestigeServer() {
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [achName, setAchName] = useState('Server Prestige');
|
||||
const [achKey, setAchKey] = useState('server_prestige');
|
||||
const [achKeyTouched, setAchKeyTouched] = useState(false);
|
||||
const [achDescription, setAchDescription] = useState(
|
||||
'Survived a Server Prestige wipe. Economy & prestige reset; stats & achievements kept.'
|
||||
);
|
||||
const [achIcon, setAchIcon] = useState('♛');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
const loadPreview = async () => {
|
||||
const data = await api.adminPrestigeServerPreview();
|
||||
setPreview(data.preview);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.adminPrestigeServerPreview();
|
||||
if (!cancelled) setPreview(data.preview);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const derivedKey = useMemo(() => {
|
||||
if (achKeyTouched) return achKey;
|
||||
const fromName = slugifyKey(achName);
|
||||
return fromName ? `server_${fromName}`.replace(/^server_server_/, 'server_') : 'server_prestige';
|
||||
}, [achName, achKey, achKeyTouched]);
|
||||
|
||||
const canSubmit =
|
||||
!busy &&
|
||||
achName.trim() &&
|
||||
achDescription.trim() &&
|
||||
password.length >= 1 &&
|
||||
confirmText.trim().toUpperCase() === 'PRESTIGE SERVER';
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setError('');
|
||||
setMessage('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api.adminPrestigeServer({
|
||||
password,
|
||||
achievement: {
|
||||
key: derivedKey,
|
||||
name: achName.trim(),
|
||||
description: achDescription.trim(),
|
||||
icon: achIcon.trim() || '♛',
|
||||
},
|
||||
});
|
||||
setPassword('');
|
||||
setConfirmText('');
|
||||
setMessage(
|
||||
`Server Prestige done · ${result.playersReset} players reset · achievement “${result.achievement.name}” granted`
|
||||
);
|
||||
await loadPreview();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Prestige Server</h1>
|
||||
<p className="muted">
|
||||
Wipe economy & prestige progress for every player. Kept: account (username, password,
|
||||
avatar, bio), <strong>Joined</strong>, <strong>play time</strong>,{' '}
|
||||
<strong>last connection</strong>, <strong>cases opened</strong>,{' '}
|
||||
<strong>jackpot / battle wins</strong>, and <strong>achievements</strong>. Then grants the
|
||||
wipe achievement configured below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="ok-msg">{message}</div>}
|
||||
|
||||
{preview && (
|
||||
<div className="admin-stat-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
<div className="admin-stat">
|
||||
<span className="admin-stat-label">Players to reset</span>
|
||||
<strong>{preview.players}</strong>
|
||||
</div>
|
||||
<div className="admin-stat">
|
||||
<span className="admin-stat-label">Inventory wiped</span>
|
||||
<strong>{preview.inventoryItems}</strong>
|
||||
</div>
|
||||
<div className="admin-stat">
|
||||
<span className="admin-stat-label">Prestige logs wiped</span>
|
||||
<strong>{preview.prestigeLogs}</strong>
|
||||
</div>
|
||||
<div className="admin-stat">
|
||||
<span className="admin-stat-label">Stats txs kept</span>
|
||||
<strong>{preview.statsTransactionsKept}</strong>
|
||||
<span className="muted">opens / battle wins</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="admin-block form" onSubmit={onSubmit}>
|
||||
<h2>Wipe achievement</h2>
|
||||
<p className="muted">
|
||||
Configure this before activating. Every reset player receives it (existing achievements
|
||||
stay).
|
||||
</p>
|
||||
|
||||
<div className="admin-form-grid">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={achName}
|
||||
onChange={(e) => setAchName(e.target.value)}
|
||||
required
|
||||
maxLength={80}
|
||||
placeholder="Server Prestige"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Icon
|
||||
<input
|
||||
value={achIcon}
|
||||
onChange={(e) => setAchIcon(e.target.value)}
|
||||
maxLength={8}
|
||||
placeholder="♛"
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-span">
|
||||
Key (unique id)
|
||||
<input
|
||||
value={derivedKey}
|
||||
onChange={(e) => {
|
||||
setAchKeyTouched(true);
|
||||
setAchKey(slugifyKey(e.target.value));
|
||||
}}
|
||||
maxLength={48}
|
||||
placeholder="server_prestige"
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-span">
|
||||
Description
|
||||
<textarea
|
||||
value={achDescription}
|
||||
onChange={(e) => setAchDescription(e.target.value)}
|
||||
required
|
||||
maxLength={240}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginTop: '1.5rem' }}>Confirm activation</h2>
|
||||
<p className="muted">
|
||||
Type <strong>PRESTIGE SERVER</strong> and enter the admin password. This cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="admin-form-grid">
|
||||
<label className="admin-form-span">
|
||||
Confirmation phrase
|
||||
<input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="PRESTIGE SERVER"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-span">
|
||||
Admin password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="row-actions" style={{ marginTop: '1rem' }}>
|
||||
<button type="submit" className="btn" disabled={!canSubmit}>
|
||||
{busy ? 'Wiping…' : 'Activate Prestige Server'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
function emptyEdit(pack) {
|
||||
return {
|
||||
name: pack.name || '',
|
||||
osu: pack.osu ?? 0,
|
||||
priceCents: pack.priceCents ?? 0,
|
||||
currency: pack.currency || 'eur',
|
||||
badge: pack.badge || '',
|
||||
blurb: pack.blurb || '',
|
||||
perOsuLabel: pack.perOsuLabel || '',
|
||||
enabled: Boolean(pack.enabled),
|
||||
sortOrder: pack.sortOrder ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminShop() {
|
||||
const [sectionEnabled, setSectionEnabled] = useState(true);
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [edit, setEdit] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.adminShopOsuPacks();
|
||||
setSectionEnabled(Boolean(data.sectionEnabled));
|
||||
setPacks(data.packs || []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((err) => setError(err.message));
|
||||
}, [load]);
|
||||
|
||||
const toggleSection = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const data = await api.adminShopOsuSection(!sectionEnabled);
|
||||
setSectionEnabled(Boolean(data.sectionEnabled));
|
||||
setPacks(data.packs || []);
|
||||
setMessage(
|
||||
data.sectionEnabled
|
||||
? 'Get osu section enabled on the shop'
|
||||
: 'Get osu section disabled on the shop'
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePack = async (pack) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.adminUpdateShopOsuPack(pack.id, { enabled: !pack.enabled });
|
||||
setPacks((prev) => prev.map((p) => (p.id === pack.id ? data.pack : p)));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveEdit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!editingId || !edit) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const data = await api.adminUpdateShopOsuPack(editingId, {
|
||||
name: edit.name,
|
||||
osu: Number(edit.osu),
|
||||
priceCents: Number(edit.priceCents),
|
||||
currency: edit.currency,
|
||||
badge: edit.badge || null,
|
||||
blurb: edit.blurb,
|
||||
perOsuLabel: edit.perOsuLabel,
|
||||
enabled: Boolean(edit.enabled),
|
||||
sortOrder: Number(edit.sortOrder),
|
||||
});
|
||||
setPacks((prev) => prev.map((p) => (p.id === editingId ? data.pack : p)));
|
||||
setEditingId(null);
|
||||
setEdit(null);
|
||||
setMessage(`Saved ${data.pack.name}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Shop · osu packs</h1>
|
||||
<p className="muted">
|
||||
Configure real-money → osu top-ups shown on /shop. Disable the whole section or
|
||||
individual packs.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn${sectionEnabled ? ' danger' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={toggleSection}
|
||||
>
|
||||
{sectionEnabled ? 'Disable Get osu section' : 'Enable Get osu section'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="ok-msg">{message}</div>}
|
||||
|
||||
{!sectionEnabled && (
|
||||
<div className="admin-block">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The <strong>Get osu</strong> grid is hidden for players. Packs below can still be
|
||||
edited.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-card-list">
|
||||
{packs.map((pack) => (
|
||||
<div key={pack.id} className="admin-block">
|
||||
{editingId === pack.id && edit ? (
|
||||
<form className="form admin-form-grid" onSubmit={saveEdit}>
|
||||
<h3 className="admin-form-span">
|
||||
Edit {pack.id}
|
||||
</h3>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={edit.name}
|
||||
onChange={(e) => setEdit({ ...edit, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Osu amount
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={edit.osu}
|
||||
onChange={(e) => setEdit({ ...edit, osu: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Price (EUR cents)
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={edit.priceCents}
|
||||
onChange={(e) => setEdit({ ...edit, priceCents: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Currency
|
||||
<input
|
||||
value={edit.currency}
|
||||
onChange={(e) => setEdit({ ...edit, currency: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Badge
|
||||
<input
|
||||
value={edit.badge}
|
||||
onChange={(e) => setEdit({ ...edit, badge: e.target.value })}
|
||||
placeholder="Popular, Best value…"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Sort order
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={edit.sortOrder}
|
||||
onChange={(e) => setEdit({ ...edit, sortOrder: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-span">
|
||||
Blurb
|
||||
<input
|
||||
value={edit.blurb}
|
||||
onChange={(e) => setEdit({ ...edit, blurb: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-span">
|
||||
Per-osu label
|
||||
<input
|
||||
value={edit.perOsuLabel}
|
||||
onChange={(e) => setEdit({ ...edit, perOsuLabel: e.target.value })}
|
||||
placeholder="€0.10 / osu"
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-span" style={{ flexDirection: 'row', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={edit.enabled}
|
||||
onChange={(e) => setEdit({ ...edit, enabled: e.target.checked })}
|
||||
/>
|
||||
Pack enabled
|
||||
</label>
|
||||
<div className="row-actions admin-form-span">
|
||||
<button className="btn" type="submit" disabled={busy}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setEdit(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="admin-row">
|
||||
<div className="admin-row-main">
|
||||
<strong>
|
||||
{pack.name}{' '}
|
||||
<span className="muted">({pack.id})</span>
|
||||
</strong>
|
||||
<div className="muted">
|
||||
{pack.osu} osu · {pack.displayPrice} · sort {pack.sortOrder}
|
||||
{pack.badge ? ` · ${pack.badge}` : ''}
|
||||
{' · '}
|
||||
<span className={pack.enabled ? 'admin-pill-on' : 'admin-pill-off'}>
|
||||
{pack.enabled ? 'enabled' : 'disabled'}
|
||||
</span>
|
||||
</div>
|
||||
{pack.blurb ? <div className="muted">{pack.blurb}</div> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditingId(pack.id);
|
||||
setEdit(emptyEdit(pack));
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn${pack.enabled ? ' ghost' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={() => togglePack(pack)}
|
||||
>
|
||||
{pack.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function ImageField({ value, onChange, disabled }) {
|
||||
{uploading ? '…' : 'Upload'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
|
||||
hidden
|
||||
onChange={onFile}
|
||||
disabled={disabled || uploading}
|
||||
|
||||
+145
-39
@@ -35,10 +35,10 @@ export const api = {
|
||||
catalog: () => request('/api/catalog'),
|
||||
prestige: () => request('/api/prestige'),
|
||||
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
|
||||
prestigeUpgrade: (skillId) =>
|
||||
prestigeUpgrade: (skillId, count = 1) =>
|
||||
request('/api/prestige/upgrade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ skillId }),
|
||||
body: JSON.stringify({ skillId, count }),
|
||||
}),
|
||||
openCase: (id, count = 1) =>
|
||||
request(`/api/cases/${id}/open`, {
|
||||
@@ -56,7 +56,6 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
vaultClaim: () => request('/api/vault/claim', { method: 'POST' }),
|
||||
sellInventory: (inventoryItemIds) =>
|
||||
request('/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
@@ -83,41 +82,65 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ packageId }),
|
||||
}),
|
||||
shopOsuCheckout: (packId) =>
|
||||
request('/api/shop/osu/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ packId }),
|
||||
}),
|
||||
shopOsuConfirm: (orderId) =>
|
||||
request('/api/shop/osu/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ orderId }),
|
||||
}),
|
||||
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
|
||||
feed: () => request('/api/feed'),
|
||||
feedAnnounce: (inventoryItemIds, caseName) => {
|
||||
feedAnnounce: (inventoryItemIds, caseName, opts = {}) => {
|
||||
const ids = Array.isArray(inventoryItemIds)
|
||||
? inventoryItemIds
|
||||
: [inventoryItemIds];
|
||||
: inventoryItemIds != null
|
||||
? [inventoryItemIds]
|
||||
: [];
|
||||
const body = {
|
||||
inventoryItemIds: ids.filter(Boolean),
|
||||
caseName,
|
||||
respin: Boolean(opts.respin),
|
||||
};
|
||||
if (opts.snapshotBest) {
|
||||
body.snapshotBest = opts.snapshotBest;
|
||||
body.openCount = opts.openCount;
|
||||
body.count = opts.count;
|
||||
} else if (opts.snapshotItems) {
|
||||
body.snapshotItems = opts.snapshotItems;
|
||||
}
|
||||
return request('/api/feed/announce', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds: ids, caseName }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
leaderboard: () => request('/api/leaderboard'),
|
||||
jackpot: () => request('/api/jackpot'),
|
||||
jackpotBet: (inventoryItemIds) =>
|
||||
jackpotRound: (id) => request(`/api/jackpot/${id}`),
|
||||
jackpotCreateBet: (inventoryItemIds) =>
|
||||
request('/api/jackpot/bet', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
jackpotWithdraw: (inventoryItemIds) =>
|
||||
request('/api/jackpot/withdraw', {
|
||||
jackpotBet: (id, inventoryItemIds) =>
|
||||
request(`/api/jackpot/${id}/bet`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
duels: () => request('/api/duels'),
|
||||
duel: (id) => request(`/api/duels/${id}`),
|
||||
createDuel: (body) =>
|
||||
request('/api/duels', { method: 'POST', body: JSON.stringify(body) }),
|
||||
duelBet: (id, inventoryItemIds) =>
|
||||
request(`/api/duels/${id}/bet`, {
|
||||
jackpotWithdraw: (id, inventoryItemIds) =>
|
||||
request(`/api/jackpot/${id}/withdraw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
// legacy alias
|
||||
jackpotCreate: (inventoryItemIds) =>
|
||||
request('/api/jackpot/bet', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
duelLeave: (id) => request(`/api/duels/${id}/leave`, { method: 'POST' }),
|
||||
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)}`),
|
||||
@@ -140,6 +163,12 @@ export const api = {
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
adminStats: () => request('/api/admin/stats'),
|
||||
adminPrestigeServerPreview: () => request('/api/admin/prestige-server'),
|
||||
adminPrestigeServer: (body) =>
|
||||
request('/api/admin/prestige-server', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
adminUsers: () => request('/api/admin/users'),
|
||||
adminUpdateUser: (id, body) =>
|
||||
request(`/api/admin/users/${id}`, {
|
||||
@@ -150,6 +179,17 @@ export const api = {
|
||||
adminItems: () => request('/api/admin/items'),
|
||||
adminRarities: () => request('/api/admin/rarities'),
|
||||
adminCategories: () => request('/api/admin/categories'),
|
||||
adminShopOsuPacks: () => request('/api/admin/shop/osu-packs'),
|
||||
adminShopOsuSection: (enabled) =>
|
||||
request('/api/admin/shop/osu-section', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
adminUpdateShopOsuPack: (id, body) =>
|
||||
request(`/api/admin/shop/osu-packs/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
createCategory: (body) =>
|
||||
request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateCategory: (id, body) =>
|
||||
@@ -186,17 +226,77 @@ export const api = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Exact credit display (cents → "1 234.56"), for tooltips / confirmations.
|
||||
* Parse money amounts (cents) to BigInt — mirror of server `cents.js`.
|
||||
* Accepts bigint, number, or decimal/int string. Truncates toward zero.
|
||||
*/
|
||||
export function toCentsBigInt(value) {
|
||||
if (typeof value === 'bigint') return value;
|
||||
if (value == null || value === '') return 0n;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return 0n;
|
||||
return BigInt(Math.trunc(value));
|
||||
}
|
||||
const s = String(value).trim();
|
||||
if (!s) return 0n;
|
||||
const m = s.match(/^([+-]?\d+)/);
|
||||
if (!m) return 0n;
|
||||
try {
|
||||
return BigInt(m[1]);
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON-safe cents string. */
|
||||
export function centsJson(value) {
|
||||
return toCentsBigInt(value).toString();
|
||||
}
|
||||
|
||||
/** Sort/compare helper: negative if a < b, 0 if equal, positive if a > b. */
|
||||
export function compareCents(a, b) {
|
||||
const aa = toCentsBigInt(a);
|
||||
const bb = toCentsBigInt(b);
|
||||
if (aa < bb) return -1;
|
||||
if (aa > bb) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Exact credit display (cents → "1 234.56"), for tooltips / confirmations. */
|
||||
export function formatCreditsExact(cents) {
|
||||
const n = Number(cents);
|
||||
if (!Number.isFinite(n)) return '0.00';
|
||||
const fixed = (n / 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}`;
|
||||
let bi = toCentsBigInt(cents);
|
||||
const neg = bi < 0n;
|
||||
if (neg) bi = -bi;
|
||||
const whole = bi / 100n;
|
||||
const frac = (bi % 100n).toString().padStart(2, '0');
|
||||
const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
|
||||
return `${neg ? '-' : ''}${grouped}.${frac}`;
|
||||
}
|
||||
|
||||
/** Parse credits input (decimal string) → integer cents string. Null if invalid. */
|
||||
export function creditsToCentsString(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
const s = String(raw).trim().replace(',', '.');
|
||||
const m = s.match(/^(\d+)(?:\.(\d{1,2})\d*)?$/);
|
||||
if (!m) return null;
|
||||
try {
|
||||
const whole = BigInt(m[1]);
|
||||
const frac = m[2] ? m[2].padEnd(2, '0') : '00';
|
||||
return (whole * 100n + BigInt(frac)).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cents → credits decimal string without Number precision loss. */
|
||||
export function centsToCreditsString(cents) {
|
||||
let bi = toCentsBigInt(cents);
|
||||
const neg = bi < 0n;
|
||||
const abs = neg ? -bi : bi;
|
||||
const whole = abs / 100n;
|
||||
const frac = (abs % 100n).toString().padStart(2, '0');
|
||||
const body =
|
||||
frac === '00' ? whole.toString() : `${whole}.${frac}`.replace(/0+$/, '').replace(/\.$/, '');
|
||||
return `${neg ? '-' : ''}${body}`;
|
||||
}
|
||||
|
||||
/** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */
|
||||
@@ -225,25 +325,31 @@ function tierSuffix(tier) {
|
||||
* K / M / B / T, then aa, ab, … (×1000 each). Input is cents.
|
||||
*/
|
||||
export function formatCredits(cents) {
|
||||
const n = Number(cents);
|
||||
if (!Number.isFinite(n)) return '0.00';
|
||||
const neg = n < 0;
|
||||
const credits = Math.abs(n) / 100;
|
||||
let bi = toCentsBigInt(cents);
|
||||
const neg = bi < 0n;
|
||||
if (neg) bi = -bi;
|
||||
|
||||
if (credits < 1000) {
|
||||
return `${neg ? '-' : ''}${credits.toFixed(2)}`;
|
||||
const creditsWhole = bi / 100n;
|
||||
const creditsFrac = bi % 100n;
|
||||
|
||||
if (creditsWhole < 1000n) {
|
||||
return `${neg ? '-' : ''}${creditsWhole}.${creditsFrac.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
let tier = 0;
|
||||
let scaled = credits;
|
||||
while (scaled >= 1000 && tier < 1000) {
|
||||
scaled /= 1000;
|
||||
let scaled = creditsWhole;
|
||||
let rem = 0n;
|
||||
while (scaled >= 1000n) {
|
||||
rem = scaled % 1000n;
|
||||
scaled /= 1000n;
|
||||
tier += 1;
|
||||
}
|
||||
|
||||
const suffix = tierSuffix(tier);
|
||||
const shown = String(Number(scaled.toFixed(2)));
|
||||
return `${neg ? '-' : ''}${shown}${suffix}`;
|
||||
// scaled < 1000 → safe as Number; rem gives ~3 decimal digits of precision
|
||||
const approx = Number(scaled) + Number(rem) / 1000;
|
||||
if (!Number.isFinite(approx)) return `${neg ? '-' : ''}0${tierSuffix(tier)}`;
|
||||
const shown = String(Number(approx.toFixed(2)));
|
||||
return `${neg ? '-' : ''}${shown}${tierSuffix(tier)}`;
|
||||
}
|
||||
|
||||
export function formatChance(pct) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { formatCredits, compareCents } from '../api';
|
||||
import CaseReel from './CaseReel';
|
||||
import { useCountdown } from '../hooks/useCountdown';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
const TOP_ITEMS = 5;
|
||||
|
||||
function depositsToReelItems(deposits) {
|
||||
return (deposits || [])
|
||||
.filter((d) => d.item)
|
||||
.map((d) => ({
|
||||
...d.item,
|
||||
id: d.id,
|
||||
ownerUsername: d.username,
|
||||
ownerAvatarUrl: d.avatarUrl || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Live battle preview card for the hub (mode-card-live).
|
||||
*/
|
||||
export default function BattleLiveCard({ battle, spinEvent }) {
|
||||
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 (!spinEvent || spinEvent.id !== battle?.id) return;
|
||||
setSpinDeposits(spinEvent.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setRevealWinner(false);
|
||||
setReelWinnerItemId(spinEvent.reelWinnerItemId);
|
||||
setLastWinner(spinEvent.winnerUsername);
|
||||
}, [spinEvent, battle?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (battle?.status === 'spinning' && !spinActive) {
|
||||
setSpinDeposits(battle.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setLastWinner(battle.winnerUsername || null);
|
||||
}
|
||||
}, [battle, spinActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealWinner) return undefined;
|
||||
const t = setTimeout(() => {
|
||||
setSpinActive(false);
|
||||
setRevealWinner(false);
|
||||
}, 4000);
|
||||
return () => clearTimeout(t);
|
||||
}, [revealWinner]);
|
||||
|
||||
const topItems = useMemo(() => {
|
||||
const deposits = [...(battle?.deposits || [])].filter((d) => d.item);
|
||||
deposits.sort((a, b) => compareCents(b.valueCents, a.valueCents));
|
||||
return deposits.slice(0, TOP_ITEMS);
|
||||
}, [battle]);
|
||||
|
||||
const potItems = useMemo(
|
||||
() =>
|
||||
depositsToReelItems(
|
||||
spinActive && spinDeposits.length ? spinDeposits : battle?.deposits
|
||||
),
|
||||
[battle, spinDeposits, spinActive]
|
||||
);
|
||||
|
||||
const showReel = spinActive && potItems.length > 0;
|
||||
|
||||
const liveTimerEndsAt =
|
||||
battle?.status === 'open' &&
|
||||
battle?.endsAt &&
|
||||
!battle?.waitingForPlayers &&
|
||||
!spinning
|
||||
? battle.endsAt
|
||||
: null;
|
||||
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
|
||||
|
||||
const playerCount = battle?.players?.length ?? 0;
|
||||
const potValue = battle?.totalValue ?? 0;
|
||||
const itemCount = battle?.deposits?.length ?? 0;
|
||||
const statusLabel =
|
||||
battle?.status === 'spinning' || spinning
|
||||
? 'Spinning'
|
||||
: battle?.waitingForPlayers
|
||||
? 'Waiting for players'
|
||||
: battle?.status === 'open'
|
||||
? liveTimerEndsAt
|
||||
? `Live ${liveTimerLabel}`
|
||||
: 'Live'
|
||||
: battle?.status || '…';
|
||||
|
||||
if (!battle) return null;
|
||||
|
||||
return (
|
||||
<Link to={`/battle/${battle.id}`} className="mode-card mode-card-live">
|
||||
<div className="mode-card-top">
|
||||
<h2>{battle.name || `Battle ${battle.id}`}</h2>
|
||||
<span
|
||||
className={`mode-status${
|
||||
battle.status === 'spinning' || spinning ? ' spinning' : ''
|
||||
}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted mode-blurb">
|
||||
Join the pot. Bet up to {battle.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 || battle.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>
|
||||
);
|
||||
}
|
||||
@@ -55,13 +55,13 @@ export default function DropFeed() {
|
||||
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) &&
|
||||
Boolean(d.respin) === Boolean(entry.respin) &&
|
||||
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
|
||||
);
|
||||
if (nearDup) return prev;
|
||||
@@ -69,7 +69,6 @@ export default function DropFeed() {
|
||||
});
|
||||
};
|
||||
|
||||
// Prefer socket history; HTTP is fallback if socket is slow
|
||||
socket.emit('feed:subscribe');
|
||||
socket.on('feed:history', onHistory);
|
||||
socket.on('feed:drop', onDrop);
|
||||
@@ -117,7 +116,11 @@ export default function DropFeed() {
|
||||
visible.map((d) => {
|
||||
const color = rarityColor(d.item?.rarity);
|
||||
return (
|
||||
<div key={d.id} className="drop-feed-row" style={{ borderLeftColor: color }}>
|
||||
<div
|
||||
key={d.id}
|
||||
className={`drop-feed-row${d.respin ? ' is-respin' : ''}`}
|
||||
style={{ borderLeftColor: color }}
|
||||
>
|
||||
<div className="drop-feed-item-visual" style={{ color }}>
|
||||
{d.item?.imageUrl ? (
|
||||
<img src={d.item.imageUrl} alt="" />
|
||||
@@ -148,16 +151,18 @@ export default function DropFeed() {
|
||||
{d.username}
|
||||
</>
|
||||
) : (
|
||||
d.username
|
||||
<>
|
||||
{d.respin ? (
|
||||
<span className="drop-feed-respin-label">Respin</span>
|
||||
) : null}
|
||||
{d.respin ? ' ' : null}
|
||||
{d.username}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="muted drop-feed-sub">
|
||||
{d.source === 'jackpot'
|
||||
? 'MultiPlayers Case · '
|
||||
: d.caseName
|
||||
? `${d.caseName} · `
|
||||
: ''}
|
||||
{d.caseName ? `${d.caseName} · ` : ''}
|
||||
{formatCredits(d.item?.marketValue || 0)} cr
|
||||
{d.openCount > 1
|
||||
? d.source === 'jackpot'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { formatCredits, formatCreditsExact } from '../api';
|
||||
import { formatCredits, formatCreditsExact, toCentsBigInt } from '../api';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
/** Instance sell/bet price, else catalog FT reference */
|
||||
/** Instance sell/bet price, else catalog FT reference (BigInt cents). */
|
||||
export function itemValueCents(item) {
|
||||
if (!item) return 0;
|
||||
if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) {
|
||||
return Number(item.valueCents);
|
||||
if (!item) return 0n;
|
||||
if (item.valueCents != null && item.valueCents !== '') {
|
||||
return toCentsBigInt(item.valueCents);
|
||||
}
|
||||
return Number(item.marketValue) || 0;
|
||||
return toCentsBigInt(item.marketValue);
|
||||
}
|
||||
|
||||
export function formatFloat(f) {
|
||||
@@ -41,6 +41,7 @@ export default function ItemTile({
|
||||
const titleParts = [];
|
||||
if (staked) titleParts.push('Vault');
|
||||
if (favorite) titleParts.push('Favorite');
|
||||
if (item.rarity) titleParts.push(item.rarity);
|
||||
if (floatTitle) titleParts.push(floatTitle);
|
||||
else titleParts.push(item.name);
|
||||
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
|
||||
@@ -76,16 +77,13 @@ export default function ItemTile({
|
||||
)}
|
||||
</div>
|
||||
<div className="item-name">{item.name}</div>
|
||||
<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" title={`${formatCreditsExact(value)} cr`}>
|
||||
<div className="item-meta item-price" title={`${formatCreditsExact(value)} cr`}>
|
||||
{formatCredits(value)} cr
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api, formatCredits, formatCreditsExact } from '../api';
|
||||
import DropFeed from './DropFeed';
|
||||
import SessionLimitOverlay from './SessionLimitOverlay';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket, onSessionLimitChange } from '../socket';
|
||||
import { getSocket, onSessionLimitChange, getSessionLimitPayload } from '../socket';
|
||||
|
||||
const FEED_KEY = 'caseorion-feed-drop';
|
||||
|
||||
@@ -17,27 +17,20 @@ function ScrollToTop() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function countWaitingBattles(jackpotRound, duelOpen) {
|
||||
function countWaitingBattles(jackpotBattles) {
|
||||
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;
|
||||
for (const battle of jackpotBattles || []) {
|
||||
if (battle.status === 'spinning') continue;
|
||||
const jpPlayers = battle?.players?.length ?? 0;
|
||||
const jpDeposits = battle?.deposits?.length ?? 0;
|
||||
if (
|
||||
battle?.waitingForPlayers &&
|
||||
battle.status === 'open' &&
|
||||
(jpPlayers > 0 || jpDeposits > 0)
|
||||
) {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
@@ -45,6 +38,7 @@ function countWaitingBattles(jackpotRound, duelOpen) {
|
||||
|
||||
export default function Layout() {
|
||||
const { user } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const [feedOpen, setFeedOpen] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(FEED_KEY) === '1';
|
||||
@@ -54,7 +48,12 @@ export default function Layout() {
|
||||
});
|
||||
const [waitingBattles, setWaitingBattles] = useState(0);
|
||||
const [prestigeVisible, setPrestigeVisible] = useState(false);
|
||||
const [sessionLimit, setSessionLimit] = useState(null);
|
||||
const [sessionLimit, setSessionLimit] = useState(() => getSessionLimitPayload());
|
||||
const [navHidden, setNavHidden] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setNavHidden(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -64,6 +63,47 @@ export default function Layout() {
|
||||
}
|
||||
}, [feedOpen]);
|
||||
|
||||
// Mobile: hide topnav while scrolling down, reveal on scroll up / near top
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 700px)');
|
||||
let lastY = window.scrollY || 0;
|
||||
let hidden = false;
|
||||
|
||||
const apply = (next) => {
|
||||
if (hidden === next) return;
|
||||
hidden = next;
|
||||
setNavHidden(next);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!mq.matches) {
|
||||
apply(false);
|
||||
lastY = window.scrollY || 0;
|
||||
return;
|
||||
}
|
||||
const y = window.scrollY || 0;
|
||||
const delta = y - lastY;
|
||||
lastY = y;
|
||||
if (y < 48) {
|
||||
apply(false);
|
||||
return;
|
||||
}
|
||||
if (delta > 6) apply(true);
|
||||
else if (delta < -6) apply(false);
|
||||
};
|
||||
|
||||
const onMq = () => {
|
||||
if (!mq.matches) apply(false);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
mq.addEventListener?.('change', onMq);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
mq.removeEventListener?.('change', onMq);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') {
|
||||
setPrestigeVisible(false);
|
||||
@@ -97,24 +137,22 @@ export default function Layout() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let jackpotRound = null;
|
||||
let duelOpen = [];
|
||||
let jackpotBattles = [];
|
||||
let cancelled = false;
|
||||
|
||||
const sync = () => {
|
||||
if (cancelled) return;
|
||||
setWaitingBattles((prev) => {
|
||||
const next = countWaitingBattles(jackpotRound, duelOpen);
|
||||
const next = countWaitingBattles(jackpotBattles);
|
||||
return prev === next ? prev : next;
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
|
||||
const jp = await api.jackpot();
|
||||
if (cancelled) return;
|
||||
jackpotRound = jp.round;
|
||||
duelOpen = duels.open || [];
|
||||
jackpotBattles = jp.battles || [];
|
||||
sync();
|
||||
} catch {
|
||||
/* nav badge is optional */
|
||||
@@ -125,35 +163,22 @@ export default function Layout() {
|
||||
const offLimitChange = onSessionLimitChange((payload) => {
|
||||
setSessionLimit(payload);
|
||||
});
|
||||
const onJackpot = (state) => {
|
||||
jackpotRound = state;
|
||||
sync();
|
||||
};
|
||||
const onDuels = (list) => {
|
||||
duelOpen = list.open || [];
|
||||
const onJackpots = (payload) => {
|
||||
jackpotBattles = payload.battles || [];
|
||||
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.emit('jackpots:subscribe');
|
||||
socket.on('jackpots:list', onJackpots);
|
||||
socket.on('connect', () => {
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
socket.emit('jackpots: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);
|
||||
socket.emit('jackpots:unsubscribe');
|
||||
socket.off('jackpots:list', onJackpots);
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
@@ -164,8 +189,8 @@ export default function Layout() {
|
||||
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
|
||||
>
|
||||
<ScrollToTop />
|
||||
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 10} />}
|
||||
<header className="topnav">
|
||||
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 2} />}
|
||||
<header className={`topnav${navHidden ? ' topnav--hidden' : ''}`}>
|
||||
<div className="topnav-left">
|
||||
<NavLink to="/dashboard" className="brand">
|
||||
CaseOrion
|
||||
|
||||
@@ -2,8 +2,14 @@ export default function ProfilePrestigeStats({ prestige }) {
|
||||
if (!prestige || !prestige.count) return null;
|
||||
|
||||
const rows = [
|
||||
typeof prestige.prBalance === 'number' && prestige.prBalance > 0
|
||||
? { label: 'Pr', value: <strong>{prestige.prBalance}</strong> }
|
||||
(() => {
|
||||
try {
|
||||
return BigInt(String(prestige.prBalance ?? 0)) > 0n;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
? { label: 'Pr', value: <strong>{String(prestige.prBalance)}</strong> }
|
||||
: null,
|
||||
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
|
||||
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
|
||||
@@ -15,9 +21,9 @@ export default function ProfilePrestigeStats({ prestige }) {
|
||||
label: 'Bulk',
|
||||
value: <strong>+{prestige.openBonus} opens</strong>,
|
||||
},
|
||||
(prestige.caseDiscount || 0) > 0 && {
|
||||
label: 'Discount',
|
||||
value: <strong>−{prestige.caseDiscount}%</strong>,
|
||||
(prestige.prBonus || 0) > 0 && {
|
||||
label: 'PR Farmer',
|
||||
value: <strong>+{prestige.prBonus}%</strong>,
|
||||
},
|
||||
(prestige.qualityBonus || 0) > 0 && {
|
||||
label: 'Quality',
|
||||
|
||||
@@ -18,7 +18,7 @@ const FAKE_ADS = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function SessionLimitOverlay({ max = 10 }) {
|
||||
export default function SessionLimitOverlay({ max = 2 }) {
|
||||
const [ad] = useState(
|
||||
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
|
||||
);
|
||||
|
||||
+493
-16
@@ -89,6 +89,20 @@ h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
transition: transform 280ms ease;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.topnav.topnav--hidden {
|
||||
transform: translateY(-110%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.topnav {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -321,11 +335,23 @@ h3 {
|
||||
|
||||
.drop-feed {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
max-height: none;
|
||||
position: static;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.drop-feed-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* One drop row visible; scroll for the rest */
|
||||
.drop-feed-list {
|
||||
flex: none;
|
||||
max-height: calc(1rem + 2 * 0.45rem + 5.75rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-pill {
|
||||
@@ -384,9 +410,13 @@ a.balance-pill-link.active {
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: transparent;
|
||||
border-color: rgba(235, 75, 75, 0.5);
|
||||
color: var(--danger);
|
||||
background: var(--danger);
|
||||
border-color: transparent;
|
||||
color: #1a0606;
|
||||
}
|
||||
|
||||
.btn.danger:hover:not(:disabled) {
|
||||
background: #f06565;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
@@ -668,7 +698,7 @@ a.balance-pill-link.active {
|
||||
}
|
||||
|
||||
.item-wear {
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
@@ -693,6 +723,39 @@ a.balance-pill-link.active {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.case-hero-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 420ms ease;
|
||||
}
|
||||
|
||||
.case-hero-wrap > .case-hero {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 320ms ease,
|
||||
transform 420ms ease;
|
||||
}
|
||||
|
||||
.case-hero-wrap--hidden {
|
||||
grid-template-rows: 0fr;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.case-hero-wrap--hidden > .case-hero {
|
||||
opacity: 0;
|
||||
transform: translateY(-1.25rem);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.case-hero-wrap,
|
||||
.case-hero-wrap > .case-hero {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.case-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -734,7 +797,8 @@ a.balance-pill-link.active {
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
.case-key-badge {
|
||||
.case-price,
|
||||
.item-price {
|
||||
color: var(--gold);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
@@ -768,6 +832,11 @@ a.balance-pill-link.active {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.multi-reel-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.multi-reels.many {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@@ -1703,6 +1772,15 @@ a.balance-pill-link.active {
|
||||
border-top: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.admin-adjust-panel .admin-adjust-row:first-child {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-adjust-panel .admin-adjust-row + .admin-adjust-row {
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
|
||||
.admin-adjust-row label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1719,6 +1797,35 @@ a.balance-pill-link.active {
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.admin-adjust-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.admin-adjust-preview {
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.admin-adjust-preview.pos {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-adjust-preview.neg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-adjust-result {
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
align-self: end;
|
||||
padding-bottom: 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1843,18 +1950,24 @@ a.balance-pill-link.active {
|
||||
|
||||
.item-vault-badge {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
left: 0.35rem;
|
||||
z-index: 1;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
top: 0.3rem;
|
||||
left: 0.3rem;
|
||||
z-index: 2;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
padding: 0 0.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.2rem;
|
||||
background: rgba(80, 160, 220, 0.35);
|
||||
color: #9fd4ff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem;
|
||||
background: #0a121c;
|
||||
color: #e8f6ff;
|
||||
border: 1.5px solid #7ec8ff;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.85),
|
||||
0 2px 6px rgba(0, 0, 0, 0.75);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1869,6 +1982,24 @@ a.balance-pill-link.active {
|
||||
|
||||
.vault-panel .section-head {
|
||||
margin-bottom: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vault-timer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.15rem;
|
||||
min-width: 7.5rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.vault-timer strong {
|
||||
font-size: 1.35rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.vault-stats {
|
||||
@@ -1881,8 +2012,60 @@ a.balance-pill-link.active {
|
||||
|
||||
.vault-grid {
|
||||
margin-bottom: 0.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 112px));
|
||||
gap: 0.55rem;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.vault-grid .item-tile {
|
||||
padding: 0.45rem;
|
||||
min-height: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.vault-grid .item-visual {
|
||||
min-height: 56px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.vault-grid .item-visual.has-img {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.vault-grid .item-visual img {
|
||||
max-height: 52px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.vault-grid .item-name {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.15;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vault-grid .item-meta,
|
||||
.vault-grid .item-wear {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.vault-grid .item-vault-badge {
|
||||
min-width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.vault-grid .item-favorite-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.28rem;
|
||||
}
|
||||
|
||||
|
||||
.item-tile.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
@@ -1909,6 +2092,10 @@ a.balance-pill-link.active {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.mode-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.mode-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -2148,6 +2335,32 @@ a.mode-card:hover {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-ico {
|
||||
color: var(--gold);
|
||||
font-size: 0.95em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-ico-vault {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 1.1em;
|
||||
height: 1.1em;
|
||||
padding: 0 0.15em;
|
||||
border-radius: 0.2rem;
|
||||
background: #0a121c;
|
||||
color: #e8f6ff;
|
||||
border: 1px solid #7ec8ff;
|
||||
font-size: 0.72em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.room-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2658,6 +2871,238 @@ a.achievement-badge:hover .achievement-badge-name {
|
||||
padding: 0.35rem 0.7rem;
|
||||
}
|
||||
|
||||
.shop-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.shop-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.7fr);
|
||||
gap: 1.5rem 2rem;
|
||||
align-items: end;
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.shop-kicker {
|
||||
margin: 0 0 0.35rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.shop-hero h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(1.75rem, 3vw, 2.4rem);
|
||||
}
|
||||
|
||||
.shop-lede {
|
||||
margin: 0;
|
||||
max-width: 38rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.shop-lede strong {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shop-wallet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(61, 214, 198, 0.08), transparent 55%),
|
||||
var(--bg-panel);
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.shop-wallet-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shop-wallet-osu {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.shop-mock-note {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.shop-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.shop-section-head h2 {
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
.shop-section-head .muted {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shop-osu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.shop-osu-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem 0.9rem 0.9rem;
|
||||
background: linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
|
||||
border: 1px solid var(--stroke);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.shop-osu-card.popular {
|
||||
border-color: rgba(61, 214, 198, 0.45);
|
||||
}
|
||||
|
||||
.shop-osu-card.highlight {
|
||||
border-color: rgba(228, 174, 57, 0.55);
|
||||
background:
|
||||
linear-gradient(160deg, rgba(228, 174, 57, 0.1), transparent 45%),
|
||||
linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
|
||||
}
|
||||
|
||||
.shop-badge {
|
||||
position: absolute;
|
||||
top: 0.55rem;
|
||||
right: 0.55rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg-deep);
|
||||
background: var(--accent);
|
||||
padding: 0.15rem 0.4rem;
|
||||
}
|
||||
|
||||
.shop-osu-card.highlight .shop-badge {
|
||||
background: var(--gold);
|
||||
}
|
||||
|
||||
.shop-badge.subtle {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--stroke);
|
||||
position: static;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.shop-osu-card h3,
|
||||
.shop-credit-card h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.shop-osu-amount {
|
||||
margin: 0.15rem 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.65rem;
|
||||
color: var(--accent);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.shop-osu-amount span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shop-osu-blurb,
|
||||
.shop-osu-rate {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shop-buy-btn {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.shop-credit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shop-credit-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.shop-credit-card .pack-credits {
|
||||
margin: 0.1rem 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.shop-credit-cost {
|
||||
margin: 0.15rem 0 0.35rem;
|
||||
}
|
||||
|
||||
.shop-credit-scale {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.shop-credit-card .btn {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.shop-ad-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px dashed var(--stroke);
|
||||
background: rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
|
||||
.shop-ad-panel h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.shop-ad-panel .muted {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shop-hero {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.inv-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2667,6 +3112,17 @@ a.achievement-badge:hover .achievement-badge-name {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.inv-section-head {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.inv-section-head h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inv-toolbar-left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2786,6 +3242,13 @@ a.achievement-badge:hover .achievement-badge-name {
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.pack-credits {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
color: var(--gold);
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.ad-panel {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@@ -3289,6 +3752,20 @@ a.achievement-badge:hover .achievement-badge-name {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.prestige-skill-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.prestige-skill-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.prestige-history {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
|
||||
@@ -8,6 +8,8 @@ import AdminItems from '../admin/AdminItems';
|
||||
import AdminCategories from '../admin/AdminCategories';
|
||||
import AdminDrops from '../admin/AdminDrops';
|
||||
import AdminPlayers from '../admin/AdminPlayers';
|
||||
import AdminPrestigeServer from '../admin/AdminPrestigeServer';
|
||||
import AdminShop from '../admin/AdminShop';
|
||||
|
||||
function AdminLogin() {
|
||||
const { adminLogin } = useAuth();
|
||||
@@ -74,6 +76,8 @@ export default function Admin() {
|
||||
<Route path="items" element={<AdminItems />} />
|
||||
<Route path="categories" element={<AdminCategories />} />
|
||||
<Route path="drops" element={<AdminDrops />} />
|
||||
<Route path="prestige-server" element={<AdminPrestigeServer />} />
|
||||
<Route path="shop" element={<AdminShop />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
+47
-204
@@ -1,43 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import { useCountdown } from '../hooks/useCountdown';
|
||||
import { rarityColor } from '../rarities';
|
||||
import BattleLiveCard from '../components/BattleLiveCard';
|
||||
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);
|
||||
const [battles, setBattles] = useState([]);
|
||||
const [spinById, setSpinById] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
@@ -45,103 +16,53 @@ export default function BattleHub() {
|
||||
|
||||
(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);
|
||||
}
|
||||
const data = await api.jackpot();
|
||||
if (!cancelled) setBattles(data.battles || []);
|
||||
} catch {
|
||||
/* hub still usable without live preview */
|
||||
/* hub still usable */
|
||||
}
|
||||
})();
|
||||
|
||||
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 onList = (payload) => {
|
||||
setBattles(payload.battles || []);
|
||||
};
|
||||
const onSpin = (state) => {
|
||||
if (!state?.id) return;
|
||||
setSpinById((prev) => ({ ...prev, [state.id]: state }));
|
||||
setBattles((prev) => {
|
||||
const idx = prev.findIndex((b) => b.id === state.id);
|
||||
if (idx < 0) return [...prev, state];
|
||||
const next = [...prev];
|
||||
next[idx] = state;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const onResult = (state) => {
|
||||
const id = state?.previousRoundId || state?.id;
|
||||
if (!id) return;
|
||||
setSpinById((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
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.emit('jackpots:subscribe');
|
||||
socket.on('jackpots:list', onList);
|
||||
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');
|
||||
});
|
||||
socket.on('connect', () => socket.emit('jackpots:subscribe'));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.emit('duels:unsubscribe');
|
||||
socket.off('jackpot:state', onState);
|
||||
socket.emit('jackpots:unsubscribe');
|
||||
socket.off('jackpots:list', onList);
|
||||
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 />;
|
||||
@@ -151,106 +72,28 @@ export default function BattleHub() {
|
||||
<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">
|
||||
<Link to="/battle/create" className="mode-card mode-card-wide">
|
||||
<div className="mode-card-top">
|
||||
<h2>MultiPlayers Case</h2>
|
||||
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<h2>Create a Battle</h2>
|
||||
</div>
|
||||
<p className="muted mode-blurb">
|
||||
Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items — chance scales with
|
||||
value.
|
||||
Bet items to open a new pot. Others can join — chance scales with value. Timer starts at
|
||||
2 players.
|
||||
</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>
|
||||
{battles.map((b) => (
|
||||
<BattleLiveCard key={b.id} battle={b} spinEvent={spinById[b.id]} />
|
||||
))}
|
||||
|
||||
<div className="mode-card stub">
|
||||
<h2>Coinflip</h2>
|
||||
<p className="muted">Classic 1v1 item flip. Coming soon.</p>
|
||||
<span className="badge">Soon</span>
|
||||
</div>
|
||||
{battles.length === 0 && (
|
||||
<p className="muted mode-pot-empty" style={{ gridColumn: '1 / -1' }}>
|
||||
No open battles — create one to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits, formatChance } from '../api';
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api, formatCredits, formatChance, compareCents } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
@@ -8,7 +8,13 @@ import { useCountdown } from '../hooks/useCountdown';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
export default function MultiPlayersPage() {
|
||||
/**
|
||||
* Create mode (`/battle/create`) or join/spectate (`/battle/:id`).
|
||||
*/
|
||||
export default function BattleRoomPage({ create = false }) {
|
||||
const { id: paramId } = useParams();
|
||||
const battleId = create ? null : Number(paramId);
|
||||
const navigate = useNavigate();
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [round, setRound] = useState(null);
|
||||
const [inventory, setInventory] = useState([]);
|
||||
@@ -37,8 +43,8 @@ export default function MultiPlayersPage() {
|
||||
() =>
|
||||
[...inventory].sort(
|
||||
(a, b) =>
|
||||
itemValueCents(b.item) - itemValueCents(a.item) ||
|
||||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
|
||||
compareCents(itemValueCents(b.item), itemValueCents(a.item)) ||
|
||||
compareCents(b.valueCents, a.valueCents)
|
||||
),
|
||||
[inventory]
|
||||
);
|
||||
@@ -46,23 +52,56 @@ export default function MultiPlayersPage() {
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [jp] = await Promise.all([api.jackpot(), loadInventory()]);
|
||||
if (!cancelled) setRound(jp.round);
|
||||
await loadInventory();
|
||||
if (create) {
|
||||
if (!cancelled) setRound(null);
|
||||
return;
|
||||
}
|
||||
if (!battleId) {
|
||||
if (!cancelled) setError('Invalid battle');
|
||||
return;
|
||||
}
|
||||
const data = await api.jackpotRound(battleId);
|
||||
if (cancelled) return;
|
||||
setRound(data.round);
|
||||
if (data.round?.status === 'spinning') {
|
||||
setSpinDeposits(data.round.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setLastWinner(data.round.winnerUsername || null);
|
||||
}
|
||||
if (data.round?.status === 'resolved' || data.round?.status === 'cancelled') {
|
||||
setLastWinner(data.round.winnerUsername || null);
|
||||
if (data.round.status === 'resolved') setRevealWinner(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
if (create || !battleId) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const socket = getSocket();
|
||||
const onState = (state) => {
|
||||
if (state?.id && state.id !== battleId) return;
|
||||
setRound(state);
|
||||
if (state?.status === 'cancelled') {
|
||||
navigate('/battle', { replace: true });
|
||||
return;
|
||||
}
|
||||
if (state?.status === 'open') {
|
||||
loadInventory().catch(() => {});
|
||||
}
|
||||
};
|
||||
const onSpin = (state) => {
|
||||
if (state?.id && state.id !== battleId) return;
|
||||
setRound(state);
|
||||
setSpinDeposits(state.deposits || []);
|
||||
setSpinActive(true);
|
||||
@@ -72,25 +111,30 @@ export default function MultiPlayersPage() {
|
||||
setLastWinner(state.winnerUsername);
|
||||
};
|
||||
const onResult = (state) => {
|
||||
if (state?.previousRoundId && state.previousRoundId !== battleId) return;
|
||||
if (state?.id && state.id !== battleId && !state.previousRoundId) return;
|
||||
setRound(state);
|
||||
if (state.winnerUsername) setLastWinner(state.winnerUsername);
|
||||
setRevealWinner(true);
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
};
|
||||
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('jackpot:subscribe', battleId);
|
||||
socket.on('jackpot:state', onState);
|
||||
socket.on('jackpot:spin', onSpin);
|
||||
socket.on('jackpot:result', onResult);
|
||||
socket.on('connect', () => socket.emit('jackpot:subscribe'));
|
||||
socket.on('connect', () => socket.emit('jackpot:subscribe', battleId));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.emit('jackpot:unsubscribe', battleId);
|
||||
socket.off('jackpot:state', onState);
|
||||
socket.off('jackpot:spin', onSpin);
|
||||
socket.off('jackpot:result', onResult);
|
||||
};
|
||||
}, [user, loadInventory, refresh]);
|
||||
}, [user, create, battleId, loadInventory, refresh, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
@@ -106,6 +150,8 @@ export default function MultiPlayersPage() {
|
||||
[round, user]
|
||||
);
|
||||
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
|
||||
const canBet =
|
||||
create || round?.status === 'open' || (!round && create);
|
||||
|
||||
const potItems = useMemo(() => {
|
||||
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
|
||||
@@ -140,7 +186,14 @@ export default function MultiPlayersPage() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.jackpotBet(selected);
|
||||
if (create && !round) {
|
||||
const data = await api.jackpotCreateBet(selected);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
navigate(`/battle/${data.round.id}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
const data = await api.jackpotBet(round.id, selected);
|
||||
setRound(data.round);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
@@ -152,10 +205,15 @@ export default function MultiPlayersPage() {
|
||||
};
|
||||
|
||||
const withdraw = async (inventoryItemIds) => {
|
||||
if (!round?.id) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.jackpotWithdraw(inventoryItemIds);
|
||||
const data = await api.jackpotWithdraw(round.id, inventoryItemIds);
|
||||
if (data.round?.status === 'cancelled') {
|
||||
navigate('/battle', { replace: true });
|
||||
return;
|
||||
}
|
||||
setRound(data.round);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
@@ -169,15 +227,22 @@ export default function MultiPlayersPage() {
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
const title = create && !round ? 'Create a Battle' : round?.name || `Battle ${battleId || ''}`;
|
||||
const isOpen = create || round?.status === 'open';
|
||||
|
||||
return (
|
||||
<div className="bet-page">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>MultiPlayers Case</h1>
|
||||
<p className="muted">Live shared pot — win chance = your value / total pot.</p>
|
||||
<h1>{title}</h1>
|
||||
<p className="muted">
|
||||
{create && !round
|
||||
? 'Bet items to open a new battle. Chance = your value / total pot.'
|
||||
: 'Win chance = your value / total pot.'}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/battle" className="btn secondary">
|
||||
Modes
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -185,25 +250,27 @@ export default function MultiPlayersPage() {
|
||||
|
||||
<div className="jackpot-status">
|
||||
<div className="timer-block">
|
||||
<span className="muted">Round</span>
|
||||
<span className="muted">Battle</span>
|
||||
<strong>#{round?.id || '—'}</strong>
|
||||
<span className={`status-pill ${round?.status || ''}`}>{round?.status || '…'}</span>
|
||||
<span className={`status-pill ${round?.status || ''}`}>
|
||||
{round?.status || (create ? 'new' : '…')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Timer</span>
|
||||
<strong className="timer-digits">
|
||||
{round?.status === 'spinning' || spinning
|
||||
? 'SPIN'
|
||||
: round?.waitingForPlayers
|
||||
: round?.waitingForPlayers || (create && !round)
|
||||
? 'WAIT'
|
||||
: round?.status === 'open' && round?.endsAt
|
||||
? timerLabel
|
||||
: '—'}
|
||||
</strong>
|
||||
{round?.waitingForPlayers && round?.status === 'open' && (
|
||||
{((round?.waitingForPlayers && round?.status === 'open') || (create && !round)) && (
|
||||
<span className="muted">
|
||||
Need {round.minPlayers || 2} players ({round.players?.length || 0}/
|
||||
{round.minPlayers || 2})
|
||||
Need {round?.minPlayers || 2} players ({round?.players?.length || 0}/
|
||||
{round?.minPlayers || 2})
|
||||
</span>
|
||||
)}
|
||||
{round?.status === 'open' &&
|
||||
@@ -289,7 +356,9 @@ export default function MultiPlayersPage() {
|
||||
{myDeposits.length > 0 && round?.status === 'open' && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<h2>Your bets ({myDeposits.length}/{maxItems})</h2>
|
||||
<h2>
|
||||
Your bets ({myDeposits.length}/{maxItems})
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
@@ -302,11 +371,11 @@ export default function MultiPlayersPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{round?.status === 'open' && (
|
||||
{isOpen && canBet && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Bet from inventory</h2>
|
||||
<h2>{create && !round ? 'Bet to create' : 'Bet from inventory'}</h2>
|
||||
<p className="muted">
|
||||
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
|
||||
</p>
|
||||
@@ -317,7 +386,7 @@ export default function MultiPlayersPage() {
|
||||
disabled={busy || !selected.length || mySlotsLeft === 0}
|
||||
onClick={placeBet}
|
||||
>
|
||||
Place bet
|
||||
{create && !round ? 'Create battle' : 'Place bet'}
|
||||
</button>
|
||||
</div>
|
||||
{inventoryByValue.length === 0 ? (
|
||||
@@ -331,8 +400,12 @@ export default function MultiPlayersPage() {
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked={inv.staked}
|
||||
selected={selected.includes(inv.id)}
|
||||
disabled={mySlotsLeft === 0 && !selected.includes(inv.id)}
|
||||
disabled={
|
||||
inv.staked ||
|
||||
(mySlotsLeft === 0 && !selected.includes(inv.id))
|
||||
}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
))}
|
||||
+415
-167
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { api, formatCredits, compareCents, toCentsBigInt } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
||||
import { notifyInventoryChanged } from '../inventoryEvents';
|
||||
import { rarityColor } from '../rarities';
|
||||
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
|
||||
|
||||
const DEFAULT_MAX_OPENS = 10;
|
||||
/** Cap simultaneous reel animations to avoid lag on large multi-opens. */
|
||||
const MAX_VISIBLE_REELS = 5;
|
||||
|
||||
/** 1–5, then steps of 5 (10, 15…), plus max if not already listed. */
|
||||
function buildOpenQuantityOptions(maxOpens) {
|
||||
@@ -20,26 +22,69 @@ function buildOpenQuantityOptions(maxOpens) {
|
||||
return options;
|
||||
}
|
||||
|
||||
function dropValue(item) {
|
||||
return itemValueCents(item);
|
||||
}
|
||||
|
||||
/** Best drops first (by instance value), capped for reel display. */
|
||||
function pickShowcaseDrops(items, limit = MAX_VISIBLE_REELS) {
|
||||
return [...items]
|
||||
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
|
||||
.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
/** Best drop by valueCents (BigInt-safe). */
|
||||
function pickBestDrop(items) {
|
||||
if (!items?.length) return null;
|
||||
let best = items[0];
|
||||
let bestVal = dropValue(best);
|
||||
for (let i = 1; i < items.length; i += 1) {
|
||||
const it = items[i];
|
||||
const v = dropValue(it);
|
||||
if (v > bestVal) {
|
||||
best = it;
|
||||
bestVal = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export default function CasePage() {
|
||||
const { id } = useParams();
|
||||
const { user, loading, updateBalance } = useAuth();
|
||||
const [crate, setCrate] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [keyMessage, setKeyMessage] = useState('');
|
||||
const [catalogMessage, setCatalogMessage] = useState('');
|
||||
const [openBonusMessage, setOpenBonusMessage] = useState('');
|
||||
const [animReduction, setAnimReduction] = useState(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [spins, setSpins] = useState([]);
|
||||
const [allDrops, setAllDrops] = useState([]);
|
||||
const [caseName, setCaseName] = useState('');
|
||||
/** True while paused on discarded winners before the respin reels restart. */
|
||||
const [respinRestarting, setRespinRestarting] = useState(false);
|
||||
const announcedRef = useRef(false);
|
||||
const preAnnouncedRef = useRef(false);
|
||||
const finishingRef = useRef(false);
|
||||
const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
|
||||
const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false });
|
||||
const pendingAutoSellRef = useRef([]);
|
||||
/** Auto-sell ids from the discarded first roll — applied when that reel finishes. */
|
||||
const pendingPreRespinAutoSellRef = useRef([]);
|
||||
/** When set, first reel pass is a discarded roll; then we restart with these final items. */
|
||||
const pendingRespinFinalRef = useRef(null);
|
||||
/** Snapshot of first-roll drops for feed (best item announced when first reels finish). */
|
||||
const pendingPreRespinAnnounceRef = useRef(null);
|
||||
/**
|
||||
* Respin animation phase (kept out of setState updaters — must be Strict-Mode safe):
|
||||
* null | 'pre' | 'restarting' | 'final'
|
||||
*/
|
||||
const respinPhaseRef = useRef(null);
|
||||
const quantityRef = useRef(1);
|
||||
const caseIdRef = useRef(null);
|
||||
const prefReadyRef = useRef(false);
|
||||
/** Tracks case maxOpens so we can bump qty when a key unlocks. */
|
||||
const prevMaxOpensRef = useRef(null);
|
||||
|
||||
const keyProgress = crate?.keyProgress ?? null;
|
||||
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
|
||||
@@ -61,10 +106,15 @@ export default function CasePage() {
|
||||
const announcePendingDrop = async (useKeepalive = false) => {
|
||||
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
|
||||
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
|
||||
const respin = Boolean(pendingAnnounceRef.current.respin);
|
||||
if (!ids.length || announcedRef.current) return;
|
||||
|
||||
announcedRef.current = true;
|
||||
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
|
||||
const body = JSON.stringify({
|
||||
inventoryItemIds: ids,
|
||||
caseName: announceCaseName,
|
||||
respin,
|
||||
});
|
||||
|
||||
if (useKeepalive) {
|
||||
fetch('/api/feed/announce', {
|
||||
@@ -80,15 +130,51 @@ export default function CasePage() {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.feedAnnounce(ids, announceCaseName);
|
||||
await api.feedAnnounce(ids, announceCaseName, { respin });
|
||||
} catch {
|
||||
announcedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const announcePreRespinDrop = async () => {
|
||||
const pending = pendingPreRespinAnnounceRef.current;
|
||||
if (!pending?.items?.length || preAnnouncedRef.current) return;
|
||||
preAnnouncedRef.current = true;
|
||||
pendingPreRespinAnnounceRef.current = null;
|
||||
const best = pickBestDrop(pending.items);
|
||||
if (!best) {
|
||||
preAnnouncedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const count = pending.items.filter((it) => it.id === best.id).length;
|
||||
try {
|
||||
await api.feedAnnounce([], pending.caseName || caseName || crate?.name || '', {
|
||||
snapshotBest: {
|
||||
id: best.id,
|
||||
name: best.name,
|
||||
imageUrl: best.imageUrl,
|
||||
rarity: best.rarity,
|
||||
valueCents: best.valueCents ?? best.marketValue,
|
||||
wear: best.wear,
|
||||
floatValue: best.floatValue,
|
||||
paintSeed: best.paintSeed,
|
||||
},
|
||||
openCount: pending.items.length,
|
||||
count,
|
||||
respin: false,
|
||||
});
|
||||
} catch {
|
||||
preAnnouncedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingAutoSellKeepalive = () => {
|
||||
const ids = pendingAutoSellRef.current.filter(Boolean);
|
||||
const ids = [
|
||||
...pendingPreRespinAutoSellRef.current,
|
||||
...pendingAutoSellRef.current,
|
||||
].filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
pendingPreRespinAutoSellRef.current = [];
|
||||
pendingAutoSellRef.current = [];
|
||||
fetch('/api/inventory/auto-sell/apply', {
|
||||
method: 'POST',
|
||||
@@ -99,42 +185,79 @@ export default function CasePage() {
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const finalizeOpens = async (completedSpins, announceCaseName) => {
|
||||
const markSpinsAutoSold = (soldIds, soldDetails = []) => {
|
||||
const soldSet = new Set(soldIds);
|
||||
const payoutById = new Map(
|
||||
(soldDetails || []).map((s) => [s.id, s.payoutCents ?? s.valueCents])
|
||||
);
|
||||
setSpins((curr) =>
|
||||
curr.map((s) =>
|
||||
soldSet.has(s.item.inventoryId)
|
||||
? {
|
||||
...s,
|
||||
item: {
|
||||
...s.item,
|
||||
autoSold: true,
|
||||
soldForCents:
|
||||
payoutById.get(s.item.inventoryId) ?? s.item.valueCents,
|
||||
},
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
setAllDrops((curr) =>
|
||||
curr.map((item) =>
|
||||
soldSet.has(item.inventoryId)
|
||||
? {
|
||||
...item,
|
||||
autoSold: true,
|
||||
soldForCents: payoutById.get(item.inventoryId) ?? item.valueCents,
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/** Credit auto-sell for the discarded first roll once its reels finish. */
|
||||
const applyPreRespinAutoSell = async () => {
|
||||
const toSell = [...pendingPreRespinAutoSellRef.current];
|
||||
pendingPreRespinAutoSellRef.current = [];
|
||||
if (!toSell.length) return;
|
||||
try {
|
||||
const data = await api.applyPendingAutoSell(toSell);
|
||||
updateBalance(data.user.balance);
|
||||
notifyInventoryChanged();
|
||||
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
|
||||
} catch (err) {
|
||||
// Keep ids so unload keepalive can still flush if the request failed mid-respin.
|
||||
pendingPreRespinAutoSellRef.current = toSell;
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeOpens = async (announceCaseName) => {
|
||||
if (finishingRef.current) return;
|
||||
finishingRef.current = true;
|
||||
|
||||
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
|
||||
pendingAnnounceRef.current = { ids, caseName: announceCaseName };
|
||||
// Keep full-batch ids already stored in pending refs (not only showcase reels)
|
||||
if (announceCaseName) {
|
||||
pendingAnnounceRef.current = {
|
||||
...pendingAnnounceRef.current,
|
||||
caseName: announceCaseName,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await announcePendingDrop(false);
|
||||
|
||||
const toSell = completedSpins
|
||||
.filter((s) => s.item?.willAutoSell)
|
||||
.map((s) => s.item.inventoryId)
|
||||
.filter(Boolean);
|
||||
|
||||
const toSell = [...pendingAutoSellRef.current];
|
||||
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
|
||||
)
|
||||
);
|
||||
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -147,6 +270,7 @@ export default function CasePage() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
prefReadyRef.current = false;
|
||||
prevMaxOpensRef.current = null;
|
||||
caseIdRef.current = null;
|
||||
setCrate(null);
|
||||
(async () => {
|
||||
@@ -167,9 +291,16 @@ export default function CasePage() {
|
||||
|
||||
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));
|
||||
try {
|
||||
const price = BigInt(String(crate.price ?? 0).split('.')[0] || '0');
|
||||
if (price <= 0n) return maxOpens;
|
||||
const bal = BigInt(String(user.balance ?? 0).split('.')[0] || '0');
|
||||
const affordable = bal / price;
|
||||
const n = affordable > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(affordable);
|
||||
return Math.min(maxOpens, Math.max(n, 0));
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}, [crate, user, maxOpens]);
|
||||
|
||||
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
|
||||
@@ -182,18 +313,28 @@ export default function CasePage() {
|
||||
|
||||
if (!prefReadyRef.current) {
|
||||
prefReadyRef.current = true;
|
||||
prevMaxOpensRef.current = maxOpens;
|
||||
setQuantity(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
const prevMax = prevMaxOpensRef.current;
|
||||
prevMaxOpensRef.current = maxOpens;
|
||||
// New key unlocked → jump select to highest affordable tier (new max if credits allow).
|
||||
const unlockedHigher = prevMax != null && maxOpens > prevMax;
|
||||
|
||||
setQuantity((q) => {
|
||||
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
|
||||
if (!allowed.length) return 1;
|
||||
// Jump to newly unlocked max only when the balance can cover it.
|
||||
if (unlockedHigher && maxOpens <= Math.max(1, maxAffordable)) {
|
||||
return maxOpens;
|
||||
}
|
||||
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]);
|
||||
}, [crate, maxAffordable, openOptions, user?.id, maxOpens]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
|
||||
@@ -202,6 +343,37 @@ export default function CasePage() {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Flush first-spin feed if page closes mid-respin
|
||||
const pre = pendingPreRespinAnnounceRef.current;
|
||||
if (pre?.items?.length && !preAnnouncedRef.current) {
|
||||
preAnnouncedRef.current = true;
|
||||
const best = pickBestDrop(pre.items);
|
||||
if (best) {
|
||||
const count = pre.items.filter((it) => it.id === best.id).length;
|
||||
fetch('/api/feed/announce', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
snapshotBest: {
|
||||
id: best.id,
|
||||
name: best.name,
|
||||
imageUrl: best.imageUrl,
|
||||
rarity: best.rarity,
|
||||
valueCents: best.valueCents ?? best.marketValue,
|
||||
wear: best.wear,
|
||||
floatValue: best.floatValue,
|
||||
paintSeed: best.paintSeed,
|
||||
},
|
||||
openCount: pre.items.length,
|
||||
count,
|
||||
caseName: pre.caseName || '',
|
||||
respin: false,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
announcePendingDrop(true);
|
||||
applyPendingAutoSellKeepalive();
|
||||
};
|
||||
@@ -213,7 +385,7 @@ export default function CasePage() {
|
||||
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);
|
||||
return compareCents(b.marketValue, a.marketValue);
|
||||
});
|
||||
}, [crate]);
|
||||
|
||||
@@ -225,31 +397,57 @@ export default function CasePage() {
|
||||
);
|
||||
}, [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 = useMemo(() => {
|
||||
if (!crate) return '0';
|
||||
return (toCentsBigInt(crate.price) * BigInt(quantity)).toString();
|
||||
}, [crate, quantity]);
|
||||
|
||||
const totalCost = crate ? crate.price * quantity : 0;
|
||||
const canOpen =
|
||||
crate &&
|
||||
!opening &&
|
||||
crate.items.length > 0 &&
|
||||
quantity >= 1 &&
|
||||
quantity <= maxOpens &&
|
||||
user.balance >= totalCost;
|
||||
const canOpen = useMemo(() => {
|
||||
if (!user || !crate || opening || !crate.items?.length) return false;
|
||||
if (quantity < 1 || quantity > maxOpens) return false;
|
||||
return toCentsBigInt(user.balance) >= toCentsBigInt(totalCost);
|
||||
}, [user, crate, opening, quantity, maxOpens, totalCost]);
|
||||
|
||||
const allDone = spins.length > 0 && spins.every((s) => s.done);
|
||||
const allDone =
|
||||
spins.length > 0 &&
|
||||
spins.every((s) => s.done) &&
|
||||
!respinRestarting &&
|
||||
respinPhaseRef.current !== 'pre' &&
|
||||
respinPhaseRef.current !== 'restarting';
|
||||
|
||||
const heroHidden = opening || (spins.length > 0 && !allDone);
|
||||
|
||||
const beginReelSpins = (items) => {
|
||||
setAllDrops(items);
|
||||
const showcase = pickShowcaseDrops(items, MAX_VISIBLE_REELS);
|
||||
const next = showcase.map((item, idx) => ({
|
||||
key: `${item.inventoryId || item.id || 'x'}-${idx}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
item,
|
||||
spinning: false,
|
||||
done: false,
|
||||
}));
|
||||
setSpins(next);
|
||||
requestAnimationFrame(() => {
|
||||
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
|
||||
});
|
||||
};
|
||||
|
||||
const openCase = async () => {
|
||||
if (!canOpen) return;
|
||||
if (!canOpen || !crate) return;
|
||||
setError('');
|
||||
setKeyMessage('');
|
||||
setCatalogMessage('');
|
||||
setOpenBonusMessage('');
|
||||
setSpins([]);
|
||||
setAllDrops([]);
|
||||
setOpening(true);
|
||||
setRespinRestarting(false);
|
||||
announcedRef.current = false;
|
||||
preAnnouncedRef.current = false;
|
||||
finishingRef.current = false;
|
||||
pendingRespinFinalRef.current = null;
|
||||
pendingPreRespinAnnounceRef.current = null;
|
||||
pendingPreRespinAutoSellRef.current = [];
|
||||
respinPhaseRef.current = null;
|
||||
|
||||
try {
|
||||
const result = await api.openCase(crate.id, quantity);
|
||||
@@ -259,10 +457,9 @@ export default function CasePage() {
|
||||
if (result.animReduction != null) {
|
||||
setAnimReduction(result.animReduction);
|
||||
}
|
||||
const bonusParts = [];
|
||||
if (result.refunded) bonusParts.push('Refund! Case cost returned');
|
||||
if (result.respin) bonusParts.push('Respin! All drops re-rolled');
|
||||
if (bonusParts.length) setOpenBonusMessage(bonusParts.join(' · '));
|
||||
if (result.refunded) {
|
||||
setOpenBonusMessage('Refund! Case cost returned');
|
||||
}
|
||||
if (result.keyProgress) {
|
||||
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
||||
}
|
||||
@@ -272,19 +469,6 @@ export default function CasePage() {
|
||||
);
|
||||
}
|
||||
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
|
||||
@@ -308,24 +492,34 @@ export default function CasePage() {
|
||||
setCaseName(resolvedCaseName);
|
||||
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
|
||||
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
|
||||
const pre = result.respin && result.preRespinItems?.length ? result.preRespinItems : null;
|
||||
|
||||
pendingAnnounceRef.current = {
|
||||
ids: inventoryIds,
|
||||
caseName: resolvedCaseName || '',
|
||||
respin: Boolean(pre),
|
||||
};
|
||||
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 })));
|
||||
});
|
||||
|
||||
if (pre) {
|
||||
pendingPreRespinAutoSellRef.current = pre
|
||||
.filter((item) => item?.willAutoSell && item?.inventoryId)
|
||||
.map((item) => item.inventoryId);
|
||||
pendingPreRespinAnnounceRef.current = {
|
||||
items: pre,
|
||||
caseName: resolvedCaseName || '',
|
||||
};
|
||||
pendingRespinFinalRef.current = items;
|
||||
respinPhaseRef.current = 'pre';
|
||||
beginReelSpins(pre);
|
||||
} else {
|
||||
pendingPreRespinAutoSellRef.current = [];
|
||||
respinPhaseRef.current = null;
|
||||
beginReelSpins(items);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setOpening(false);
|
||||
@@ -336,109 +530,149 @@ export default function CasePage() {
|
||||
setSpins((prev) => {
|
||||
const alreadyDone = prev.find((s) => s.key === key)?.done;
|
||||
if (alreadyDone) return prev;
|
||||
|
||||
const next = prev.map((s) =>
|
||||
return 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;
|
||||
});
|
||||
};
|
||||
|
||||
// 1) When first spin reels finish → feed line #1, start respin pause
|
||||
// 2) When final reels finish → feed line #2 (respin) via finalizeOpens
|
||||
useEffect(() => {
|
||||
if (!spins.length || spins.some((s) => !s.done)) return;
|
||||
|
||||
const phase = respinPhaseRef.current;
|
||||
|
||||
if (phase === 'pre') {
|
||||
if (!pendingRespinFinalRef.current) return;
|
||||
// Block re-entry while we credit first-roll auto-sell, then pause before restart.
|
||||
respinPhaseRef.current = 'restarting';
|
||||
void (async () => {
|
||||
await announcePreRespinDrop();
|
||||
await applyPreRespinAutoSell();
|
||||
setRespinRestarting(true);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'restarting') return;
|
||||
|
||||
if ((phase === 'final' || phase === null) && opening && !finishingRef.current) {
|
||||
respinPhaseRef.current = null;
|
||||
setOpening(false);
|
||||
void finalizeOpens(caseName || crate?.name || '');
|
||||
}
|
||||
}, [spins, opening, caseName, crate?.name]);
|
||||
|
||||
// After pause, restart reels with real loot (respin feed waits until those finish)
|
||||
useEffect(() => {
|
||||
if (!respinRestarting) return;
|
||||
const finalItems = pendingRespinFinalRef.current;
|
||||
if (!finalItems) return;
|
||||
|
||||
const t = setTimeout(() => {
|
||||
pendingRespinFinalRef.current = null;
|
||||
respinPhaseRef.current = 'final';
|
||||
setOpenBonusMessage((msg) =>
|
||||
msg?.includes('Respin')
|
||||
? msg
|
||||
: msg
|
||||
? `${msg} · Respin! Re-rolling…`
|
||||
: 'Respin! Re-rolling all drops…'
|
||||
);
|
||||
beginReelSpins(finalItems);
|
||||
setRespinRestarting(false);
|
||||
}, 550);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [respinRestarting]);
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
return (
|
||||
<div className="case-page">
|
||||
<Link to="/cases" className="muted">
|
||||
← Back to cases
|
||||
</Link>
|
||||
|
||||
{!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>}
|
||||
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
|
||||
|
||||
{crate && (
|
||||
<>
|
||||
<div className="case-hero">
|
||||
<h1>{crate.name}</h1>
|
||||
<p className="muted">
|
||||
Cost {formatCredits(crate.price)} cr each
|
||||
{crate.basePrice != null && Number(crate.basePrice) !== Number(crate.price)
|
||||
? ` (was ${formatCredits(crate.basePrice)})`
|
||||
: ''}{' '}
|
||||
· Balance {formatCredits(user.balance)} cr
|
||||
{crate.catalogLuckPercent > 0
|
||||
? ` · Catalog luck +${crate.catalogLuckPercent}%`
|
||||
: ''}
|
||||
</p>
|
||||
<div
|
||||
className={`case-hero-wrap${heroHidden ? ' case-hero-wrap--hidden' : ''}`}
|
||||
aria-hidden={heroHidden}
|
||||
>
|
||||
<div className="case-hero">
|
||||
<h1>{crate.name}</h1>
|
||||
|
||||
{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}%` }} />
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
|
||||
onClick={openCase}
|
||||
disabled={!canOpen}
|
||||
title="Open case"
|
||||
>
|
||||
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
|
||||
{opening
|
||||
? `Opening ${quantity}×…`
|
||||
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
|
||||
onClick={openCase}
|
||||
disabled={!canOpen}
|
||||
title="Open case"
|
||||
>
|
||||
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
|
||||
{opening
|
||||
? `Opening ${quantity}×…`
|
||||
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{spins.length > 0 && (
|
||||
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
|
||||
{allDrops.length > spins.length && (
|
||||
<p className="muted multi-reel-hint">
|
||||
Showing top {spins.length} of {allDrops.length} drops
|
||||
</p>
|
||||
)}
|
||||
{spins.map((spin, idx) => (
|
||||
<div key={spin.key} className="multi-reel-slot">
|
||||
{spins.length > 1 && (
|
||||
@@ -464,14 +698,18 @@ export default function CasePage() {
|
||||
{spin.item.rarity}
|
||||
</div>
|
||||
<div className="muted">
|
||||
{[
|
||||
spin.item.wear || null,
|
||||
`${formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
{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}` : ''}
|
||||
{' '}
|
||||
· Auto-sold for {formatCredits(spin.item.soldForCents)} cr
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -480,13 +718,23 @@ export default function CasePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allDone && spins.length > 1 && (
|
||||
{allDone && allDrops.length > 1 && (
|
||||
<div className="won-banner section">
|
||||
<div className="muted">You unboxed {spins.length} items</div>
|
||||
<div className="muted">
|
||||
You unboxed {allDrops.length} items
|
||||
{allDrops.length > spins.length
|
||||
? ` · reels showed the top ${spins.length} by value`
|
||||
: ''}
|
||||
</div>
|
||||
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
||||
{spins.map((s) => (
|
||||
<ItemTile key={s.key} item={s.item} />
|
||||
))}
|
||||
{[...allDrops]
|
||||
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
|
||||
.map((item) => (
|
||||
<ItemTile
|
||||
key={item.inventoryId || `${item.id}-${item.floatValue}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,12 +30,6 @@ export default function CasesPage() {
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Cases</h1>
|
||||
<p className="muted">Pick a crate to open.</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="grid case-grid">
|
||||
{cases.map((c) => (
|
||||
@@ -44,7 +38,7 @@ export default function CasesPage() {
|
||||
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
|
||||
</div>
|
||||
<div className="case-name">{c.name}</div>
|
||||
<div className="case-meta">{formatCredits(c.price)} cr</div>
|
||||
<div className="case-meta case-price">{formatCredits(c.price)} cr</div>
|
||||
<div className="case-meta">{c.items.length} possible drops</div>
|
||||
{c.keyProgress && (
|
||||
<div className="case-meta case-key-badge">
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function CoinflipStub() {
|
||||
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>Coinflip</h1>
|
||||
<p className="muted">1v1 item flip — coming soon.</p>
|
||||
</div>
|
||||
<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="/battle/multi" className="btn">
|
||||
MultiPlayers
|
||||
</Link>
|
||||
<Link to="/battle/1vx" className="btn secondary">
|
||||
1vX Case
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+253
-211
@@ -1,13 +1,22 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, creditsToCentsString, centsToCreditsString, compareCents, toCentsBigInt } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket, onSocketSessionOk } from '../socket';
|
||||
import { loadInventorySort, saveInventorySort } from '../userPrefs';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatVaultCountdown(ms) {
|
||||
if (ms == null || !Number.isFinite(ms)) return '—';
|
||||
const totalSec = Math.max(0, Math.ceil(ms / 1000));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [inventory, setInventory] = useState([]);
|
||||
@@ -21,6 +30,7 @@ export default function Dashboard() {
|
||||
const [maxValue, setMaxValue] = useState('10');
|
||||
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
|
||||
const [settingsBusy, setSettingsBusy] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
@@ -32,7 +42,7 @@ export default function Dashboard() {
|
||||
if (!user) return;
|
||||
setAutoSellEnabled(Boolean(user.autoSellEnabled));
|
||||
if (user.autoSellThresholdCents != null) {
|
||||
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
|
||||
setMaxValue(centsToCreditsString(user.autoSellThresholdCents));
|
||||
}
|
||||
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
|
||||
|
||||
@@ -64,13 +74,64 @@ export default function Dashboard() {
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
const socket = getSocket();
|
||||
const onYield = (payload) => {
|
||||
if (payload?.user) patchUser(payload.user);
|
||||
else if (payload?.balance != null) patchUser({ balance: payload.balance });
|
||||
if (payload?.vault) setVault(payload.vault);
|
||||
const credited = toCentsBigInt(payload?.credited);
|
||||
if (credited > 0n) {
|
||||
setVaultMessage(`Vault yield +${formatCredits(payload.credited)} cr`);
|
||||
}
|
||||
load().catch(() => {});
|
||||
};
|
||||
const onSession = () => {
|
||||
load().catch(() => {});
|
||||
};
|
||||
socket.on('vault:yield', onYield);
|
||||
const offSession = onSocketSessionOk(onSession);
|
||||
return () => {
|
||||
socket.off('vault:yield', onYield);
|
||||
offSession();
|
||||
};
|
||||
}, [user, patchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(vault?.used > 0)) return undefined;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [vault?.used]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(vault?.used > 0) || !vault?.nextYieldAt) return undefined;
|
||||
const dueAt = new Date(vault.nextYieldAt).getTime();
|
||||
const delay = Math.max(500, dueAt - Date.now() + 800);
|
||||
const t = setTimeout(() => {
|
||||
load().catch(() => {});
|
||||
}, delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [vault?.nextYieldAt, vault?.used]);
|
||||
|
||||
const vaultCountdownMs = useMemo(() => {
|
||||
if (!(vault?.used > 0) || !vault?.nextYieldAt) return null;
|
||||
return new Date(vault.nextYieldAt).getTime() - now;
|
||||
}, [vault?.used, vault?.nextYieldAt, now]);
|
||||
|
||||
const vaultTickLabel = useMemo(() => {
|
||||
const ms = vault?.tickMs ?? (vault?.mode === 'offline' ? 600_000 : 300_000);
|
||||
return `${Math.round(ms / 60_000)} min`;
|
||||
}, [vault?.tickMs, vault?.mode]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...inventory];
|
||||
const list = inventory.filter((inv) => !inv.staked);
|
||||
if (sort === 'value') {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
|
||||
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
|
||||
list.sort((a, b) =>
|
||||
compareCents(
|
||||
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
|
||||
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
|
||||
)
|
||||
);
|
||||
} else {
|
||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||
@@ -93,28 +154,23 @@ export default function Dashboard() {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const selectedValue = useMemo(
|
||||
() =>
|
||||
inventory
|
||||
.filter((inv) => selected.includes(inv.id))
|
||||
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
|
||||
[inventory, selected]
|
||||
);
|
||||
const selectedValue = useMemo(() => {
|
||||
let sum = 0n;
|
||||
for (const inv of inventory) {
|
||||
if (!selected.includes(inv.id)) continue;
|
||||
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
|
||||
}
|
||||
return sum.toString();
|
||||
}, [inventory, selected]);
|
||||
|
||||
const thresholdCents = useMemo(() => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return null;
|
||||
return Math.round(credits * 100);
|
||||
}, [maxValue]);
|
||||
const thresholdCents = useMemo(() => creditsToCentsString(maxValue), [maxValue]);
|
||||
|
||||
const belowThreshold = useMemo(() => {
|
||||
if (thresholdCents == null) return [];
|
||||
return inventory.filter(
|
||||
(inv) =>
|
||||
!inv.favorite &&
|
||||
!inv.staked &&
|
||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
);
|
||||
return inventory.filter((inv) => {
|
||||
if (inv.favorite || inv.staked) return false;
|
||||
return compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0;
|
||||
});
|
||||
}, [inventory, thresholdCents]);
|
||||
|
||||
const selectedItems = useMemo(
|
||||
@@ -134,14 +190,13 @@ export default function Dashboard() {
|
||||
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
|
||||
const canUnstake = selectedStaked.length > 0;
|
||||
|
||||
const belowValue = useMemo(
|
||||
() =>
|
||||
belowThreshold.reduce(
|
||||
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
|
||||
0
|
||||
),
|
||||
[belowThreshold]
|
||||
);
|
||||
const belowValue = useMemo(() => {
|
||||
let sum = 0n;
|
||||
for (const inv of belowThreshold) {
|
||||
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
|
||||
}
|
||||
return sum.toString();
|
||||
}, [belowThreshold]);
|
||||
|
||||
const toggle = (id) => {
|
||||
setSelected((prev) =>
|
||||
@@ -217,27 +272,6 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const claimVault = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setVaultMessage('');
|
||||
try {
|
||||
const data = await api.vaultClaim();
|
||||
if (data.user) patchUser({ balance: data.user.balance });
|
||||
if (data.vault) setVault(data.vault);
|
||||
setVaultMessage(
|
||||
data.credited > 0
|
||||
? `Vault yield +${formatCredits(data.credited)} cr`
|
||||
: 'No vault yield ready yet'
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setFavorite = async (favorite) => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
@@ -257,7 +291,8 @@ export default function Dashboard() {
|
||||
if (!ids.length || thresholdCents == null) return;
|
||||
const favSkipped = inventory.filter(
|
||||
(inv) =>
|
||||
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
inv.favorite &&
|
||||
compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0
|
||||
).length;
|
||||
const ok = window.confirm(
|
||||
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
|
||||
@@ -282,19 +317,18 @@ export default function Dashboard() {
|
||||
|
||||
const onAutoSellToggle = async (enabled) => {
|
||||
setAutoSellEnabled(enabled);
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
await saveAutoSell(enabled, credits);
|
||||
if (creditsToCentsString(maxValue) == null) return;
|
||||
await saveAutoSell(enabled, maxValue);
|
||||
};
|
||||
|
||||
const onThresholdBlur = async () => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
const savedCents = user?.autoSellThresholdCents ?? 1000;
|
||||
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
|
||||
const cents = creditsToCentsString(maxValue);
|
||||
if (cents == null) return;
|
||||
const savedCents = String(user?.autoSellThresholdCents ?? 1000);
|
||||
if (cents === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
|
||||
return;
|
||||
}
|
||||
await saveAutoSell(autoSellEnabled, credits);
|
||||
await saveAutoSell(autoSellEnabled, maxValue);
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
@@ -303,15 +337,56 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Inventory</h1>
|
||||
<p className="muted">Select items to sell at market value.</p>
|
||||
</div>
|
||||
</div>
|
||||
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
||||
|
||||
<div className="inv-toolbar">
|
||||
{stakedItems.length > 0 && (
|
||||
<section className="section">
|
||||
<div className="vault-panel admin-block">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Vault</h2>
|
||||
</div>
|
||||
<div className="vault-timer" title={`Shared ${vaultTickLabel} tick (${vault?.mode || 'online'})`}>
|
||||
<span className="muted">Next payment</span>
|
||||
<strong className={vaultCountdownMs != null && vaultCountdownMs <= 0 ? 'gold' : ''}>
|
||||
{vaultCountdownMs != null && vaultCountdownMs <= 0
|
||||
? 'paying…'
|
||||
: formatVaultCountdown(vaultCountdownMs)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vault-stats">
|
||||
<span>
|
||||
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Online / tick{' '}
|
||||
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
|
||||
</span>
|
||||
<span>
|
||||
Offline / tick{' '}
|
||||
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid vault-grid">
|
||||
{stakedItems.map((inv) => (
|
||||
<ItemTile
|
||||
key={`vault-${inv.id}`}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section">
|
||||
<div className="section-head inv-section-head">
|
||||
<h1>Inventory</h1>
|
||||
<div className="inv-toolbar-left">
|
||||
<div className="sort-toggle" role="group" aria-label="Sort inventory">
|
||||
<button
|
||||
@@ -335,87 +410,103 @@ export default function Dashboard() {
|
||||
Most recent
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length > 0 && (
|
||||
{sorted.length > 0 && (
|
||||
<span className="muted inv-count">
|
||||
{inventory.length} item{inventory.length === 1 ? '' : 's'}
|
||||
{sorted.length} item{sorted.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span className="muted">
|
||||
{selected.length} selected · {formatCredits(selectedValue)} cr
|
||||
{selectedHasFavorite ? ' · includes favorites' : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setSelected([])}
|
||||
disabled={busy}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{!selectedAllFavorite && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => setFavorite(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
Favorite
|
||||
</button>
|
||||
)}
|
||||
{selectedItems.some((inv) => inv.favorite) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setFavorite(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
Unfavorite
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={sell}
|
||||
disabled={busy || !selectedCanSell}
|
||||
title={
|
||||
selectedHasFavorite || selectedStaked.length
|
||||
? 'Favorites and vault items cannot be sold'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
{canStake && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={stakeSelected}
|
||||
disabled={busy}
|
||||
>
|
||||
Stake ({selectedUnstaked.length})
|
||||
</button>
|
||||
)}
|
||||
{canUnstake && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={unstakeSelected}
|
||||
disabled={busy}
|
||||
>
|
||||
Unstake ({selectedStaked.length})
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="inv-toolbar">
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={sell}
|
||||
disabled={busy || !selectedCanSell}
|
||||
title={
|
||||
selectedHasFavorite || selectedStaked.length
|
||||
? 'Favorites and vault items cannot be sold'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
<span className="muted">
|
||||
{selected.length} selected ·{' '}
|
||||
<span className="item-price">
|
||||
{formatCredits(selectedValue)} cr
|
||||
</span>
|
||||
{selectedHasFavorite ? ' · includes favorites' : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setSelected([])}
|
||||
disabled={busy}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{!selectedAllFavorite && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => setFavorite(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="btn-ico" aria-hidden="true">
|
||||
★
|
||||
</span>
|
||||
Favorite
|
||||
</button>
|
||||
)}
|
||||
{selectedItems.some((inv) => inv.favorite) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setFavorite(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="btn-ico" aria-hidden="true">
|
||||
★
|
||||
</span>
|
||||
Unfavorite
|
||||
</button>
|
||||
)}
|
||||
{canStake && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={stakeSelected}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="btn-ico btn-ico-vault" aria-hidden="true">
|
||||
V
|
||||
</span>
|
||||
Stake ({selectedUnstaked.length})
|
||||
</button>
|
||||
)}
|
||||
{canUnstake && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={unstakeSelected}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="btn-ico btn-ico-vault" aria-hidden="true">
|
||||
V
|
||||
</span>
|
||||
Unstake ({selectedStaked.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inv-bulk-sell">
|
||||
{inventory.length > 0 && (
|
||||
{sorted.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -440,11 +531,13 @@ export default function Dashboard() {
|
||||
</label>
|
||||
<span className="muted inv-bulk-preview">
|
||||
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
|
||||
{formatCredits(belowValue)} cr
|
||||
<span className="item-price">
|
||||
{formatCredits(belowValue)} cr
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{inventory.length === 0 && (
|
||||
{sorted.length === 0 && (
|
||||
<label className="inv-bulk-label">
|
||||
Threshold
|
||||
<input
|
||||
@@ -471,76 +564,26 @@ export default function Dashboard() {
|
||||
<span>Auto-sell</span>
|
||||
</label>
|
||||
<span className="inv-auto-sell-at muted">
|
||||
at{' '}
|
||||
{formatCredits(
|
||||
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
|
||||
? Math.round(Number(maxValue) * 100)
|
||||
: 0
|
||||
)}{' '}
|
||||
cr
|
||||
sell below {formatCredits(creditsToCentsString(maxValue) ?? 0)} cr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
||||
|
||||
<div className="vault-panel admin-block">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Vault</h2>
|
||||
<p className="muted">
|
||||
Stake up to {vault?.slots ?? 3} items. Online: 10% / 10 min · Offline: 1% / 10 min
|
||||
(scales with wear & rarity).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={claimVault}
|
||||
disabled={busy || !(vault?.used > 0)}
|
||||
>
|
||||
Claim yield
|
||||
</button>
|
||||
</div>
|
||||
<div className="vault-stats">
|
||||
<span>
|
||||
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Online / tick{' '}
|
||||
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
|
||||
</span>
|
||||
<span>
|
||||
Offline / tick{' '}
|
||||
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
|
||||
</span>
|
||||
</div>
|
||||
{stakedItems.length > 0 ? (
|
||||
<div className="grid vault-grid">
|
||||
{stakedItems.map((inv) => (
|
||||
<ItemTile
|
||||
key={`vault-${inv.id}`}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">No items staked — select items below and Stake.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inventory.length === 0 ? (
|
||||
{sorted.length === 0 ? (
|
||||
<div className="empty">
|
||||
Empty for now —{' '}
|
||||
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
||||
open a case
|
||||
</Link>
|
||||
.
|
||||
{stakedItems.length > 0 ? (
|
||||
'All items are in the vault.'
|
||||
) : (
|
||||
<>
|
||||
Empty for now —{' '}
|
||||
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
||||
open a case
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -550,7 +593,6 @@ export default function Dashboard() {
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked={inv.staked}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
const defaults = {
|
||||
maxPlayers: 4,
|
||||
maxItemsPerPlayer: 3,
|
||||
minItemValue: 0,
|
||||
minPlayersToStart: 2,
|
||||
};
|
||||
|
||||
export default function DuelListPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState([]);
|
||||
const [inProgress, setInProgress] = useState([]);
|
||||
const [form, setForm] = useState(defaults);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const list = await api.duels();
|
||||
if (!cancelled) {
|
||||
setOpen(list.open || []);
|
||||
setInProgress(list.inProgress || []);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const onList = (list) => {
|
||||
setOpen(list.open || []);
|
||||
setInProgress(list.inProgress || []);
|
||||
};
|
||||
socket.emit('duels:subscribe');
|
||||
socket.on('duels:list', onList);
|
||||
socket.on('connect', () => socket.emit('duels:subscribe'));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('duels:unsubscribe');
|
||||
socket.off('duels:list', onList);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const create = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.createDuel({
|
||||
maxPlayers: Number(form.maxPlayers),
|
||||
maxItemsPerPlayer: Number(form.maxItemsPerPlayer),
|
||||
minItemValue: Math.round(Number(form.minItemValue) * 100),
|
||||
minPlayersToStart: Number(form.minPlayersToStart),
|
||||
});
|
||||
navigate(`/battle/1vx/${data.room.id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
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 />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>1vX Case</h1>
|
||||
<p className="muted">Create a room or join an open duel.</p>
|
||||
</div>
|
||||
<Link to="/battle" className="btn secondary">
|
||||
Modes
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<section className="section admin-block">
|
||||
<h2>Create room</h2>
|
||||
<form className="form form-inline" onSubmit={create}>
|
||||
<label>
|
||||
Max players (2–10)
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={10}
|
||||
value={form.maxPlayers}
|
||||
onChange={(e) => setForm({ ...form, maxPlayers: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Max items / player (1–10)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={form.maxItemsPerPlayer}
|
||||
onChange={(e) => setForm({ ...form, maxItemsPerPlayer: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Min item value (cr)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={form.minItemValue}
|
||||
onChange={(e) => setForm({ ...form, minItemValue: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Min players to start
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={10}
|
||||
value={form.minPlayersToStart}
|
||||
onChange={(e) => setForm({ ...form, minPlayersToStart: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Open rooms</h2>
|
||||
{open.length === 0 ? (
|
||||
<div className="empty">No open rooms.</div>
|
||||
) : (
|
||||
<div className="room-list">
|
||||
{open.map((r) => (
|
||||
<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 ·{' '}
|
||||
{r.maxItemsPerPlayer} items max · min {formatCredits(r.minItemValue)} cr
|
||||
</div>
|
||||
<div className="muted">Pot {formatCredits(r.totalValue)} cr</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>In progress</h2>
|
||||
{inProgress.length === 0 ? (
|
||||
<div className="empty">No live rooms.</div>
|
||||
) : (
|
||||
<div className="room-list">
|
||||
{inProgress.map((r) => (
|
||||
<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>
|
||||
<div className="muted">
|
||||
{r.playerCount} players · pot {formatCredits(r.totalValue)} cr · Spectate
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
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, { itemValueCents } from '../components/ItemTile';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
export default function DuelRoomPage() {
|
||||
const { id } = useParams();
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [room, setRoom] = useState(null);
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
|
||||
|
||||
const loadInventory = useCallback(async () => {
|
||||
const inv = await api.inventory();
|
||||
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;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [data] = await Promise.all([api.duel(id), loadInventory()]);
|
||||
if (!cancelled) setRoom(data.room);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const roomId = Number(id);
|
||||
|
||||
const onState = (state) => {
|
||||
if (Number(state.id) !== roomId) return;
|
||||
setRoom(state);
|
||||
if (state.status === 'open' || state.status === 'resolved') {
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
}
|
||||
};
|
||||
const onSpin = (state) => {
|
||||
if (Number(state.id) !== roomId) return;
|
||||
setRoom(state);
|
||||
setSpinning(true);
|
||||
setReelWinnerItemId(state.reelWinnerItemId);
|
||||
};
|
||||
const onResult = (state) => {
|
||||
if (Number(state.id) !== roomId) return;
|
||||
setRoom(state);
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
};
|
||||
|
||||
socket.emit('duel:subscribe', roomId);
|
||||
socket.on('duel:state', onState);
|
||||
socket.on('duel:spin', onSpin);
|
||||
socket.on('duel:result', onResult);
|
||||
socket.on('connect', () => socket.emit('duel:subscribe', roomId));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('duel:unsubscribe', roomId);
|
||||
socket.off('duel:state', onState);
|
||||
socket.off('duel:spin', onSpin);
|
||||
socket.off('duel:result', onResult);
|
||||
};
|
||||
}, [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') &&
|
||||
!(room.deposits || []).some((d) => d.userId === user?.id);
|
||||
|
||||
const myDeposits = useMemo(
|
||||
() => (room?.deposits || []).filter((d) => d.userId === user?.id),
|
||||
[room, user]
|
||||
);
|
||||
const slotsLeft = Math.max(0, (room?.maxItemsPerPlayer || 0) - myDeposits.length);
|
||||
const potItems = useMemo(
|
||||
() =>
|
||||
(room?.deposits || [])
|
||||
.filter((d) => d.item)
|
||||
.map((d) => ({ ...d.item, id: d.id })),
|
||||
[room]
|
||||
);
|
||||
const canBet = room?.status === 'open';
|
||||
const isCreator = room?.creatorId === user?.id;
|
||||
|
||||
const toggleSelect = (invId) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(invId)) return prev.filter((x) => x !== invId);
|
||||
if (prev.length >= slotsLeft) return prev;
|
||||
return [...prev, invId];
|
||||
});
|
||||
};
|
||||
|
||||
const placeBet = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.duelBet(id, selected);
|
||||
setRoom(data.room);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const leave = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.duelLeave(id);
|
||||
setRoom(data.room);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.duelCancel(id);
|
||||
setRoom(data.room);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.duelStart(id);
|
||||
setRoom(data.room);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
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 />;
|
||||
if (!room && !error) return <p className="muted">Loading room…</p>;
|
||||
|
||||
return (
|
||||
<div className="bet-page">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Room #{id}</h1>
|
||||
<p className="muted">
|
||||
{room?.maxPlayers} players max · {room?.maxItemsPerPlayer} items · min{' '}
|
||||
{formatCredits(room?.minItemValue || 0)} cr
|
||||
{isSpectator ? ' · Spectating' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/battle/1vx" className="btn secondary">
|
||||
All rooms
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="jackpot-status">
|
||||
<div className="timer-block">
|
||||
<span className="muted">Status</span>
|
||||
<span className={`status-pill ${room?.status || ''}`}>{room?.status}</span>
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Pot</span>
|
||||
<strong className="gold">{formatCredits(room?.totalValue || 0)} cr</strong>
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Players</span>
|
||||
<strong>
|
||||
{room?.playerCount || 0}/{room?.maxPlayers}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(spinning || room?.status === 'spinning') && potItems.length > 0 && (
|
||||
<div className="section">
|
||||
<CaseReel
|
||||
items={potItems}
|
||||
winnerId={reelWinnerItemId || potItems[0]?.id}
|
||||
spinning={spinning || room?.status === 'spinning'}
|
||||
onDone={() => {}}
|
||||
/>
|
||||
{room?.winnerUsername && (
|
||||
<div className="won-banner" style={{ marginTop: '0.75rem' }}>
|
||||
Winner: <strong>{room.winnerUsername}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{room?.status === 'resolved' && room.winnerUsername && (
|
||||
<div className="won-banner section">
|
||||
Winner: <strong>{room.winnerUsername}</strong> took the pot (
|
||||
{formatCredits(room.totalValue)} cr)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="section">
|
||||
<h2>Players & chances</h2>
|
||||
{(room?.players || []).length === 0 ? (
|
||||
<div className="empty">No deposits yet.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Value</th>
|
||||
<th>Items</th>
|
||||
<th>Chance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{room.players.map((p) => (
|
||||
<tr key={p.userId} className={p.userId === user.id ? 'row-me' : ''}>
|
||||
<td>{p.username}</td>
|
||||
<td>{formatCredits(p.valueCents)} cr</td>
|
||||
<td>{p.itemCount}</td>
|
||||
<td>{formatChance(p.chance)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Deposited items</h2>
|
||||
{(room?.deposits || []).length === 0 ? (
|
||||
<div className="empty">Empty.</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{room.deposits.map((d) => (
|
||||
<div key={d.id} className="deposit-wrap">
|
||||
<ItemTile item={d.item} />
|
||||
<div className="deposit-owner muted">{d.username}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{canBet && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Your bets</h2>
|
||||
<p className="muted">
|
||||
{myDeposits.length}/{room.maxItemsPerPlayer} items · {slotsLeft} slot
|
||||
{slotsLeft === 1 ? '' : 's'} left
|
||||
</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
{myDeposits.length > 0 && (
|
||||
<button type="button" className="btn danger" disabled={busy} onClick={leave}>
|
||||
Leave / withdraw
|
||||
</button>
|
||||
)}
|
||||
{isCreator && (
|
||||
<>
|
||||
<button type="button" className="btn secondary" disabled={busy} onClick={cancel}>
|
||||
Cancel room
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={start}>
|
||||
Start now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-head">
|
||||
<p className="muted">Select items to bet</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !selected.length || slotsLeft === 0}
|
||||
onClick={placeBet}
|
||||
>
|
||||
Deposit
|
||||
</button>
|
||||
</div>
|
||||
{inventoryByValue.length === 0 ? (
|
||||
<div className="empty">
|
||||
No free items — <Link to="/cases">open a case</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{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)) ||
|
||||
itemValueCents(inv.item) < (room.minItemValue || 0)
|
||||
}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
|
||||
import { api, formatCredits, formatLastConnection, formatPlayTime, compareCents } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import ProfileCaseStats from '../components/ProfileCaseStats';
|
||||
@@ -38,10 +38,11 @@ export default function PlayerProfilePage() {
|
||||
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)
|
||||
list.sort((a, b) =>
|
||||
compareCents(
|
||||
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
|
||||
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
|
||||
)
|
||||
);
|
||||
} else {
|
||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||
@@ -161,10 +162,7 @@ export default function PlayerProfilePage() {
|
||||
Cases opened <strong>{profile.casesOpened}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Duel wins <strong>{profile.duelWins}</strong>
|
||||
Battle wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<ProfileAchievementBadges achievements={profile.achievements} />
|
||||
@@ -223,7 +221,12 @@ export default function PlayerProfilePage() {
|
||||
<>
|
||||
<div className="grid">
|
||||
{pageItems.map((inv) => (
|
||||
<ItemTile key={inv.id} item={inv.item} favorite={inv.favorite} />
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked={inv.staked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, toCentsBigInt } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
function formatSkillValue(skill, value) {
|
||||
@@ -67,7 +67,7 @@ export default function PrestigePage() {
|
||||
patchUser(result.user);
|
||||
await refresh();
|
||||
setMessage(
|
||||
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.`
|
||||
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory & keys reset.`
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -77,14 +77,14 @@ export default function PrestigePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onUpgrade = async (skillId) => {
|
||||
const onUpgrade = async (skillId, count = 1) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await api.prestigeUpgrade(skillId);
|
||||
const result = await api.prestigeUpgrade(skillId, count);
|
||||
patchUser(result.user);
|
||||
setMessage(`Upgraded ${skillId}.`);
|
||||
setMessage(count > 1 ? `Upgraded ${skillId} ×${count}.` : `Upgraded ${skillId}.`);
|
||||
setData((prev) =>
|
||||
prev
|
||||
? {
|
||||
@@ -101,7 +101,10 @@ export default function PrestigePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const shortfall = Math.max(0, cost - (Number(user.balance) || 0));
|
||||
const shortfall =
|
||||
toCentsBigInt(cost) > toCentsBigInt(user.balance)
|
||||
? toCentsBigInt(cost) - toCentsBigInt(user.balance)
|
||||
: 0n;
|
||||
const statusLabel = !data?.canAfford
|
||||
? `Need ${formatCredits(shortfall)} cr more`
|
||||
: data?.lockedItems > 0
|
||||
@@ -178,8 +181,8 @@ export default function PrestigePage() {
|
||||
<strong>+{p.openBonus || 0}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Discount</span>
|
||||
<strong>−{p.caseDiscount || 0}%</strong>
|
||||
<span className="muted">PR Farmer</span>
|
||||
<strong>+{p.prBonus || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Quality</span>
|
||||
@@ -251,6 +254,7 @@ export default function PrestigePage() {
|
||||
<li>All Pr already earned</li>
|
||||
<li>Skill-tree bonuses</li>
|
||||
<li>Account & prestige count</li>
|
||||
<li>Catalog progress</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -258,7 +262,7 @@ export default function PrestigePage() {
|
||||
<ul>
|
||||
<li>Balance</li>
|
||||
<li>Inventory & vault stakes</li>
|
||||
<li>Case keys & catalog progress</li>
|
||||
<li>Case keys</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,21 +312,43 @@ export default function PrestigePage() {
|
||||
<p className="muted">{skill.description}</p>
|
||||
<div className="prestige-skill-meta">
|
||||
<span>
|
||||
Lv {skill.level} · now{' '}
|
||||
Lv {skill.level}
|
||||
{skill.maxLevel != null ? `/${skill.maxLevel}` : ''}
|
||||
{' · now '}
|
||||
<strong>{formatSkillValue(skill, skill.current)}</strong>
|
||||
</span>
|
||||
<span className="prestige-delta">
|
||||
→ {formatSkillValue(skill, skill.nextValue)}
|
||||
</span>
|
||||
{!skill.atMax && (
|
||||
<span className="prestige-delta">
|
||||
→ {formatSkillValue(skill, skill.nextValue)}
|
||||
</span>
|
||||
)}
|
||||
{skill.atMax && <span className="prestige-delta">MAX</span>}
|
||||
</div>
|
||||
<div className="prestige-skill-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={busy || skill.atMax || !skill.canAfford}
|
||||
onClick={() => onUpgrade(skill.id, 1)}
|
||||
>
|
||||
{skill.atMax ? 'Maxed' : `Buy · ${skill.cost} Pr`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={busy || skill.atMax || !skill.canAfford10}
|
||||
onClick={() => onUpgrade(skill.id, skill.buyCount10 || 10)}
|
||||
title={
|
||||
skill.atMax
|
||||
? 'Max level reached'
|
||||
: `${skill.buyCount10 || 10} levels → ${formatSkillValue(skill, skill.nextValue10)}`
|
||||
}
|
||||
>
|
||||
{skill.atMax
|
||||
? '—'
|
||||
: `×${skill.buyCount10 || 10} · ${skill.cost10} Pr`}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={busy || !skill.canAfford}
|
||||
onClick={() => onUpgrade(skill.id)}
|
||||
>
|
||||
Buy · {skill.cost} Pr
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
|
||||
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { onSocketSessionOk } from '../socket';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, loading, refresh, logout } = useAuth();
|
||||
@@ -21,7 +22,7 @@ export default function ProfilePage() {
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await api.profile();
|
||||
if (!cancelled) {
|
||||
@@ -31,9 +32,14 @@ export default function ProfilePage() {
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
};
|
||||
loadProfile();
|
||||
const offSession = onSocketSessionOk(() => {
|
||||
loadProfile().catch(() => {});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offSession();
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
@@ -130,7 +136,13 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
<label className="btn secondary file-btn">
|
||||
Change photo
|
||||
<input type="file" accept="image/*" hidden onChange={onAvatar} disabled={busy} />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
|
||||
hidden
|
||||
onChange={onAvatar}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<div className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
Balance {formatCredits(user.balance)} cr
|
||||
@@ -178,10 +190,7 @@ export default function ProfilePage() {
|
||||
Cases opened <strong>{profile.casesOpened}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Duel wins <strong>{profile.duelWins}</strong>
|
||||
Battle wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<ProfileAchievementBadges
|
||||
|
||||
+160
-54
@@ -23,20 +23,33 @@ function useAdCountdown(nextAdAt) {
|
||||
return remaining;
|
||||
}
|
||||
|
||||
function formatCountdown(sec) {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function ShopPage() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [packages, setPackages] = useState([]);
|
||||
const [osuTopups, setOsuTopups] = useState([]);
|
||||
const [osuTopupsEnabled, setOsuTopupsEnabled] = useState(true);
|
||||
const [payments, setPayments] = useState(null);
|
||||
const [ad, setAd] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [watching, setWatching] = useState(false);
|
||||
const [confirmingId, setConfirmingId] = useState(null);
|
||||
|
||||
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.shop();
|
||||
setPackages(data.packages || []);
|
||||
setOsuTopups(data.osuTopups || []);
|
||||
setOsuTopupsEnabled(data.osuTopupsEnabled !== false && (data.osuTopups || []).length > 0);
|
||||
setPayments(data.payments || null);
|
||||
setAd(data.ad);
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
@@ -60,7 +73,7 @@ export default function ShopPage() {
|
||||
};
|
||||
}, [user?.id, user?.role, load]);
|
||||
|
||||
const buy = async (packageId) => {
|
||||
const buyCredits = async (packageId) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
@@ -70,7 +83,7 @@ export default function ShopPage() {
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
});
|
||||
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`);
|
||||
setMessage(`Exchanged — +${formatCredits(data.package.credits)} cr`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -79,12 +92,41 @@ export default function ShopPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const buyOsu = async (packId) => {
|
||||
setBusy(true);
|
||||
setConfirmingId(packId);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const checkout = await api.shopOsuCheckout(packId);
|
||||
if (checkout.mode === 'mock' && checkout.confirmable) {
|
||||
const data = await api.shopOsuConfirm(checkout.order.id);
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
});
|
||||
setMessage(
|
||||
data.alreadyPaid
|
||||
? 'Order already completed'
|
||||
: `+${data.osuGranted} osu added to your wallet`
|
||||
);
|
||||
await load();
|
||||
} else {
|
||||
setError('Payment provider is not ready for live checkout yet');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setConfirmingId(null);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const watchAd = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
setWatching(true);
|
||||
// Simulated short ad
|
||||
await new Promise((r) => setTimeout(r, 1800));
|
||||
try {
|
||||
const data = await api.shopAd();
|
||||
@@ -115,30 +157,122 @@ export default function ShopPage() {
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
|
||||
const osuBal = user.osuBalance ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Shop</h1>
|
||||
<p className="muted">Spend osu for credit packs, or watch an ad every minute.</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<span className="osu-pill">{user.osuBalance ?? 0} osu</span>
|
||||
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="ok-msg">{message}</div>}
|
||||
|
||||
<div className="admin-block ad-panel">
|
||||
<h2>Watch an ad</h2>
|
||||
<p className="muted">
|
||||
Earn between {formatCredits(ad?.rewardMin || 10000)} and{' '}
|
||||
{formatCredits(ad?.rewardMax || 20000)} cr. Cooldown: 1 minute.
|
||||
<div className="shop-page">
|
||||
<header className="shop-hero">
|
||||
<div className="shop-hero-copy">
|
||||
<p className="shop-kicker">CaseOrion Shop</p>
|
||||
<h1>Fuel your runs</h1>
|
||||
<p className="shop-lede">
|
||||
<strong>osu</strong> is the premium wallet — bought with real money, spent on credit
|
||||
packs. Credits are for opening cases, vaulting, and battling.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shop-wallet">
|
||||
<div className="shop-wallet-row">
|
||||
<span className="muted">osu</span>
|
||||
<strong className="osu-pill shop-wallet-osu">{osuBal}</strong>
|
||||
</div>
|
||||
<div className="shop-wallet-row">
|
||||
<span className="muted">Credits</span>
|
||||
<strong className="balance-pill">{formatCredits(user.balance)} cr</strong>
|
||||
</div>
|
||||
{payments?.mock && (
|
||||
<p className="shop-mock-note muted">
|
||||
Dev payments: mock checkout credits osu instantly (Stripe-ready backend).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="ok-msg">{message}</div>}
|
||||
|
||||
{osuTopupsEnabled && osuTopups.length > 0 && (
|
||||
<section className="shop-section">
|
||||
<div className="shop-section-head">
|
||||
<h2>Get osu</h2>
|
||||
<p className="muted">Premium top-ups · better rate on larger packs</p>
|
||||
</div>
|
||||
<div className="shop-osu-grid">
|
||||
{osuTopups.map((pack) => (
|
||||
<article
|
||||
key={pack.id}
|
||||
className={`shop-osu-card${pack.badge ? ' has-badge' : ''}${
|
||||
pack.badge === 'Best value' ? ' highlight' : ''
|
||||
}${pack.badge === 'Popular' ? ' popular' : ''}`}
|
||||
>
|
||||
{pack.badge ? <span className="shop-badge">{pack.badge}</span> : null}
|
||||
<h3>{pack.name}</h3>
|
||||
<p className="shop-osu-amount">
|
||||
{pack.osu} <span>osu</span>
|
||||
</p>
|
||||
<p className="muted shop-osu-blurb">{pack.blurb}</p>
|
||||
<p className="shop-osu-rate muted">{pack.perOsuLabel}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn shop-buy-btn"
|
||||
disabled={busy}
|
||||
onClick={() => buyOsu(pack.id)}
|
||||
>
|
||||
{confirmingId === pack.id
|
||||
? 'Processing…'
|
||||
: `Buy · ${pack.displayPrice || `€${(pack.priceCents / 100).toFixed(2)}`}`}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="shop-section">
|
||||
<div className="shop-section-head">
|
||||
<h2>Exchange for credits</h2>
|
||||
<p className="muted">
|
||||
Spend osu · payout scales with your credit balance (min floor early game)
|
||||
</p>
|
||||
</div>
|
||||
<div className="shop-credit-grid">
|
||||
{packages.map((p) => {
|
||||
const canAfford = osuBal >= p.osuCost;
|
||||
return (
|
||||
<article key={p.id} className="shop-credit-card">
|
||||
{p.tag ? <span className="shop-badge subtle">{p.tag}</span> : null}
|
||||
<h3>{p.name}</h3>
|
||||
<p className="pack-credits">+{formatCredits(p.credits)} cr</p>
|
||||
<p className="muted shop-credit-scale">
|
||||
{p.balancePct != null ? `${p.balancePct}% of balance` : null}
|
||||
{p.scaled === false ? ' · floor' : ''}
|
||||
</p>
|
||||
<p className="shop-credit-cost">
|
||||
<span className="osu-pill">{p.osuCost} osu</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={busy || !canAfford}
|
||||
onClick={() => buyCredits(p.id)}
|
||||
title={canAfford ? undefined : 'Not enough osu'}
|
||||
>
|
||||
{canAfford ? 'Exchange' : 'Need osu'}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shop-section shop-free">
|
||||
<div className="shop-ad-panel">
|
||||
<div>
|
||||
<h2>Free credits</h2>
|
||||
<p className="muted">
|
||||
Watch a short ad every minute for {formatCredits(ad?.rewardMin || 10000)}–
|
||||
{formatCredits(ad?.rewardMax || 20000)} cr.
|
||||
</p>
|
||||
</div>
|
||||
{watching ? (
|
||||
<div className="ad-sim">
|
||||
<div className="ad-bar" />
|
||||
@@ -147,43 +281,15 @@ export default function ShopPage() {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
className="btn secondary"
|
||||
disabled={busy || !adReady}
|
||||
onClick={watchAd}
|
||||
>
|
||||
{adReady
|
||||
? 'Watch ad'
|
||||
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
|
||||
remaining % 60
|
||||
).padStart(2, '0')}`}
|
||||
{adReady ? 'Watch ad' : `Ready in ${formatCountdown(remaining)}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Credit packs</h2>
|
||||
<p className="muted" style={{ marginBottom: '1rem' }}>
|
||||
Buy credits with osu (demo currency).
|
||||
</p>
|
||||
<div className="mode-grid">
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className="mode-card shop-pack">
|
||||
<h2>{p.name}</h2>
|
||||
<p className="pack-credits">{p.blurb}</p>
|
||||
<p className="muted">{p.osuCost} osu</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || (user.osuBalance ?? 0) < p.osuCost}
|
||||
onClick={() => buy(p.id)}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+107
-3
@@ -4,10 +4,14 @@ let socket = null;
|
||||
let limited = false;
|
||||
let limitPayload = null;
|
||||
const limitListeners = new Set();
|
||||
const sessionOkListeners = new Set();
|
||||
let unloadBound = false;
|
||||
let connectTimer = null;
|
||||
let reconnectSeq = 0;
|
||||
|
||||
function notifyLimit(payload) {
|
||||
limited = true;
|
||||
limitPayload = payload || { max: 10 };
|
||||
limitPayload = payload || { max: 2 };
|
||||
for (const fn of limitListeners) {
|
||||
try {
|
||||
fn(limitPayload);
|
||||
@@ -29,32 +33,125 @@ function notifyOk() {
|
||||
}
|
||||
}
|
||||
|
||||
function notifySessionOk(payload) {
|
||||
for (const fn of sessionOkListeners) {
|
||||
try {
|
||||
fn(payload);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearConnectTimer() {
|
||||
if (connectTimer != null) {
|
||||
clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
if (!socket || limited) return;
|
||||
socket.io.reconnection(true);
|
||||
if (!socket.connected) {
|
||||
clearConnectTimer();
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null;
|
||||
if (!socket || limited || socket.connected) return;
|
||||
socket.connect();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function bindUnloadOnce() {
|
||||
if (unloadBound || typeof window === 'undefined') return;
|
||||
unloadBound = true;
|
||||
const release = () => {
|
||||
clearConnectTimer();
|
||||
if (socket?.connected) {
|
||||
// Manual disconnect frees the presence slot; pageshow/visibility will reconnect.
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
// pagehide covers tab close, bfcache, and mobile backgrounding better than unload
|
||||
window.addEventListener('pagehide', release);
|
||||
window.addEventListener('pageshow', () => {
|
||||
ensureConnected();
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') ensureConnected();
|
||||
});
|
||||
}
|
||||
|
||||
export function getSocket() {
|
||||
if (!socket) {
|
||||
// Connect only after auth is known (see refreshSocketSession) to avoid
|
||||
// anonymous+auth double handshakes that stack slots on rapid refresh.
|
||||
socket = io({
|
||||
path: '/socket.io',
|
||||
withCredentials: true,
|
||||
autoConnect: true,
|
||||
autoConnect: false,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 500,
|
||||
reconnectionAttempts: Infinity,
|
||||
});
|
||||
|
||||
bindUnloadOnce();
|
||||
|
||||
socket.on('session:limit', (payload) => {
|
||||
socket.io.reconnection(false);
|
||||
notifyLimit(payload);
|
||||
});
|
||||
|
||||
socket.on('session:ok', () => {
|
||||
socket.on('session:replaced', () => {
|
||||
// Another tab/refresh took our slot — stay quiet, allow a later reconnect
|
||||
// without showing the hard limit overlay.
|
||||
socket.io.reconnection(false);
|
||||
});
|
||||
|
||||
socket.on('session:ok', (payload) => {
|
||||
if (!socket.io.reconnection()) {
|
||||
socket.io.reconnection(true);
|
||||
}
|
||||
notifyOk();
|
||||
notifySessionOk(payload);
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a new Socket.IO handshake so express-session reloads the auth cookie.
|
||||
* Needed after login/register/logout — otherwise an anonymous socket stays "offline".
|
||||
* Serialized so spam refresh cannot open many overlapping sockets from this tab.
|
||||
*/
|
||||
export function refreshSocketSession() {
|
||||
const s = getSocket();
|
||||
if (limited) return s;
|
||||
|
||||
const seq = ++reconnectSeq;
|
||||
clearConnectTimer();
|
||||
s.io.reconnection(true);
|
||||
|
||||
const connectNow = () => {
|
||||
if (seq !== reconnectSeq || limited) return;
|
||||
if (!s.connected) s.connect();
|
||||
};
|
||||
|
||||
if (s.connected) {
|
||||
s.once('disconnect', () => {
|
||||
if (seq !== reconnectSeq) return;
|
||||
clearConnectTimer();
|
||||
connectTimer = setTimeout(connectNow, 40);
|
||||
});
|
||||
s.disconnect();
|
||||
} else {
|
||||
clearConnectTimer();
|
||||
connectTimer = setTimeout(connectNow, 0);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function isSessionLimited() {
|
||||
return limited;
|
||||
}
|
||||
@@ -70,10 +167,17 @@ export function onSessionLimitChange(fn) {
|
||||
return () => limitListeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Fired when the server confirms an authenticated presence session. */
|
||||
export function onSocketSessionOk(fn) {
|
||||
sessionOkListeners.add(fn);
|
||||
return () => sessionOkListeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Allow reconnect after the user closes other tabs and reloads. */
|
||||
export function clearSessionLimit() {
|
||||
notifyOk();
|
||||
if (socket) {
|
||||
socket.io.reconnection(true);
|
||||
if (!socket.connected) socket.connect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
port: 5888,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
|
||||
Reference in New Issue
Block a user