cool prestige update
This commit is contained in:
@@ -37,10 +37,12 @@ export default function AdminDrops() {
|
||||
[cases, selectedCaseId]
|
||||
);
|
||||
|
||||
const percents = useMemo(
|
||||
() => (selected ? dropPercents(selected.items) : []),
|
||||
[selected]
|
||||
);
|
||||
const poolItems = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate));
|
||||
}, [selected]);
|
||||
|
||||
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -116,12 +118,12 @@ export default function AdminDrops() {
|
||||
</div>
|
||||
|
||||
<div className="admin-block">
|
||||
<h3>Pool ({selected.items.length})</h3>
|
||||
{selected.items.length === 0 ? (
|
||||
<h3>Pool ({poolItems.length})</h3>
|
||||
{poolItems.length === 0 ? (
|
||||
<div className="muted">No drops yet.</div>
|
||||
) : (
|
||||
<div className="drop-list">
|
||||
{selected.items.map((ci, idx) => (
|
||||
{poolItems.map((ci, idx) => (
|
||||
<div key={ci.id} className="drop-row">
|
||||
<span className="drop-item-cell">
|
||||
{ci.item.imageUrl ? (
|
||||
|
||||
+65
-2
@@ -46,6 +46,17 @@ export const api = {
|
||||
body: JSON.stringify({ count }),
|
||||
}),
|
||||
inventory: () => request('/api/inventory'),
|
||||
vaultStake: (inventoryItemIds) =>
|
||||
request('/api/vault/stake', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
vaultUnstake: (inventoryItemIds) =>
|
||||
request('/api/vault/unstake', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
vaultClaim: () => request('/api/vault/claim', { method: 'POST' }),
|
||||
sellInventory: (inventoryItemIds) =>
|
||||
request('/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
@@ -174,8 +185,13 @@ export const api = {
|
||||
},
|
||||
};
|
||||
|
||||
export function formatCredits(cents) {
|
||||
const fixed = (Number(cents) / 100).toFixed(2);
|
||||
/**
|
||||
* 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('.');
|
||||
@@ -183,6 +199,53 @@ export function formatCredits(cents) {
|
||||
return `${neg ? '-' : ''}${grouped}.${dec}`;
|
||||
}
|
||||
|
||||
/** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */
|
||||
function alphabetSuffix(index) {
|
||||
let num = Math.max(0, Math.floor(index));
|
||||
let result = '';
|
||||
do {
|
||||
result = String.fromCharCode(97 + (num % 26)) + result;
|
||||
num = Math.floor(num / 26);
|
||||
} while (num > 0);
|
||||
while (result.length < 2) result = `a${result}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
function tierSuffix(tier) {
|
||||
if (tier <= 0) return '';
|
||||
if (tier === 1) return 'K';
|
||||
if (tier === 2) return 'M';
|
||||
if (tier === 3) return 'B';
|
||||
if (tier === 4) return 'T';
|
||||
return alphabetSuffix(tier - 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact credit display for UI readability.
|
||||
* 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;
|
||||
|
||||
if (credits < 1000) {
|
||||
return `${neg ? '-' : ''}${credits.toFixed(2)}`;
|
||||
}
|
||||
|
||||
let tier = 0;
|
||||
let scaled = credits;
|
||||
while (scaled >= 1000 && tier < 1000) {
|
||||
scaled /= 1000;
|
||||
tier += 1;
|
||||
}
|
||||
|
||||
const suffix = tierSuffix(tier);
|
||||
const shown = String(Number(scaled.toFixed(2)));
|
||||
return `${neg ? '-' : ''}${shown}${suffix}`;
|
||||
}
|
||||
|
||||
export function formatChance(pct) {
|
||||
return `${Number(pct || 0).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import { rarityColor } from '../rarities';
|
||||
|
||||
const ITEM_WIDTH = 120;
|
||||
const ITEM_WIDTH_COMPACT = 72;
|
||||
const SPIN_DURATION_MS = 5500;
|
||||
const BASE_SPIN_DURATION_MS = 5500;
|
||||
const SPIN_SLOTS = 42;
|
||||
const TAIL_SLOTS = 8;
|
||||
|
||||
export function spinDurationMs(animReduction = 0) {
|
||||
const pct = Math.max(0, Math.min(90, Number(animReduction) || 0));
|
||||
return Math.max(1000, Math.round(BASE_SPIN_DURATION_MS * (1 - pct / 100)));
|
||||
}
|
||||
|
||||
function pickRandom(items, avoidId) {
|
||||
if (!items.length) return null;
|
||||
if (items.length === 1) return items[0];
|
||||
@@ -78,6 +83,7 @@ export default function CaseReel({
|
||||
onDone,
|
||||
showOwners = false,
|
||||
compact = false,
|
||||
animReduction = 0,
|
||||
}) {
|
||||
const trackRef = useRef(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
@@ -85,6 +91,7 @@ export default function CaseReel({
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
const itemWidth = compact ? ITEM_WIDTH_COMPACT : ITEM_WIDTH;
|
||||
const durationMs = spinDurationMs(animReduction);
|
||||
|
||||
const spinKey =
|
||||
winnerId && items?.length ? `${winnerId}:${items.map((i) => i.id).join(',')}` : '';
|
||||
@@ -122,7 +129,7 @@ export default function CaseReel({
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
onDoneRef.current?.();
|
||||
}, SPIN_DURATION_MS + 80);
|
||||
}, durationMs + 80);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(id);
|
||||
@@ -131,7 +138,7 @@ export default function CaseReel({
|
||||
};
|
||||
// intentionally only re-run when a new spin starts
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [spinning, winnerId, itemWidth]);
|
||||
}, [spinning, winnerId, itemWidth, durationMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -144,7 +151,7 @@ export default function CaseReel({
|
||||
style={{
|
||||
transform: `translateX(-${offset}px)`,
|
||||
transition: spinning
|
||||
? `transform ${SPIN_DURATION_MS}ms cubic-bezier(0.12, 0.75, 0.12, 1)`
|
||||
? `transform ${durationMs}ms cubic-bezier(0.12, 0.75, 0.12, 1)`
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatCredits } from '../api';
|
||||
import { formatCredits, formatCreditsExact } from '../api';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
/** Instance sell/bet price, else catalog FT reference */
|
||||
@@ -22,6 +22,7 @@ export default function ItemTile({
|
||||
showWear = true,
|
||||
selected = false,
|
||||
favorite = false,
|
||||
staked = false,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}) {
|
||||
@@ -37,16 +38,23 @@ export default function ItemTile({
|
||||
}`
|
||||
: wear || undefined;
|
||||
|
||||
const titleParts = [];
|
||||
if (staked) titleParts.push('Vault');
|
||||
if (favorite) titleParts.push('Favorite');
|
||||
if (floatTitle) titleParts.push(floatTitle);
|
||||
else titleParts.push(item.name);
|
||||
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`item-tile${selected ? ' selected' : ''}${favorite ? ' favorite' : ''}${
|
||||
clickable ? ' selectable' : ''
|
||||
}${disabled ? ' disabled' : ''}`}
|
||||
staked ? ' staked' : ''
|
||||
}${clickable ? ' selectable' : ''}${disabled ? ' disabled' : ''}`}
|
||||
style={{ borderTopColor: color }}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable && !disabled ? 0 : undefined}
|
||||
title={favorite ? `Favorite · ${floatTitle || item.name}` : floatTitle}
|
||||
title={titleParts.join(' · ')}
|
||||
onKeyDown={
|
||||
clickable && !disabled
|
||||
? (e) => {
|
||||
@@ -60,6 +68,7 @@ export default function ItemTile({
|
||||
>
|
||||
<div className={`item-visual${hasImage ? ' has-img' : ''}`} style={{ color }}>
|
||||
{favorite ? <span className="item-favorite-badge" aria-label="Favorite">★</span> : null}
|
||||
{staked ? <span className="item-vault-badge" aria-label="Vault">V</span> : null}
|
||||
{hasImage ? (
|
||||
<img src={item.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
@@ -76,7 +85,9 @@ export default function ItemTile({
|
||||
</div>
|
||||
) : null}
|
||||
{showValue && (
|
||||
<div className="item-meta">{formatCredits(value)} cr</div>
|
||||
<div className="item-meta" title={`${formatCreditsExact(value)} cr`}>
|
||||
{formatCredits(value)} cr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, formatCreditsExact } from '../api';
|
||||
import DropFeed from './DropFeed';
|
||||
import SessionLimitOverlay from './SessionLimitOverlay';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
@@ -213,11 +213,20 @@ export default function Layout() {
|
||||
{user?.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
|
||||
{user && (
|
||||
isPlayer ? (
|
||||
<NavLink to="/shop" className="balance-pill balance-pill-link">
|
||||
<NavLink
|
||||
to="/shop"
|
||||
className="balance-pill balance-pill-link"
|
||||
title={`${formatCreditsExact(user.balance)} cr`}
|
||||
>
|
||||
{formatCredits(user.balance)} cr
|
||||
</NavLink>
|
||||
) : (
|
||||
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
|
||||
<span
|
||||
className="balance-pill"
|
||||
title={`${formatCreditsExact(user.balance)} cr`}
|
||||
>
|
||||
{formatCredits(user.balance)} cr
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{user ? (
|
||||
|
||||
@@ -1,8 +1,54 @@
|
||||
import { formatCredits } from '../api';
|
||||
|
||||
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> }
|
||||
: null,
|
||||
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
|
||||
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
|
||||
(prestige.wearBonus || 0) > 0 && {
|
||||
label: 'Polish',
|
||||
value: <strong>+{prestige.wearBonus}</strong>,
|
||||
},
|
||||
(prestige.openBonus || 0) > 0 && {
|
||||
label: 'Bulk',
|
||||
value: <strong>+{prestige.openBonus} opens</strong>,
|
||||
},
|
||||
(prestige.caseDiscount || 0) > 0 && {
|
||||
label: 'Discount',
|
||||
value: <strong>−{prestige.caseDiscount}%</strong>,
|
||||
},
|
||||
(prestige.qualityBonus || 0) > 0 && {
|
||||
label: 'Quality',
|
||||
value: <strong>+{prestige.qualityBonus}</strong>,
|
||||
},
|
||||
(prestige.animReduction || 0) > 0 && {
|
||||
label: 'noTime',
|
||||
value: <strong>−{prestige.animReduction}%</strong>,
|
||||
},
|
||||
(prestige.refundChance || 0) > 0 && {
|
||||
label: 'Refund',
|
||||
value: <strong>{prestige.refundChance}%</strong>,
|
||||
},
|
||||
(prestige.respinChance || 0) > 0 && {
|
||||
label: 'Respin',
|
||||
value: <strong>{prestige.respinChance}%</strong>,
|
||||
},
|
||||
(prestige.vaultSlots || 0) > 0 && {
|
||||
label: 'Vault Capacity',
|
||||
value: <strong>+{prestige.vaultSlots}</strong>,
|
||||
},
|
||||
(prestige.offlineYield || 0) > 0 && {
|
||||
label: 'Offline Yield',
|
||||
value: <strong>+{prestige.offlineYield}%</strong>,
|
||||
},
|
||||
(prestige.onlineYield || 0) > 0 && {
|
||||
label: 'Online Yield',
|
||||
value: <strong>+{prestige.onlineYield}%</strong>,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="prestige-profile-block">
|
||||
<div className="catalog-badges-head">
|
||||
@@ -10,65 +56,11 @@ export default function ProfilePrestigeStats({ prestige }) {
|
||||
<span className="catalog-badges-luck">×{prestige.count}</span>
|
||||
</div>
|
||||
<ul className="stat-list prestige-profile-stats">
|
||||
{typeof prestige.prBalance === 'number' && prestige.prBalance > 0 && (
|
||||
<li>
|
||||
Pr <strong>{prestige.prBalance}</strong>
|
||||
{rows.map((row) => (
|
||||
<li key={row.label}>
|
||||
{row.label} {row.value}
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
Chance <strong>+{prestige.chanceBonus}%</strong>
|
||||
</li>
|
||||
<li>
|
||||
Sell gains <strong>+{prestige.gainBonus}%</strong>
|
||||
</li>
|
||||
<li>
|
||||
Key opens <strong>−{prestige.keyOpenReduction}</strong>
|
||||
</li>
|
||||
{(prestige.startBonus || 0) > 0 && (
|
||||
<li>
|
||||
Vault <strong>{formatCredits(prestige.startBonus)} cr</strong>
|
||||
</li>
|
||||
)}
|
||||
{(prestige.adBonus || 0) > 0 && (
|
||||
<li>
|
||||
Ad reward <strong>+{prestige.adBonus}%</strong>
|
||||
</li>
|
||||
)}
|
||||
{(prestige.adCooldownReduction || 0) > 0 && (
|
||||
<li>
|
||||
Ad cooldown <strong>−{prestige.adCooldownReduction}s</strong>
|
||||
</li>
|
||||
)}
|
||||
{(prestige.wearBonus || 0) > 0 && (
|
||||
<li>
|
||||
Polish <strong>+{prestige.wearBonus}</strong>
|
||||
</li>
|
||||
)}
|
||||
{(prestige.prBonus || 0) > 0 && (
|
||||
<li>
|
||||
Pr echo <strong>+{prestige.prBonus}%</strong>
|
||||
</li>
|
||||
)}
|
||||
{(prestige.shopDiscount || 0) > 0 && (
|
||||
<li>
|
||||
Haggle <strong>−{prestige.shopDiscount} osu</strong>
|
||||
</li>
|
||||
)}
|
||||
{(prestige.startOsu || 0) > 0 && (
|
||||
<li>
|
||||
Purse <strong>+{prestige.startOsu} osu</strong>
|
||||
</li>
|
||||
)}
|
||||
{(prestige.dropValueBonus || 0) > 0 && (
|
||||
<li>
|
||||
Magnet <strong>+{prestige.dropValueBonus}%</strong>
|
||||
</li>
|
||||
)}
|
||||
{(prestige.openBonus || 0) > 0 && (
|
||||
<li>
|
||||
Bulk <strong>+{prestige.openBonus} opens</strong>
|
||||
</li>
|
||||
)}
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1841,6 +1841,48 @@ a.balance-pill-link.active {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.item-vault-badge {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
left: 0.35rem;
|
||||
z-index: 1;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
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;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.item-tile.staked {
|
||||
box-shadow: inset 0 0 0 1px rgba(80, 160, 220, 0.4);
|
||||
}
|
||||
|
||||
.vault-panel {
|
||||
margin: 1.25rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.vault-panel .section-head {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.vault-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.vault-grid {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.item-tile.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -27,6 +27,8 @@ export default function CasePage() {
|
||||
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([]);
|
||||
@@ -243,6 +245,7 @@ export default function CasePage() {
|
||||
setError('');
|
||||
setKeyMessage('');
|
||||
setCatalogMessage('');
|
||||
setOpenBonusMessage('');
|
||||
setSpins([]);
|
||||
setOpening(true);
|
||||
announcedRef.current = false;
|
||||
@@ -253,6 +256,13 @@ export default function CasePage() {
|
||||
updateBalance(result.user.balance);
|
||||
notifyInventoryChanged();
|
||||
persistQty(crate.id, quantity);
|
||||
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.keyProgress) {
|
||||
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
||||
}
|
||||
@@ -352,14 +362,18 @@ export default function CasePage() {
|
||||
{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 · Balance{' '}
|
||||
{formatCredits(user.balance)} cr
|
||||
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}%`
|
||||
: ''}
|
||||
@@ -435,6 +449,7 @@ export default function CasePage() {
|
||||
winnerId={spin.item.id}
|
||||
spinning={spin.spinning}
|
||||
onDone={() => onReelDone(spin.key)}
|
||||
animReduction={animReduction}
|
||||
/>
|
||||
{spin.done && (
|
||||
<div
|
||||
|
||||
@@ -11,6 +11,8 @@ const PAGE_SIZE = 50;
|
||||
export default function Dashboard() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [vault, setVault] = useState(null);
|
||||
const [vaultMessage, setVaultMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [sort, setSort] = useState('value');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -37,6 +39,7 @@ export default function Dashboard() {
|
||||
const load = async () => {
|
||||
const inv = await api.inventory();
|
||||
setInventory(inv.inventory);
|
||||
setVault(inv.vault || null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,6 +112,7 @@ export default function Dashboard() {
|
||||
return inventory.filter(
|
||||
(inv) =>
|
||||
!inv.favorite &&
|
||||
!inv.staked &&
|
||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
);
|
||||
}, [inventory, thresholdCents]);
|
||||
@@ -118,10 +122,17 @@ export default function Dashboard() {
|
||||
[inventory, selected]
|
||||
);
|
||||
|
||||
const stakedItems = useMemo(() => inventory.filter((inv) => inv.staked), [inventory]);
|
||||
|
||||
const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
|
||||
const selectedAllFavorite =
|
||||
selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite);
|
||||
const selectedCanSell = selectedItems.some((inv) => !inv.favorite);
|
||||
const selectedCanSell = selectedItems.some((inv) => !inv.favorite && !inv.staked);
|
||||
const selectedStaked = selectedItems.filter((inv) => inv.staked);
|
||||
const selectedUnstaked = selectedItems.filter((inv) => !inv.staked);
|
||||
const vaultFree = Math.max(0, (vault?.slots ?? 3) - (vault?.used ?? 0));
|
||||
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
|
||||
const canUnstake = selectedStaked.length > 0;
|
||||
|
||||
const belowValue = useMemo(
|
||||
() =>
|
||||
@@ -155,11 +166,78 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
const sell = async () => {
|
||||
const ids = selectedItems.filter((inv) => !inv.favorite).map((inv) => inv.id);
|
||||
const ids = selectedItems
|
||||
.filter((inv) => !inv.favorite && !inv.staked)
|
||||
.map((inv) => inv.id);
|
||||
if (!ids.length) return;
|
||||
await sellIds(ids);
|
||||
};
|
||||
|
||||
const stakeSelected = async () => {
|
||||
const ids = selectedUnstaked.map((inv) => inv.id);
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setVaultMessage('');
|
||||
try {
|
||||
const data = await api.vaultStake(ids);
|
||||
if (data.user) patchUser({ balance: data.user.balance });
|
||||
setSelected([]);
|
||||
setVaultMessage(`Staked ${ids.length} item${ids.length === 1 ? '' : 's'} in vault`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unstakeSelected = async () => {
|
||||
const ids = selectedStaked.map((inv) => inv.id);
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setVaultMessage('');
|
||||
try {
|
||||
const data = await api.vaultUnstake(ids);
|
||||
if (data.user) patchUser({ balance: data.user.balance });
|
||||
setSelected([]);
|
||||
const yieldPart =
|
||||
data.yieldCredited > 0
|
||||
? ` · +${formatCredits(data.yieldCredited)} cr yield`
|
||||
: '';
|
||||
setVaultMessage(
|
||||
`Unstaked ${ids.length} item${ids.length === 1 ? '' : 's'}${yieldPart}`
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const 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);
|
||||
@@ -303,10 +381,34 @@ export default function Dashboard() {
|
||||
className="btn"
|
||||
onClick={sell}
|
||||
disabled={busy || !selectedCanSell}
|
||||
title={selectedHasFavorite ? 'Favorites cannot be sold' : undefined}
|
||||
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>
|
||||
@@ -381,6 +483,57 @@ export default function Dashboard() {
|
||||
</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 ? (
|
||||
<div className="empty">
|
||||
Empty for now —{' '}
|
||||
@@ -397,6 +550,7 @@ 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)}
|
||||
/>
|
||||
|
||||
@@ -162,53 +162,53 @@ export default function PrestigePage() {
|
||||
{p && (
|
||||
<div className="prestige-totals prestige-totals-wide">
|
||||
<div>
|
||||
<span className="muted">Chance</span>
|
||||
<span className="muted">Fortune</span>
|
||||
<strong>+{p.chanceBonus}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Gains</span>
|
||||
<span className="muted">Yield</span>
|
||||
<strong>+{p.gainBonus}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Key opens</span>
|
||||
<strong>−{p.keyOpenReduction}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Vault</span>
|
||||
<strong>{formatCredits(p.startBonus || 0)} cr</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Ad reward</span>
|
||||
<strong>+{p.adBonus || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Ad cooldown</span>
|
||||
<strong>−{p.adCooldownReduction || 0}s</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Polish</span>
|
||||
<strong>+{p.wearBonus || 0}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Pr echo</span>
|
||||
<strong>+{p.prBonus || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Haggle</span>
|
||||
<strong>−{p.shopDiscount || 0} osu</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Purse</span>
|
||||
<strong>+{p.startOsu || 0} osu</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Magnet</span>
|
||||
<strong>+{p.dropValueBonus || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Bulk</span>
|
||||
<strong>+{p.openBonus || 0}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Discount</span>
|
||||
<strong>−{p.caseDiscount || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Quality</span>
|
||||
<strong>+{p.qualityBonus || 0}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">noTime</span>
|
||||
<strong>−{p.animReduction || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Refund</span>
|
||||
<strong>{p.refundChance || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Respin</span>
|
||||
<strong>{p.respinChance || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Vault slots</span>
|
||||
<strong>+{p.vaultSlots || 0}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Offline Yield</span>
|
||||
<strong>+{p.offlineYield || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Online Yield</span>
|
||||
<strong>+{p.onlineYield || 0}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -256,8 +256,8 @@ export default function PrestigePage() {
|
||||
<div>
|
||||
<h3>You lose</h3>
|
||||
<ul>
|
||||
<li>Balance (minus Vault / Purse)</li>
|
||||
<li>Inventory</li>
|
||||
<li>Balance</li>
|
||||
<li>Inventory & vault stakes</li>
|
||||
<li>Case keys & catalog progress</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user