- {feedOpen && user && user.role !== 'admin' &&
}
+ {feedOpen && isPlayer &&
}
diff --git a/client/src/components/ProfileAchievementBadges.jsx b/client/src/components/ProfileAchievementBadges.jsx
new file mode 100644
index 0000000..be335b3
--- /dev/null
+++ b/client/src/components/ProfileAchievementBadges.jsx
@@ -0,0 +1,51 @@
+import { Link } from 'react-router-dom';
+
+export default function ProfileAchievementBadges({
+ achievements = [],
+ linkToAchievements = false,
+}) {
+ if (!achievements.length) return null;
+
+ return (
+
+
+
Achievements
+ {linkToAchievements && (
+
+ View all
+
+ )}
+
+
+ {achievements.map((a) => {
+ const inner = (
+ <>
+
+ {a.icon || '★'}
+
+
{a.name}
+ >
+ );
+ return linkToAchievements ? (
+
+ {inner}
+
+ ) : (
+
+ {inner}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/client/src/components/ProfileCaseStats.jsx b/client/src/components/ProfileCaseStats.jsx
new file mode 100644
index 0000000..3363bb7
--- /dev/null
+++ b/client/src/components/ProfileCaseStats.jsx
@@ -0,0 +1,33 @@
+import { Link } from 'react-router-dom';
+
+export default function ProfileCaseStats({ caseOpens = [] }) {
+ if (!caseOpens.length) {
+ return
No cases opened yet.
;
+ }
+
+ return (
+
+ {caseOpens.map((row) => (
+
+
+
+ {row.imageUrl ? (
+
+ ) : (
+ '▣'
+ )}
+
+ {row.caseName}
+
+ {row.count}
+ opened
+
+
+ {row.keys} key{row.keys === 1 ? '' : 's'}
+
+
+
+ ))}
+
+ );
+}
diff --git a/client/src/components/ProfileCatalogBadges.jsx b/client/src/components/ProfileCatalogBadges.jsx
new file mode 100644
index 0000000..a4bcbae
--- /dev/null
+++ b/client/src/components/ProfileCatalogBadges.jsx
@@ -0,0 +1,55 @@
+import { Link } from 'react-router-dom';
+
+export default function ProfileCatalogBadges({
+ completed = [],
+ luckPercent = 0,
+ linkToCatalog = false,
+}) {
+ if (!completed.length) return null;
+
+ return (
+
+
+
Catalog
+ {luckPercent > 0 && (
+ +{luckPercent}% luck
+ )}
+
+
+ {completed.map((ch) => {
+ const inner = (
+ <>
+ {ch.imageUrl ? (
+
+ ) : (
+
▣
+ )}
+
+ ✓
+
+
{ch.name}
+ >
+ );
+ return linkToCatalog ? (
+
+ {inner}
+
+ ) : (
+
+ {inner}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/client/src/components/ProfilePrestigeStats.jsx b/client/src/components/ProfilePrestigeStats.jsx
new file mode 100644
index 0000000..95446c6
--- /dev/null
+++ b/client/src/components/ProfilePrestigeStats.jsx
@@ -0,0 +1,23 @@
+export default function ProfilePrestigeStats({ prestige }) {
+ if (!prestige || !prestige.count) return null;
+
+ return (
+
+
+
Prestige
+ ×{prestige.count}
+
+
+
+ Chance +{prestige.chanceBonus}%
+
+
+ Sell gains +{prestige.gainBonus}%
+
+
+ Key opens −{prestige.keyOpenReduction}
+
+
+
+ );
+}
diff --git a/client/src/components/SessionLimitOverlay.jsx b/client/src/components/SessionLimitOverlay.jsx
new file mode 100644
index 0000000..5899256
--- /dev/null
+++ b/client/src/components/SessionLimitOverlay.jsx
@@ -0,0 +1,104 @@
+import { useEffect, useState } from 'react';
+
+const FAKE_ADS = [
+ {
+ brand: 'ServerFuel™',
+ tagline: 'Keep the forge warm',
+ blurb: 'Fictional energy drink for fictional servers. 0 calories, 100% vibes.',
+ },
+ {
+ brand: 'CloudCrate Hosting',
+ tagline: 'Boxes in the sky',
+ blurb: 'Not a real host. Just a polite reminder that bandwidth isn’t free.',
+ },
+ {
+ brand: 'FairPlay Shield',
+ tagline: 'One account, few tabs',
+ blurb: 'Demo ad. Thanks for not multi-boxing the backend into orbit.',
+ },
+];
+
+export default function SessionLimitOverlay({ max = 10 }) {
+ const [ad] = useState(
+ () => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
+ );
+ const [phase, setPhase] = useState('idle'); // idle | watching | done
+ const [secondsLeft, setSecondsLeft] = useState(5);
+
+ useEffect(() => {
+ if (phase !== 'watching') return undefined;
+ if (secondsLeft <= 0) {
+ setPhase('done');
+ return undefined;
+ }
+ const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
+ return () => clearTimeout(t);
+ }, [phase, secondsLeft]);
+
+ const startAd = () => {
+ setSecondsLeft(5);
+ setPhase('watching');
+ };
+
+ return (
+
+
+
Connection limit
+
Too many windows
+
+ This account already has {max} tabs/windows connected.
+ Close another one, then reload this page.
+
+
+ Merci de ne pas abuser ni exploiter de bugs pour multiplier les sessions —
+ ça charge le serveur pour tout le monde. Un compte, un usage raisonnable.
+
+
+
+
Sponsored · fictif
+
+ {phase === 'idle' && (
+ <>
+
{ad.brand}
+
{ad.tagline}
+
{ad.blurb}
+
+ Watch ad (5s) · support the servers
+
+ >
+ )}
+ {phase === 'watching' && (
+ <>
+
{ad.brand}
+
+
Ad playing… {secondsLeft}s
+
Fictional spot — thanks for the support gesture.
+ >
+ )}
+ {phase === 'done' && (
+ <>
+
Thanks!
+
+ Pub fictive terminée. Pour débloquer cette page : ferme un autre onglet,
+ puis recharge.
+
+
window.location.reload()}
+ >
+ Reload page
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/client/src/index.css b/client/src/index.css
index b369c81..32a1c02 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -99,6 +99,13 @@ h3 {
text-transform: uppercase;
}
+.topnav-left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-shrink: 0;
+}
+
.nav-links {
display: flex;
gap: 1rem;
@@ -116,6 +123,41 @@ h3 {
color: var(--text);
}
+.nav-battle {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding-right: 0.15rem;
+}
+
+.nav-battle.nav-battle-waiting,
+.nav-battle.nav-battle-waiting:hover,
+.nav-battle.nav-battle-waiting.active {
+ color: var(--accent);
+}
+
+.nav-battle-badge {
+ position: absolute;
+ top: -0.55rem;
+ right: -0.75rem;
+ min-width: 1.1rem;
+ height: 1.1rem;
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ display: inline-grid;
+ place-items: center;
+ font-family: var(--font-display);
+ font-size: 0.7rem;
+ font-weight: 600;
+ line-height: 1;
+ color: inherit;
+ background: transparent;
+ box-shadow: none;
+ pointer-events: none;
+}
+
.nav-toggle {
background: transparent;
border: 1px solid var(--stroke);
@@ -214,6 +256,17 @@ h3 {
white-space: nowrap;
}
+.drop-feed-count {
+ color: var(--gold);
+ font-weight: 700;
+ margin-left: 0.15rem;
+}
+
+.drop-feed-wear {
+ font-size: 0.72rem;
+ margin-top: 0.1rem;
+}
+
.drop-feed-player {
display: flex;
align-items: center;
@@ -222,6 +275,13 @@ h3 {
margin-top: 0.15rem;
}
+.drop-feed-winner-label {
+ color: var(--gold);
+ font-family: var(--font-display);
+ font-weight: 600;
+ font-size: 0.78rem;
+}
+
.drop-feed-avatar {
width: 18px;
height: 18px;
@@ -276,6 +336,19 @@ h3 {
padding: 0.35rem 0.7rem;
}
+a.balance-pill-link {
+ color: var(--gold);
+ text-decoration: none;
+ cursor: pointer;
+}
+
+a.balance-pill-link:hover,
+a.balance-pill-link.active {
+ color: var(--gold);
+ background: rgba(228, 174, 57, 0.15);
+ border-color: rgba(228, 174, 57, 0.5);
+}
+
.main {
width: min(1100px, 100%);
margin: 0 auto;
@@ -382,6 +455,148 @@ h3 {
font-size: 0.9rem;
}
+.session-limit-banner {
+ padding: 0.65rem 1rem;
+ text-align: center;
+ font-size: 0.88rem;
+ font-family: var(--font-display);
+ color: var(--text);
+ background: color-mix(in srgb, var(--danger) 18%, var(--bg-panel));
+ border-bottom: 1px solid color-mix(in srgb, var(--danger) 45%, var(--stroke));
+}
+
+.app-shell.session-locked .topnav,
+.app-shell.session-locked .app-body {
+ filter: grayscale(0.85) brightness(0.45);
+ pointer-events: none;
+ user-select: none;
+}
+
+.session-limit-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: grid;
+ place-items: center;
+ padding: 1.25rem;
+ background: rgba(6, 10, 14, 0.72);
+}
+
+.session-limit-box {
+ width: min(28rem, 100%);
+ padding: 1.5rem 1.35rem 1.35rem;
+ border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--stroke));
+ background:
+ linear-gradient(165deg, rgba(235, 75, 75, 0.08) 0%, transparent 40%),
+ var(--bg-panel);
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
+ text-align: center;
+}
+
+.session-limit-kicker {
+ margin: 0 0 0.35rem;
+ font-size: 0.7rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--danger);
+ font-family: var(--font-display);
+}
+
+.session-limit-box h2 {
+ margin: 0 0 0.65rem;
+ font-size: 1.45rem;
+}
+
+.session-limit-lead {
+ margin: 0 0 0.75rem;
+ font-size: 0.95rem;
+ line-height: 1.45;
+}
+
+.session-limit-respect {
+ margin: 0 0 1.15rem;
+ padding: 0.75rem 0.85rem;
+ font-size: 0.88rem;
+ line-height: 1.45;
+ color: var(--muted);
+ border: 1px solid var(--stroke);
+ background: var(--bg-deep);
+ text-align: left;
+}
+
+.session-limit-ad {
+ text-align: left;
+ border: 1px solid var(--stroke);
+ background: var(--bg-elevated);
+}
+
+.session-limit-ad-label {
+ padding: 0.35rem 0.75rem;
+ font-size: 0.65rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ border-bottom: 1px solid var(--stroke);
+ font-family: var(--font-display);
+}
+
+.session-limit-ad-screen {
+ padding: 1rem 0.9rem 1.05rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ align-items: flex-start;
+ min-height: 8.5rem;
+}
+
+.session-limit-ad-screen.playing {
+ background:
+ radial-gradient(ellipse at 30% 20%, rgba(61, 214, 198, 0.12), transparent 55%),
+ var(--bg-elevated);
+}
+
+.session-limit-ad-brand {
+ font-family: var(--font-display);
+ font-size: 1.15rem;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.session-limit-ad-tag {
+ font-family: var(--font-display);
+ font-size: 0.85rem;
+ color: var(--text);
+}
+
+.session-limit-ad-screen .muted {
+ margin: 0;
+ font-size: 0.82rem;
+}
+
+.session-limit-ad-screen .btn {
+ margin-top: 0.35rem;
+}
+
+.session-limit-ad-progress {
+ width: 100%;
+ height: 0.35rem;
+ background: var(--bg-deep);
+ border: 1px solid var(--stroke);
+ overflow: hidden;
+}
+
+.session-limit-ad-progress-fill {
+ height: 100%;
+ background: var(--accent);
+ transition: width 0.9s linear;
+}
+
+.session-limit-ad-countdown {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+}
+
.section {
margin-bottom: 2.5rem;
}
@@ -430,6 +645,7 @@ h3 {
.item-visual,
.case-visual {
+ position: relative;
flex: 1;
display: grid;
place-items: center;
@@ -451,6 +667,14 @@ h3 {
font-size: 0.85rem;
}
+.item-wear {
+ color: var(--gold);
+ font-size: 0.72rem;
+ font-family: var(--font-display);
+ letter-spacing: 0.02em;
+ line-height: 1.2;
+}
+
.empty {
padding: 1.5rem;
border: 1px dashed var(--stroke);
@@ -460,18 +684,120 @@ h3 {
.case-page {
display: flex;
flex-direction: column;
- gap: 1.5rem;
+ gap: 0.5rem;
+ margin-top: -0.5rem;
+}
+
+.case-page > a.muted {
+ margin: 0;
+ line-height: 1.2;
}
.case-hero {
display: flex;
flex-direction: column;
align-items: center;
- gap: 0.75rem;
- padding: 1.5rem 1rem 0.5rem;
+ gap: 0.35rem;
+ padding: 0.35rem 1rem 0.25rem;
text-align: center;
}
+.case-hero > p.muted {
+ margin: 0;
+}
+
+.case-key-progress {
+ width: min(420px, 100%);
+ margin: 0.35rem 0 0.15rem;
+}
+
+.case-key-head {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 0.35rem 0.75rem;
+ font-size: 0.9rem;
+ margin-bottom: 0.4rem;
+}
+
+.case-key-bar {
+ height: 6px;
+ border-radius: 999px;
+ background: var(--stroke);
+ overflow: hidden;
+}
+
+.case-key-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent-dim), var(--accent));
+ border-radius: inherit;
+ transition: width 300ms ease;
+}
+
+.case-key-badge {
+ color: var(--gold);
+ font-family: var(--font-display);
+}
+
+.open-qty {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ justify-content: center;
+}
+
+.open-qty label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.open-qty select {
+ background: var(--bg-deep);
+ border: 1px solid var(--stroke);
+ color: var(--text);
+ padding: 0.4rem 0.55rem;
+ font-family: var(--font-display);
+}
+
+.multi-reels {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.multi-reels.many {
+ gap: 1.25rem;
+}
+
+.multi-reel-slot {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.multi-reel-label {
+ font-family: var(--font-display);
+ font-size: 0.85rem;
+}
+
+.won-banner.compact {
+ padding: 0.65rem;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem 0.85rem;
+}
+
+.won-banner.compact .won-img {
+ width: 48px;
+ height: 48px;
+ margin: 0;
+}
+
.case-hero .case-icon {
width: 140px;
height: 140px;
@@ -664,6 +990,439 @@ h3 {
color: var(--accent);
}
+.table tbody tr.table-row-clickable {
+ cursor: pointer;
+}
+
+.table tbody tr.table-row-clickable:hover,
+.table tbody tr.table-row-clickable:focus-visible {
+ background: rgba(61, 214, 198, 0.06);
+ outline: none;
+}
+
+/* —— Leaderboard —— */
+.lb-page {
+ max-width: 960px;
+}
+
+.lb-avatar {
+ flex-shrink: 0;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ background: var(--bg-deep);
+ border: 1px solid var(--stroke);
+ font-family: var(--font-display);
+ font-weight: 600;
+ color: var(--muted);
+ letter-spacing: 0.02em;
+}
+
+.lb-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.lb-avatar-sm {
+ width: 2.35rem;
+ height: 2.35rem;
+ font-size: 0.7rem;
+}
+
+.lb-avatar-lg {
+ width: 3.4rem;
+ height: 3.4rem;
+ font-size: 0.9rem;
+}
+
+.lb-avatar-xl {
+ width: 4.5rem;
+ height: 4.5rem;
+ font-size: 1.15rem;
+}
+
+.lb-podium {
+ display: grid;
+ grid-template-columns: 1fr 1.15fr 1fr;
+ gap: 0.65rem;
+ align-items: end;
+ margin: 0.25rem 0 1.25rem;
+}
+
+.lb-podium-card {
+ appearance: none;
+ width: 100%;
+ text-align: center;
+ cursor: pointer;
+ color: inherit;
+ font: inherit;
+ padding: 0.7rem 0.7rem 0.75rem;
+ border: 1px solid var(--stroke);
+ background: var(--bg-panel);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.2rem;
+ transition:
+ border-color 0.2s ease,
+ transform 0.2s ease,
+ background 0.2s ease;
+}
+
+.lb-podium-card:hover,
+.lb-podium-card:focus-visible {
+ outline: none;
+ transform: translateY(-2px);
+}
+
+.lb-podium-rank {
+ font-family: var(--font-display);
+ font-weight: 700;
+ font-size: 0.75rem;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+
+.lb-podium-frame {
+ margin: 0.15rem 0 0.1rem;
+ padding: 0.2rem;
+ border: 1px solid var(--stroke);
+}
+
+.lb-podium-title {
+ font-family: var(--font-display);
+ font-size: 0.65rem;
+ font-weight: 600;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.lb-podium-name {
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.lb-podium-bonus {
+ font-size: 0.68rem;
+ color: var(--accent);
+ margin-bottom: 0.1rem;
+}
+
+.lb-wealth {
+ width: 100%;
+ margin-top: 0.2rem;
+ padding-top: 0.45rem;
+ border-top: 1px solid var(--stroke);
+}
+
+.lb-wealth-total {
+ display: flex;
+ flex-direction: column;
+ gap: 0.05rem;
+}
+
+.lb-wealth-label {
+ font-size: 0.6rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.lb-wealth-total strong {
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.lb-wealth-parts {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ margin-top: 0.3rem;
+ font-size: 0.68rem;
+ color: var(--muted);
+}
+
+.lb-wealth-parts em {
+ font-style: normal;
+ color: var(--text);
+ font-family: var(--font-display);
+}
+
+.lb-wealth-compact strong {
+ font-family: var(--font-display);
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+/* 1st — most impressive */
+.lb-place-1 {
+ position: relative;
+ z-index: 1;
+ overflow: visible;
+ isolation: isolate;
+ padding: 0.95rem 0.85rem 0.9rem;
+ border-color: color-mix(in srgb, var(--gold) 55%, var(--stroke));
+ background:
+ linear-gradient(165deg, rgba(228, 174, 57, 0.16) 0%, transparent 42%),
+ linear-gradient(180deg, #243040 0%, var(--bg-panel) 100%);
+ box-shadow: inset 0 1px 0 rgba(228, 174, 57, 0.25);
+}
+
+.lb-fire {
+ position: absolute;
+ inset: -4px;
+ pointer-events: none;
+ z-index: -1;
+}
+
+.lb-fire-ring {
+ position: absolute;
+ inset: 0;
+ padding: 2px;
+ border: none;
+ background: linear-gradient(
+ 120deg,
+ #7a1a00,
+ #e85d04,
+ #ffba08,
+ #faa307,
+ #dc2f02,
+ #7a1a00
+ );
+ background-size: 280% 280%;
+ animation: lb-fire-ring 2.4s linear infinite;
+ opacity: 0.95;
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+}
+
+@keyframes lb-fire-ring {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 100% {
+ background-position: 100% 50%;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .lb-fire-ring {
+ animation: none;
+ background-position: 40% 50%;
+ }
+}
+
+.lb-place-1 .lb-podium-rank {
+ font-size: 0.95rem;
+ color: var(--gold);
+}
+
+.lb-place-1 .lb-podium-frame {
+ border-color: color-mix(in srgb, var(--gold) 70%, var(--stroke));
+ padding: 0.28rem;
+ background: color-mix(in srgb, var(--gold) 10%, transparent);
+}
+
+.lb-place-1 .lb-podium-title {
+ color: var(--gold);
+ font-size: 0.72rem;
+}
+
+.lb-place-1 .lb-podium-name {
+ font-size: 1.15rem;
+}
+
+.lb-place-1 .lb-podium-bonus {
+ color: var(--gold);
+ font-family: var(--font-display);
+ font-size: 0.75rem;
+}
+
+.lb-place-1 .lb-wealth-total strong {
+ font-size: 1.1rem;
+ color: var(--gold);
+}
+
+.lb-place-1:hover,
+.lb-place-1:focus-visible {
+ border-color: var(--gold);
+ transform: translateY(-3px);
+}
+
+/* 2nd */
+.lb-place-2 {
+ border-color: color-mix(in srgb, #c5d0db 40%, var(--stroke));
+ background:
+ linear-gradient(165deg, rgba(197, 208, 219, 0.1) 0%, transparent 45%),
+ var(--bg-panel);
+}
+
+.lb-place-2 .lb-podium-rank {
+ color: #c5d0db;
+}
+
+.lb-place-2 .lb-podium-frame {
+ border-color: color-mix(in srgb, #c5d0db 55%, var(--stroke));
+}
+
+.lb-place-2 .lb-podium-title,
+.lb-place-2 .lb-podium-bonus {
+ color: #b8c4d0;
+}
+
+.lb-place-2:hover,
+.lb-place-2:focus-visible {
+ border-color: #c5d0db;
+}
+
+/* 3rd */
+.lb-place-3 {
+ border-color: color-mix(in srgb, #c47a45 45%, var(--stroke));
+ background:
+ linear-gradient(165deg, rgba(196, 122, 69, 0.12) 0%, transparent 45%),
+ var(--bg-panel);
+}
+
+.lb-place-3 .lb-podium-rank {
+ color: #d4925c;
+}
+
+.lb-place-3 .lb-podium-frame {
+ border-color: color-mix(in srgb, #c47a45 55%, var(--stroke));
+}
+
+.lb-place-3 .lb-podium-title,
+.lb-place-3 .lb-podium-bonus {
+ color: #d4925c;
+}
+
+.lb-place-3:hover,
+.lb-place-3:focus-visible {
+ border-color: #d4925c;
+}
+
+.lb-list {
+ border-top: 1px solid var(--stroke);
+ padding-top: 1rem;
+}
+
+.lb-list-head {
+ display: grid;
+ grid-template-columns: 3rem 2.35rem 1fr auto auto;
+ gap: 0.75rem;
+ align-items: center;
+ padding: 0 0.85rem 0.55rem;
+ font-size: 0.72rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.lb-list-head span:nth-child(2) {
+ grid-column: 2 / 4;
+}
+
+.lb-list-head-wealth {
+ text-align: right;
+}
+
+.lb-list-rows {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.lb-row {
+ appearance: none;
+ width: 100%;
+ display: grid;
+ grid-template-columns: 3rem 2.35rem minmax(0, 1fr) auto auto;
+ gap: 0.75rem;
+ align-items: center;
+ padding: 0.65rem 0.85rem;
+ border: 1px solid transparent;
+ border-bottom-color: var(--stroke);
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease;
+}
+
+.lb-row:hover,
+.lb-row:focus-visible {
+ outline: none;
+ background: rgba(61, 214, 198, 0.05);
+ border-color: color-mix(in srgb, var(--accent) 25%, transparent);
+}
+
+.lb-row-rank {
+ font-family: var(--font-display);
+ font-weight: 600;
+ color: var(--accent-dim);
+}
+
+.lb-row-name {
+ font-family: var(--font-display);
+ font-weight: 560;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lb-row-meta {
+ font-size: 0.78rem;
+ display: none;
+}
+
+.lb-row .lb-wealth-compact {
+ text-align: right;
+}
+
+@media (min-width: 640px) {
+ .lb-row-meta {
+ display: block;
+ }
+}
+
+@media (max-width: 720px) {
+ .lb-podium {
+ grid-template-columns: 1fr;
+ gap: 0.65rem;
+ }
+
+ .lb-place-1 {
+ order: -1;
+ }
+
+ .lb-list-head {
+ grid-template-columns: 2.5rem 2.35rem 1fr auto;
+ }
+
+ .lb-list-head span:nth-child(2) {
+ grid-column: 2 / 3;
+ }
+
+ .lb-row {
+ grid-template-columns: 2.5rem 2.35rem minmax(0, 1fr) auto;
+ }
+
+ .lb-row-meta {
+ display: none;
+ }
+}
+
.admin-tabs {
display: flex;
gap: 0.5rem;
@@ -767,6 +1526,22 @@ h3 {
outline-offset: 2px;
}
+.item-tile.favorite {
+ box-shadow: inset 0 0 0 1px rgba(228, 174, 57, 0.45);
+}
+
+.item-favorite-badge {
+ position: absolute;
+ top: 0.35rem;
+ right: 0.35rem;
+ z-index: 1;
+ color: var(--gold);
+ font-size: 0.95rem;
+ line-height: 1;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65);
+ pointer-events: none;
+}
+
.item-tile.disabled {
opacity: 0.4;
pointer-events: none;
@@ -788,6 +1563,159 @@ h3 {
position: relative;
}
+.mode-card-live {
+ grid-column: 1 / -1;
+ min-height: 220px;
+}
+
+.mode-card-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.mode-card-top h2 {
+ margin: 0;
+}
+
+.mode-status {
+ flex-shrink: 0;
+ font-family: var(--font-display);
+ font-size: 0.75rem;
+ color: var(--accent);
+ border: 1px solid rgba(61, 214, 198, 0.35);
+ background: rgba(61, 214, 198, 0.08);
+ padding: 0.2rem 0.45rem;
+ white-space: nowrap;
+}
+
+.mode-status.spinning {
+ color: var(--gold);
+ border-color: rgba(228, 174, 57, 0.4);
+ background: rgba(228, 174, 57, 0.1);
+}
+
+.mode-blurb {
+ margin: 0.5rem 0 0.85rem;
+ font-size: 0.9rem;
+}
+
+.mode-pot-stats {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 12rem));
+ gap: 0.75rem 1.5rem;
+ margin-bottom: 0.85rem;
+}
+
+.mode-pot-stats > div {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ font-size: 0.8rem;
+}
+
+.mode-pot-stats strong {
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ color: var(--text);
+}
+
+.mode-pot-items {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+}
+
+.mode-pot-thumb {
+ width: 4.5rem;
+ height: 4.5rem;
+ border: 1px solid var(--stroke);
+ border-top-width: 2px;
+ background: rgba(0, 0, 0, 0.25);
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ font-size: 0.85rem;
+ font-family: var(--font-display);
+ color: var(--muted);
+}
+
+.mode-pot-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.mode-pot-more {
+ font-size: 0.8rem;
+}
+
+.mode-pot-empty {
+ margin: 0;
+ font-size: 0.85rem;
+}
+
+.mode-card-reel {
+ margin-top: 0.15rem;
+}
+
+.mode-card-winner {
+ margin: 0.5rem 0 0;
+ font-size: 0.9rem;
+ color: var(--gold);
+}
+
+.reel-wrap-compact {
+ height: 118px;
+}
+
+.reel-wrap-compact.reel-wrap-owners {
+ height: 148px;
+}
+
+.reel-wrap-compact::before,
+.reel-wrap-compact::after {
+ width: 48px;
+}
+
+.reel-wrap-compact .reel-item {
+ flex: 0 0 72px;
+ width: 72px;
+ padding: 0.25rem;
+ gap: 0.2rem;
+ border-top-width: 3px;
+}
+
+.reel-wrap-compact .reel-item .glyph {
+ font-size: 1rem;
+}
+
+.reel-wrap-compact .reel-img {
+ max-height: 2.4rem;
+}
+
+.reel-wrap-compact .reel-item .label {
+ font-size: 0.62rem;
+}
+
+.reel-wrap-compact .reel-owner {
+ gap: 0.2rem;
+}
+
+.reel-wrap-compact .reel-owner-avatar,
+.reel-wrap-compact .reel-owner-fallback {
+ width: 0.9rem;
+ height: 0.9rem;
+ font-size: 0.55rem;
+}
+
+.reel-wrap-compact .reel-owner-name {
+ font-size: 0.58rem;
+ max-width: 3.5rem;
+}
+
a.mode-card:hover {
transform: translateY(-3px);
border-color: var(--accent-dim);
@@ -960,6 +1888,305 @@ a.mode-card:hover {
gap: 0.55rem;
}
+.case-stats-block {
+ margin-top: 1rem;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--stroke);
+}
+
+.catalog-badges-block {
+ margin-top: 1rem;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--stroke);
+}
+
+.catalog-badges-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.65rem;
+}
+
+.catalog-badges-head h3 {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.catalog-badges-luck {
+ font-family: var(--font-display);
+ font-size: 0.75rem;
+ color: var(--accent);
+ border: 1px solid rgba(61, 214, 198, 0.35);
+ background: rgba(61, 214, 198, 0.08);
+ padding: 0.15rem 0.45rem;
+ white-space: nowrap;
+}
+
+.catalog-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.catalog-badge {
+ position: relative;
+ width: 3.4rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.2rem;
+ text-decoration: none;
+ color: inherit;
+}
+
+a.catalog-badge:hover .catalog-badge-name {
+ color: var(--accent);
+}
+
+.catalog-badge img,
+.catalog-badge-fallback {
+ width: 2.75rem;
+ height: 2.75rem;
+ object-fit: contain;
+ border: 1px solid rgba(61, 214, 198, 0.4);
+ background: rgba(61, 214, 198, 0.08);
+ display: grid;
+ place-items: center;
+ color: var(--accent);
+ font-size: 1rem;
+}
+
+.catalog-badge-check {
+ position: absolute;
+ top: -0.2rem;
+ right: 0.1rem;
+ font-size: 0.65rem;
+ font-weight: 700;
+ color: var(--bg-deep);
+ background: var(--accent);
+ width: 0.95rem;
+ height: 0.95rem;
+ display: grid;
+ place-items: center;
+ line-height: 1;
+}
+
+.catalog-badge-name {
+ font-size: 0.62rem;
+ color: var(--muted);
+ text-align: center;
+ line-height: 1.15;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.presence-online {
+ color: #3ecf8e;
+}
+
+.presence-offline {
+ color: var(--muted);
+}
+
+.achievement-badges-block {
+ margin-top: 1rem;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--stroke);
+}
+
+.achievement-badges-link {
+ font-size: 0.75rem;
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.achievement-badges-link:hover {
+ text-decoration: underline;
+}
+
+.achievement-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.achievement-badge {
+ position: relative;
+ width: 3.4rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.2rem;
+ text-decoration: none;
+ color: inherit;
+}
+
+a.achievement-badge:hover .achievement-badge-name {
+ color: var(--accent);
+}
+
+.achievement-badge-icon {
+ width: 2.75rem;
+ height: 2.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--stroke);
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ color: var(--accent);
+}
+
+.achievement-badge-name {
+ font-size: 0.62rem;
+ color: var(--muted);
+ text-align: center;
+ line-height: 1.15;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.achievement-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
+ gap: 0.85rem;
+ margin-top: 1rem;
+}
+
+.achievement-card {
+ display: flex;
+ gap: 0.85rem;
+ padding: 0.9rem 1rem;
+ border: 1px solid var(--stroke);
+ background: color-mix(in srgb, var(--panel) 88%, transparent);
+}
+
+.achievement-card.locked {
+ opacity: 0.55;
+}
+
+.achievement-card.unlocked .achievement-card-icon {
+ color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--stroke));
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+}
+
+.achievement-card-icon {
+ flex-shrink: 0;
+ width: 2.75rem;
+ height: 2.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--stroke);
+ font-family: var(--font-display);
+ font-size: 1.1rem;
+}
+
+.achievement-card-body h2 {
+ margin: 0 0 0.25rem;
+ font-family: var(--font-display);
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.achievement-card-body p {
+ margin: 0 0 0.45rem;
+ font-size: 0.85rem;
+}
+
+.achievement-card-meta {
+ font-size: 0.72rem;
+ color: var(--muted);
+}
+
+.achievement-card-meta.locked-label {
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.case-stats-block h3 {
+ margin: 0 0 0.65rem;
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.case-stats-empty {
+ margin: 0;
+ font-size: 0.85rem;
+}
+
+.case-stat-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.case-stat-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto auto;
+ align-items: center;
+ gap: 0.55rem 0.75rem;
+ padding: 0.45rem 0.55rem;
+ border: 1px solid var(--stroke);
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.12);
+ color: inherit;
+ text-decoration: none;
+ transition: border-color 150ms ease, background 150ms ease;
+}
+
+.case-stat-row:hover {
+ border-color: var(--accent-dim);
+ background: rgba(61, 214, 198, 0.06);
+}
+
+.case-stat-visual {
+ width: 2rem;
+ height: 2rem;
+ display: grid;
+ place-items: center;
+ font-size: 1rem;
+ color: var(--accent);
+}
+
+.case-stat-visual img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.case-stat-name {
+ font-family: var(--font-display);
+ font-size: 0.9rem;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.case-stat-count {
+ font-size: 0.85rem;
+ white-space: nowrap;
+}
+
+.case-stat-keys {
+ font-size: 0.8rem;
+ white-space: nowrap;
+}
+
.ok-msg {
color: var(--accent);
font-size: 0.9rem;
@@ -1099,6 +2326,98 @@ a.mode-card:hover {
margin-bottom: 1rem;
}
+.inv-toolbar-left {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.inv-count {
+ font-size: 0.85rem;
+}
+
+.inv-pager {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ margin-top: 1.25rem;
+ flex-wrap: wrap;
+}
+
+.inv-bulk-sell {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ padding: 0.75rem 0;
+ border-top: 1px solid var(--stroke);
+}
+
+.inv-bulk-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-family: var(--font-display);
+ font-size: 0.9rem;
+ color: var(--text);
+}
+
+.inv-bulk-label input[type='number'] {
+ width: 5.5rem;
+ background: var(--panel);
+ border: 1px solid var(--stroke);
+ color: var(--text);
+ padding: 0.4rem 0.55rem;
+ font-family: inherit;
+}
+
+.inv-bulk-preview {
+ font-size: 0.85rem;
+}
+
+.inv-auto-sell-box {
+ margin-left: auto;
+ border: 1px solid rgba(61, 214, 198, 0.35);
+ background: rgba(61, 214, 198, 0.08);
+ border-radius: 8px;
+ padding: 0.45rem 0.85rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.inv-auto-sell-at {
+ font-family: var(--font-display);
+ font-size: 0.85rem;
+ white-space: nowrap;
+}
+
+.inv-auto-sell-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ cursor: pointer;
+ font-family: var(--font-display);
+ font-size: 0.9rem;
+ color: var(--text);
+ user-select: none;
+}
+
+.inv-auto-sell-toggle input[type='checkbox'] {
+ width: 1.15rem;
+ height: 1.15rem;
+ margin: 0;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.inv-auto-sell-toggle span {
+ white-space: nowrap;
+}
+
.sort-toggle {
display: inline-flex;
border: 1px solid var(--stroke);
@@ -1150,3 +2469,283 @@ a.mode-card:hover {
background-position: -100% 0;
}
}
+
+/* Catalog */
+.catalog-luck-pill {
+ font-family: var(--font-display);
+ color: var(--accent);
+ border: 1px solid rgba(61, 214, 198, 0.35);
+ background: rgba(61, 214, 198, 0.08);
+ padding: 0.35rem 0.7rem;
+ white-space: nowrap;
+}
+
+.catalog-chapters {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.catalog-chapter {
+ border: 1px solid var(--stroke);
+ background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel));
+ padding: 1rem 1.15rem 1.15rem;
+}
+
+.catalog-chapter.completed {
+ border-color: rgba(61, 214, 198, 0.4);
+}
+
+.catalog-chapter-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.85rem;
+ flex-wrap: wrap;
+}
+
+.catalog-chapter-title {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.catalog-chapter-title h2 {
+ margin: 0;
+ font-size: 1.15rem;
+}
+
+.catalog-chapter-title h2 a {
+ color: var(--text);
+}
+
+.catalog-chapter-title h2 a:hover {
+ color: var(--accent);
+}
+
+.catalog-chapter-title p {
+ margin: 0.2rem 0 0;
+ font-size: 0.85rem;
+}
+
+.catalog-case-thumb {
+ width: 3rem;
+ height: 3rem;
+ object-fit: contain;
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid var(--stroke);
+ flex-shrink: 0;
+}
+
+.catalog-case-thumb.placeholder {
+ display: grid;
+ place-items: center;
+ color: var(--accent);
+ font-size: 1.2rem;
+}
+
+.catalog-complete-badge {
+ font-family: var(--font-display);
+ font-size: 0.8rem;
+ color: var(--accent);
+ border: 1px solid rgba(61, 214, 198, 0.35);
+ background: rgba(61, 214, 198, 0.08);
+ padding: 0.25rem 0.55rem;
+ white-space: nowrap;
+}
+
+.catalog-progress-bar {
+ width: 7rem;
+ height: 0.35rem;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid var(--stroke);
+ flex-shrink: 0;
+}
+
+.catalog-progress-fill {
+ height: 100%;
+ background: var(--accent);
+ transition: width 200ms ease;
+}
+
+.catalog-slots {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
+ gap: 0.5rem;
+}
+
+.catalog-slot {
+ aspect-ratio: 1;
+ border: 1px solid var(--stroke);
+ background: rgba(0, 0, 0, 0.28);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ padding: 0.35rem;
+ overflow: hidden;
+ position: relative;
+}
+
+.catalog-slot.filled {
+ border-top: 3px solid var(--rarity, var(--stroke));
+}
+
+.catalog-slot.empty {
+ opacity: 0.55;
+}
+
+.catalog-slot img {
+ width: 70%;
+ height: 55%;
+ object-fit: contain;
+}
+
+.catalog-slot-fallback {
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ color: var(--muted);
+}
+
+.catalog-slot-name {
+ font-size: 0.62rem;
+ color: var(--muted);
+ text-align: center;
+ line-height: 1.15;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.catalog-slot-empty {
+ font-family: var(--font-display);
+ font-size: 1.4rem;
+ color: var(--muted);
+}
+
+
+/* Prestige */
+.prestige-count-pill {
+ font-family: var(--font-display);
+ color: var(--gold);
+ border: 1px solid rgba(228, 174, 57, 0.4);
+ background: rgba(228, 174, 57, 0.08);
+ padding: 0.35rem 0.7rem;
+ white-space: nowrap;
+}
+
+.prestige-totals {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 10rem));
+ gap: 0.75rem 1.5rem;
+ margin-bottom: 1.25rem;
+}
+
+.prestige-totals > div {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ font-size: 0.85rem;
+}
+
+.prestige-totals strong {
+ font-family: var(--font-display);
+ font-size: 1.05rem;
+ color: var(--text);
+}
+
+.prestige-cost-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5rem 1rem;
+ margin-bottom: 1rem;
+ font-size: 0.9rem;
+}
+
+.prestige-choices {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0.85rem;
+ margin-bottom: 1.25rem;
+}
+
+.prestige-choice {
+ text-align: left;
+ background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel));
+ border: 1px solid var(--stroke);
+ color: inherit;
+ padding: 1rem;
+ cursor: pointer;
+ font: inherit;
+}
+
+.prestige-choice:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.prestige-choice.selected {
+ border-color: rgba(228, 174, 57, 0.55);
+ box-shadow: inset 0 0 0 1px rgba(228, 174, 57, 0.25);
+}
+
+.prestige-choice h2 {
+ margin: 0 0 0.4rem;
+ font-size: 1.05rem;
+}
+
+.prestige-choice p {
+ margin: 0 0 0.75rem;
+ font-size: 0.85rem;
+}
+
+.prestige-choice-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+}
+
+.prestige-delta {
+ color: var(--gold);
+ font-family: var(--font-display);
+}
+
+.prestige-confirm {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ max-width: 32rem;
+}
+
+.prestige-confirm label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.prestige-history {
+ margin-top: 1.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--stroke);
+}
+
+.prestige-history h2 {
+ margin: 0 0 0.65rem;
+ font-size: 1.05rem;
+}
+
+.prestige-profile-block {
+ margin-top: 1rem;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--stroke);
+}
+
+.prestige-profile-stats {
+ margin-top: 0.35rem;
+}
diff --git a/client/src/inventoryEvents.js b/client/src/inventoryEvents.js
new file mode 100644
index 0000000..79395be
--- /dev/null
+++ b/client/src/inventoryEvents.js
@@ -0,0 +1,10 @@
+export function notifyInventoryChanged() {
+ if (typeof window === 'undefined') return;
+ window.dispatchEvent(new Event('inventory:changed'));
+}
+
+export function onInventoryChanged(handler) {
+ if (typeof window === 'undefined') return () => {};
+ window.addEventListener('inventory:changed', handler);
+ return () => window.removeEventListener('inventory:changed', handler);
+}
diff --git a/client/src/pages/AchievementsPage.jsx b/client/src/pages/AchievementsPage.jsx
new file mode 100644
index 0000000..48c7815
--- /dev/null
+++ b/client/src/pages/AchievementsPage.jsx
@@ -0,0 +1,97 @@
+import { useEffect, useState } from 'react';
+import { Link, Navigate } from 'react-router-dom';
+import { api } from '../api';
+import { useAuth } from '../AuthContext';
+
+function formatUnlockedAt(iso) {
+ if (!iso) return null;
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return null;
+ }
+}
+
+export default function AchievementsPage() {
+ const { user, loading } = useAuth();
+ const [achievements, setAchievements] = useState([]);
+ const [unlocked, setUnlocked] = useState(0);
+ const [total, setTotal] = useState(0);
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(true);
+
+ useEffect(() => {
+ if (!user || user.role === 'admin') return undefined;
+ let cancelled = false;
+ (async () => {
+ try {
+ const data = await api.achievements();
+ if (cancelled) return;
+ setAchievements(data.achievements || []);
+ setUnlocked(data.unlocked || 0);
+ setTotal(data.total || 0);
+ } catch (err) {
+ if (!cancelled) setError(err.message);
+ } finally {
+ if (!cancelled) setBusy(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [user?.id, user?.role]);
+
+ if (loading || busy) return
Loading…
;
+ if (!user) return
;
+ if (user.role === 'admin') return
;
+
+ return (
+
+
+
+
Achievements
+
+ Unlock badges by playing. Earned badges show on your{' '}
+ profile.
+
+
+
+ {unlocked}/{total} unlocked
+
+
+
+ {error && {error}
}
+
+ {achievements.length === 0 && !error && (
+ No achievements defined yet.
+ )}
+
+
+ {achievements.map((a) => {
+ const done = Boolean(a.unlockedAt);
+ return (
+
+
+ {a.icon || '★'}
+
+
+
{a.name}
+
{a.description}
+ {done ? (
+
+ Unlocked {formatUnlockedAt(a.unlockedAt)}
+
+ ) : (
+
Locked
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/client/src/pages/Admin.jsx b/client/src/pages/Admin.jsx
index 7aff016..1c0ad2e 100644
--- a/client/src/pages/Admin.jsx
+++ b/client/src/pages/Admin.jsx
@@ -432,7 +432,7 @@ export default function Admin() {
- Value (cents)
+ Value (cents, Field-Tested ref)
- Value (cents)
+ Value (cents, Field-Tested ref)
+ r.status === 'open' &&
+ ((r.playerCount ?? r.players?.length ?? 0) > 0 || (r.deposits?.length ?? 0) > 0)
+ );
+}
+
+function depositsToReelItems(deposits) {
+ return (deposits || [])
+ .filter((d) => d.item)
+ .map((d) => ({
+ ...d.item,
+ id: d.id,
+ ownerUsername: d.username,
+ ownerAvatarUrl: d.avatarUrl || '',
+ }));
+}
+
+export default function BattleHub() {
+ const { user, loading } = useAuth();
+ const [round, setRound] = useState(null);
+ const [duelOpen, setDuelOpen] = useState([]);
+ const [spinning, setSpinning] = useState(false);
+ const [spinActive, setSpinActive] = useState(false);
+ const [spinDeposits, setSpinDeposits] = useState([]);
+ const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
+ const [lastWinner, setLastWinner] = useState(null);
+ const [revealWinner, setRevealWinner] = useState(false);
+
+ useEffect(() => {
+ if (!user || user.role === 'admin') return undefined;
+ let cancelled = false;
+
+ (async () => {
+ try {
+ const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
+ if (cancelled) return;
+ setRound(jp.round);
+ setDuelOpen(duels.open || []);
+ if (jp.round?.status === 'spinning') {
+ setSpinDeposits(jp.round.deposits || []);
+ setSpinActive(true);
+ setSpinning(true);
+ setReelWinnerItemId(jp.round.reelWinnerItemId || null);
+ setLastWinner(jp.round.winnerUsername || null);
+ }
+ } catch {
+ /* hub still usable without live preview */
+ }
+ })();
+
+ const socket = getSocket();
+ const onState = (state) => setRound(state);
+ const onSpin = (state) => {
+ setRound(state);
+ setSpinDeposits(state.deposits || []);
+ setSpinActive(true);
+ setSpinning(true);
+ setRevealWinner(false);
+ setReelWinnerItemId(state.reelWinnerItemId);
+ setLastWinner(state.winnerUsername);
+ };
+ const onResult = (state) => setRound(state);
+ const onDuels = (list) => setDuelOpen(list.open || []);
+
+ socket.emit('jackpot:subscribe');
+ socket.emit('duels:subscribe');
+ socket.on('jackpot:state', onState);
+ socket.on('jackpot:spin', onSpin);
+ socket.on('jackpot:result', onResult);
+ socket.on('duels:list', onDuels);
+ socket.on('connect', () => {
+ socket.emit('jackpot:subscribe');
+ socket.emit('duels:subscribe');
+ });
+
+ return () => {
+ cancelled = true;
+ socket.emit('jackpot:unsubscribe');
+ socket.emit('duels:unsubscribe');
+ socket.off('jackpot:state', onState);
+ socket.off('jackpot:spin', onSpin);
+ socket.off('jackpot:result', onResult);
+ socket.off('duels:list', onDuels);
+ };
+ }, [user]);
+
+ useEffect(() => {
+ if (!revealWinner) return undefined;
+ const t = setTimeout(() => {
+ setSpinActive(false);
+ setRevealWinner(false);
+ }, 4000);
+ return () => clearTimeout(t);
+ }, [revealWinner]);
+
+ const topItems = useMemo(() => {
+ const deposits = [...(round?.deposits || [])].filter((d) => d.item);
+ deposits.sort((a, b) => (b.valueCents || 0) - (a.valueCents || 0));
+ return deposits.slice(0, TOP_ITEMS);
+ }, [round]);
+
+ const potItems = useMemo(
+ () => depositsToReelItems(spinActive && spinDeposits.length ? spinDeposits : round?.deposits),
+ [round, spinDeposits, spinActive]
+ );
+
+ const showReel = spinActive && potItems.length > 0;
+ const waitingRooms = useMemo(() => waitingDuelRooms(duelOpen), [duelOpen]);
+ const waitingRoomCount = waitingRooms.length;
+
+ const liveTimerEndsAt =
+ round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers && !spinning
+ ? round.endsAt
+ : null;
+ const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
+
+ const playerCount = round?.players?.length ?? 0;
+ const potValue = round?.totalValue ?? 0;
+ const itemCount = round?.deposits?.length ?? 0;
+ const statusLabel = !round
+ ? '…'
+ : round.status === 'spinning' || spinning
+ ? 'Spinning'
+ : round.waitingForPlayers
+ ? 'Waiting for players'
+ : round.status === 'open'
+ ? liveTimerEndsAt
+ ? `Live ${liveTimerLabel}`
+ : 'Live'
+ : round.status;
+
+ if (loading) return Loading…
;
+ if (!user) return ;
+ if (user.role === 'admin') return ;
+
+ return (
+
+
+
+
Battle
+
Multiplayer item gambling modes.
+
+
+
+
+
+
MultiPlayers Case
+
+ {statusLabel}
+
+
+
+ Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items — chance scales with
+ value.
+
+
+
+
+ Pot
+ {formatCredits(potValue)} cr
+
+
+ Players
+ {playerCount}
+
+
+ Items
+ {itemCount}
+
+
+
+ {showReel ? (
+
+
{
+ setSpinning(false);
+ setRevealWinner(true);
+ }}
+ />
+ {revealWinner && lastWinner && (
+
+ Winner: {lastWinner}
+
+ )}
+
+ ) : topItems.length > 0 ? (
+
+ {topItems.map((d) => (
+
+ {d.item.imageUrl ? (
+
+ ) : (
+
{d.item.name.slice(0, 2).toUpperCase()}
+ )}
+
+ ))}
+ {itemCount > topItems.length && (
+
+{itemCount - topItems.length}
+ )}
+
+ ) : (
+
No items in the pot yet — be the first.
+ )}
+
+
+
+
+
1vX Case
+ {waitingRoomCount > 0 && (
+
+ {waitingRoomCount} waiting
+
+ )}
+
+
+ Create or join custom rooms. Set player/item limits and min value. Spectate live duels.
+
+
+
+ Open rooms
+ {waitingRoomCount}
+
+
+
+
+
+
Coinflip
+
Classic 1v1 item flip. Coming soon.
+
Soon
+
+
+
+ );
+}
diff --git a/client/src/pages/BetHub.jsx b/client/src/pages/BetHub.jsx
deleted file mode 100644
index d475fba..0000000
--- a/client/src/pages/BetHub.jsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Link, Navigate } from 'react-router-dom';
-import { useAuth } from '../AuthContext';
-
-const MODES = [
- {
- to: '/bet/multi',
- title: 'MultiPlayers Case',
- blurb: 'Join the live pot. Bet up to 3 items — chance scales with value. Winner takes all.',
- ready: true,
- },
- {
- to: '/bet/1vx',
- title: '1vX Case',
- blurb: 'Create or join custom rooms. Set player/item limits and min value. Spectate live duels.',
- ready: true,
- },
- {
- to: '/bet/coinflip',
- title: 'Coinflip',
- blurb: 'Classic 1v1 item flip. Coming soon.',
- ready: false,
- },
-];
-
-export default function BetHub() {
- const { user, loading } = useAuth();
-
- if (loading) return Loading…
;
- if (!user) return ;
- if (user.role === 'admin') return ;
-
- return (
-
-
-
-
Bet Item
-
Multiplayer item gambling modes.
-
-
-
- {MODES.map((m) =>
- m.ready ? (
-
-
{m.title}
-
{m.blurb}
-
- ) : (
-
-
{m.title}
-
{m.blurb}
-
Soon
-
- )
- )}
-
-
- );
-}
diff --git a/client/src/pages/CasePage.jsx b/client/src/pages/CasePage.jsx
index 82660c0..8c1ca2c 100644
--- a/client/src/pages/CasePage.jsx
+++ b/client/src/pages/CasePage.jsx
@@ -1,27 +1,159 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import ItemTile from '../components/ItemTile';
+import { notifyInventoryChanged } from '../inventoryEvents';
import { rarityColor } from '../rarities';
+import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
+
+const DEFAULT_MAX_OPENS = 10;
+
+/** 1–5, then steps of 5 (10, 15…), plus max if not already listed. */
+function buildOpenQuantityOptions(maxOpens) {
+ const cap = Math.max(1, maxOpens);
+ const options = [];
+ for (let n = 1; n <= Math.min(5, cap); n += 1) options.push(n);
+ for (let n = 10; n <= cap; n += 5) options.push(n);
+ if (cap > 5 && !options.includes(cap)) options.push(cap);
+ return options;
+}
export default function CasePage() {
const { id } = useParams();
const { user, loading, updateBalance } = useAuth();
const [crate, setCrate] = useState(null);
const [error, setError] = useState('');
- const [spinning, setSpinning] = useState(false);
- const [winnerId, setWinnerId] = useState(null);
- const [wonItem, setWonItem] = useState(null);
- const [showResult, setShowResult] = useState(false);
+ const [keyMessage, setKeyMessage] = useState('');
+ const [catalogMessage, setCatalogMessage] = useState('');
+ const [quantity, setQuantity] = useState(1);
+ const [opening, setOpening] = useState(false);
+ const [spins, setSpins] = useState([]);
+ const [caseName, setCaseName] = useState('');
+ const announcedRef = useRef(false);
+ const finishingRef = useRef(false);
+ const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
+ const pendingAutoSellRef = useRef([]);
+ const quantityRef = useRef(1);
+ const caseIdRef = useRef(null);
+ const prefReadyRef = useRef(false);
+
+ const keyProgress = crate?.keyProgress ?? null;
+ const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
+
+ quantityRef.current = quantity;
+
+ const persistQty = (caseId, qty) => {
+ if (user?.id && caseId != null) saveCaseOpenQty(user.id, caseId, qty);
+ };
+
+ useEffect(() => {
+ return () => {
+ if (user?.id && caseIdRef.current != null) {
+ saveCaseOpenQty(user.id, caseIdRef.current, quantityRef.current);
+ }
+ };
+ }, [user?.id]);
+
+ const announcePendingDrop = async (useKeepalive = false) => {
+ const ids = pendingAnnounceRef.current.ids.filter(Boolean);
+ const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
+ if (!ids.length || announcedRef.current) return;
+
+ announcedRef.current = true;
+ const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
+
+ if (useKeepalive) {
+ fetch('/api/feed/announce', {
+ method: 'POST',
+ credentials: 'include',
+ keepalive: true,
+ headers: { 'Content-Type': 'application/json' },
+ body,
+ }).catch(() => {
+ announcedRef.current = false;
+ });
+ return;
+ }
+
+ try {
+ await api.feedAnnounce(ids, announceCaseName);
+ } catch {
+ announcedRef.current = false;
+ }
+ };
+
+ const applyPendingAutoSellKeepalive = () => {
+ const ids = pendingAutoSellRef.current.filter(Boolean);
+ if (!ids.length) return;
+ pendingAutoSellRef.current = [];
+ fetch('/api/inventory/auto-sell/apply', {
+ method: 'POST',
+ credentials: 'include',
+ keepalive: true,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ inventoryItemIds: ids }),
+ }).catch(() => {});
+ };
+
+ const finalizeOpens = async (completedSpins, announceCaseName) => {
+ if (finishingRef.current) return;
+ finishingRef.current = true;
+
+ const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
+ pendingAnnounceRef.current = { ids, caseName: announceCaseName };
+
+ try {
+ await announcePendingDrop(false);
+
+ const toSell = completedSpins
+ .filter((s) => s.item?.willAutoSell)
+ .map((s) => s.item.inventoryId)
+ .filter(Boolean);
+
+ pendingAutoSellRef.current = [];
+
+ if (toSell.length) {
+ const data = await api.applyPendingAutoSell(toSell);
+ updateBalance(data.user.balance);
+ notifyInventoryChanged();
+ const soldSet = new Set(data.soldIds);
+ setSpins((curr) =>
+ curr.map((s) =>
+ soldSet.has(s.item.inventoryId)
+ ? {
+ ...s,
+ item: {
+ ...s.item,
+ autoSold: true,
+ soldForCents: s.item.valueCents,
+ },
+ }
+ : s
+ )
+ );
+ }
+ } catch (err) {
+ setError(err.message);
+ announcedRef.current = false;
+ } finally {
+ finishingRef.current = false;
+ }
+ };
useEffect(() => {
let cancelled = false;
+ prefReadyRef.current = false;
+ caseIdRef.current = null;
+ setCrate(null);
(async () => {
try {
const data = await api.case(id);
- if (!cancelled) setCrate(data.case);
+ if (!cancelled) {
+ setCrate(data.case);
+ caseIdRef.current = data.case.id;
+ }
} catch (err) {
if (!cancelled) setError(err.message);
}
@@ -31,33 +163,185 @@ export default function CasePage() {
};
}, [id]);
+ const maxAffordable = useMemo(() => {
+ if (!crate || !user) return 1;
+ if (crate.price <= 0) return maxOpens;
+ const affordable = Math.floor(user.balance / crate.price);
+ return Math.min(maxOpens, Math.max(affordable, 0));
+ }, [crate, user, maxOpens]);
+
+ const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
+
+ useLayoutEffect(() => {
+ if (!crate || !user?.id) return;
+
+ const saved = loadCaseOpenQty(user.id, crate.id);
+ const resolved = resolveOpenQty(saved, openOptions, maxAffordable);
+
+ if (!prefReadyRef.current) {
+ prefReadyRef.current = true;
+ setQuantity(resolved);
+ return;
+ }
+
+ setQuantity((q) => {
+ const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
+ if (!allowed.length) return 1;
+ if (allowed.includes(q)) return q;
+ const below = allowed.filter((n) => n <= q);
+ return below.length ? below[below.length - 1] : allowed[0];
+ });
+ }, [crate, maxAffordable, openOptions, user?.id]);
+
+ useEffect(() => {
+ if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
+ persistQty(crate.id, quantity);
+ }, [quantity, user?.id, crate?.id]);
+
+ useEffect(() => {
+ return () => {
+ announcePendingDrop(true);
+ applyPendingAutoSellKeepalive();
+ };
+ }, []);
+
+ const possibleDrops = useMemo(() => {
+ if (!crate?.items?.length) return [];
+ return [...crate.items].sort((a, b) => {
+ const ca = Number(a.chance ?? a.dropRate ?? 0);
+ const cb = Number(b.chance ?? b.dropRate ?? 0);
+ if (ca !== cb) return ca - cb;
+ return (b.marketValue || 0) - (a.marketValue || 0);
+ });
+ }, [crate]);
+
+ const progressPct = useMemo(() => {
+ if (!keyProgress?.opensRequired) return 0;
+ return Math.min(
+ 100,
+ Math.round((keyProgress.opensSinceLastKey / keyProgress.opensRequired) * 100)
+ );
+ }, [keyProgress]);
+
if (loading) return Loading…
;
if (!user) return ;
if (user.role === 'admin') return ;
+ const totalCost = crate ? crate.price * quantity : 0;
const canOpen =
- crate && !spinning && user.balance >= crate.price && crate.items.length > 0;
+ crate &&
+ !opening &&
+ crate.items.length > 0 &&
+ quantity >= 1 &&
+ quantity <= maxOpens &&
+ user.balance >= totalCost;
+
+ const allDone = spins.length > 0 && spins.every((s) => s.done);
const openCase = async () => {
if (!canOpen) return;
setError('');
- setWonItem(null);
- setShowResult(false);
- setWinnerId(null);
- setSpinning(false);
+ setKeyMessage('');
+ setCatalogMessage('');
+ setSpins([]);
+ setOpening(true);
+ announcedRef.current = false;
+ finishingRef.current = false;
try {
- const result = await api.openCase(crate.id);
+ const result = await api.openCase(crate.id, quantity);
updateBalance(result.user.balance);
- setWonItem(result.item);
- setWinnerId(result.item.id);
- // start spin on next frame so reel rebuilds with winner
- requestAnimationFrame(() => setSpinning(true));
+ notifyInventoryChanged();
+ persistQty(crate.id, quantity);
+ if (result.keyProgress) {
+ setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
+ }
+ if (result.keysGained > 0) {
+ setKeyMessage(
+ `+${result.keysGained} key${result.keysGained === 1 ? '' : 's'} unlocked · now x${result.keyProgress.maxOpens} max`
+ );
+ }
+ if (result.catalog) {
+ const parts = [];
+ if (result.catalog.newlyFilled?.length) {
+ const n = result.catalog.newlyFilled.length;
+ parts.push(
+ `${n} catalog slot${n === 1 ? '' : 's'} filled`
+ );
+ }
+ if (result.catalog.chapterJustCompleted) {
+ parts.push('chapter complete · +1% luck');
+ } else if (result.catalog.luckPercent > 0 && result.catalog.newlyFilled?.length) {
+ parts.push(`luck +${result.catalog.luckPercent}%`);
+ }
+ if (parts.length) setCatalogMessage(parts.join(' · '));
+ if (result.catalog.luckPercent != null) {
+ setCrate((prev) =>
+ prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
+ );
+ if (result.catalog.chapterJustCompleted) {
+ api.case(crate.id).then((data) => {
+ setCrate((prev) =>
+ prev
+ ? {
+ ...prev,
+ ...data.case,
+ keyProgress: result.keyProgress || data.case.keyProgress,
+ }
+ : data.case
+ );
+ }).catch(() => {});
+ }
+ }
+ }
+ const resolvedCaseName = result.caseName || crate.name;
+ setCaseName(resolvedCaseName);
+ const items = result.items?.length ? result.items : result.item ? [result.item] : [];
+ const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
+ pendingAnnounceRef.current = {
+ ids: inventoryIds,
+ caseName: resolvedCaseName || '',
+ };
+ pendingAutoSellRef.current = items
+ .filter((item) => item?.willAutoSell)
+ .map((item) => item.inventoryId)
+ .filter(Boolean);
+ const next = items.map((item, idx) => ({
+ key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`,
+ item,
+ spinning: false,
+ done: false,
+ }));
+ setSpins(next);
+ requestAnimationFrame(() => {
+ setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
+ });
} catch (err) {
setError(err.message);
+ setOpening(false);
}
};
+ const onReelDone = (key) => {
+ setSpins((prev) => {
+ const alreadyDone = prev.find((s) => s.key === key)?.done;
+ if (alreadyDone) return prev;
+
+ const next = prev.map((s) =>
+ s.key === key ? { ...s, spinning: false, done: true } : s
+ );
+
+ const wasComplete = prev.length > 0 && prev.every((s) => s.done);
+ const nowComplete = next.length > 0 && next.every((s) => s.done);
+
+ if (nowComplete && !wasComplete) {
+ setOpening(false);
+ void finalizeOpens(next, caseName || crate?.name || '');
+ }
+ return next;
+ });
+ };
+
return (
@@ -66,14 +350,63 @@ export default function CasePage() {
{!crate && !error &&
Loading case…
}
{error &&
{error}
}
+ {keyMessage &&
{keyMessage}
}
+ {catalogMessage &&
{catalogMessage}
}
{crate && (
<>
{crate.name}
- Cost {formatCredits(crate.price)} cr · Balance {formatCredits(user.balance)} cr
+ Cost {formatCredits(crate.price)} cr each · Balance{' '}
+ {formatCredits(user.balance)} cr
+ {crate.catalogLuckPercent > 0
+ ? ` · Catalog luck +${crate.catalogLuckPercent}%`
+ : ''}
+
+ {keyProgress && (
+
+
+
+ Keys {keyProgress.keys} · Max{' '}
+ x{keyProgress.maxOpens}
+
+
+ Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
+
+
+
+
+ )}
+
+
+
+ Open
+ {
+ const next = Number(e.target.value);
+ setQuantity(next);
+ persistQty(crate.id, next);
+ }}
+ >
+ {openOptions.map((n) => (
+ maxAffordable && maxAffordable > 0}>
+ {n}×
+
+ ))}
+
+
+
+ Total {formatCredits(totalCost)} cr
+ {maxAffordable < quantity ? ' · insufficient balance' : ''}
+
+
+
: '▣'}
- {spinning ? 'Opening…' : `Open for ${formatCredits(crate.price)} cr`}
+ {opening
+ ? `Opening ${quantity}×…`
+ : `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
- {(spinning || winnerId) && (
-
{
- setSpinning(false);
- setShowResult(true);
- }}
- />
+ {spins.length > 0 && (
+ 1 ? ' many' : ''}`}>
+ {spins.map((spin, idx) => (
+
+ {spins.length > 1 && (
+
#{idx + 1}
+ )}
+
onReelDone(spin.key)}
+ />
+ {spin.done && (
+
+ {spin.item.imageUrl ? (
+
+ ) : null}
+
{spin.item.name}
+
+ {spin.item.rarity}
+
+
+ {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}` : ''}
+ >
+ )}
+
+
+ )}
+
+ ))}
+
)}
- {showResult && wonItem && (
-
- {wonItem.imageUrl ? (
-
- ) : null}
-
You unboxed
-
{wonItem.name}
-
{wonItem.rarity}
-
{formatCredits(wonItem.marketValue)} cr
+ {allDone && spins.length > 1 && (
+
+
You unboxed {spins.length} items
+
+ {spins.map((s) => (
+
+ ))}
+
)}
@@ -119,11 +480,17 @@ export default function CasePage() {
Possible drops
-
Relative drop odds for this case.
+
+ Relative drop odds
+ {crate.catalogLuckPercent > 0
+ ? ` (with +${crate.catalogLuckPercent}% catalog luck)`
+ : ''}{' '}
+ · prices shown at Field-Tested reference.
+
- {crate.items.map((item) => (
+ {possibleDrops.map((item) => (
diff --git a/client/src/pages/CasesPage.jsx b/client/src/pages/CasesPage.jsx
index fc8fc0d..ec6f3fc 100644
--- a/client/src/pages/CasesPage.jsx
+++ b/client/src/pages/CasesPage.jsx
@@ -46,6 +46,12 @@ export default function CasesPage() {
{c.name}
{formatCredits(c.price)} cr
{c.items.length} possible drops
+ {c.keyProgress && (
+
+ {c.keyProgress.keys} key{c.keyProgress.keys === 1 ? '' : 's'} · x
+ {c.keyProgress.maxOpens}
+
+ )}
))}
diff --git a/client/src/pages/CatalogPage.jsx b/client/src/pages/CatalogPage.jsx
new file mode 100644
index 0000000..9dcfd72
--- /dev/null
+++ b/client/src/pages/CatalogPage.jsx
@@ -0,0 +1,125 @@
+import { useEffect, useState } from 'react';
+import { Link, Navigate } from 'react-router-dom';
+import { api, formatCredits } from '../api';
+import { useAuth } from '../AuthContext';
+import { rarityColor } from '../rarities';
+
+export default function CatalogPage() {
+ const { user, loading } = useAuth();
+ const [chapters, setChapters] = useState([]);
+ const [luckPercent, setLuckPercent] = useState(0);
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(true);
+
+ useEffect(() => {
+ if (!user || user.role === 'admin') return undefined;
+ let cancelled = false;
+ (async () => {
+ try {
+ const data = await api.catalog();
+ if (cancelled) return;
+ setChapters(data.chapters || []);
+ setLuckPercent(data.luckPercent || 0);
+ } catch (err) {
+ if (!cancelled) setError(err.message);
+ } finally {
+ if (!cancelled) setBusy(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [user?.id, user?.role]);
+
+ if (loading || busy) return
Loading…
;
+ if (!user) return
;
+ if (user.role === 'admin') return
;
+
+ return (
+
+
+
+
Catalog
+
+ Fill every slot in a case chapter. Each completed chapter grants +1% catalog luck on
+ opens.
+
+
+
+ {luckPercent > 0 ? `+${luckPercent}% luck` : 'No luck bonus yet'}
+
+
+
+ {error && {error}
}
+
+ {chapters.length === 0 && !error && (
+ No active cases to collect.
+ )}
+
+
+ {chapters.map((ch) => (
+
+
+
+ {ch.imageUrl ? (
+
+ ) : (
+
▣
+ )}
+
+
+ {ch.name}
+
+
+ {ch.filled}/{ch.total} slots · {formatCredits(ch.price)} cr
+
+
+
+ {ch.completed ? (
+
Complete · +1%
+ ) : (
+
+ )}
+
+
+
+ {ch.slots.map((slot) => (
+
+ {slot.filled ? (
+ <>
+ {slot.imageUrl ? (
+
+ ) : (
+
+ {slot.name.slice(0, 2).toUpperCase()}
+
+ )}
+
{slot.name}
+ >
+ ) : (
+
?
+ )}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/pages/CoinflipStub.jsx b/client/src/pages/CoinflipStub.jsx
index 3c2dcd1..ab0e0b7 100644
--- a/client/src/pages/CoinflipStub.jsx
+++ b/client/src/pages/CoinflipStub.jsx
@@ -15,17 +15,17 @@ export default function CoinflipStub() {
Coinflip
1v1 item flip — coming soon.
-
+
Back
This mode is not playable yet. Use MultiPlayers Case or 1vX Case in the meantime.
-
+
MultiPlayers
-
+
1vX Case
diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx
index 2ddd873..bd2ff0b 100644
--- a/client/src/pages/Dashboard.jsx
+++ b/client/src/pages/Dashboard.jsx
@@ -3,14 +3,36 @@ import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
+import { onInventoryChanged } from '../inventoryEvents';
+import { loadInventorySort, saveInventorySort } from '../userPrefs';
+
+const PAGE_SIZE = 50;
export default function Dashboard() {
const { user, loading, patchUser } = useAuth();
const [inventory, setInventory] = useState([]);
const [error, setError] = useState('');
- const [sort, setSort] = useState('recent');
+ const [sort, setSort] = useState('value');
+ const [page, setPage] = useState(1);
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
+ const [maxValue, setMaxValue] = useState('10');
+ const [autoSellEnabled, setAutoSellEnabled] = useState(false);
+ const [settingsBusy, setSettingsBusy] = useState(false);
+
+ useEffect(() => {
+ if (!user?.id) return;
+ const saved = loadInventorySort(user.id);
+ if (saved) setSort(saved);
+ }, [user?.id]);
+
+ useEffect(() => {
+ if (!user) return;
+ setAutoSellEnabled(Boolean(user.autoSellEnabled));
+ if (user.autoSellThresholdCents != null) {
+ setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
+ }
+ }, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
const load = async () => {
const inv = await api.inventory();
@@ -32,38 +54,98 @@ export default function Dashboard() {
};
}, [user]);
+ useEffect(() => {
+ if (!user || user.role === 'admin') return undefined;
+ return onInventoryChanged(() => {
+ load().catch(() => {});
+ });
+ }, [user]);
+
const sorted = useMemo(() => {
const list = [...inventory];
if (sort === 'value') {
- list.sort((a, b) => b.item.marketValue - a.item.marketValue);
+ list.sort(
+ (a, b) =>
+ (b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
+ (a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
+ );
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
}
return list;
}, [inventory, sort]);
+ const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
+ const currentPage = Math.min(page, totalPages);
+ const pageItems = useMemo(() => {
+ const start = (currentPage - 1) * PAGE_SIZE;
+ return sorted.slice(start, start + PAGE_SIZE);
+ }, [sorted, currentPage]);
+
+ useEffect(() => {
+ setPage(1);
+ }, [sort]);
+
+ useEffect(() => {
+ if (page > totalPages) setPage(totalPages);
+ }, [page, totalPages]);
+
const selectedValue = useMemo(
() =>
inventory
.filter((inv) => selected.includes(inv.id))
- .reduce((sum, inv) => sum + inv.item.marketValue, 0),
+ .reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
[inventory, selected]
);
+ const thresholdCents = useMemo(() => {
+ const credits = Number(maxValue);
+ if (!Number.isFinite(credits) || credits < 0) return null;
+ return Math.round(credits * 100);
+ }, [maxValue]);
+
+ const belowThreshold = useMemo(() => {
+ if (thresholdCents == null) return [];
+ return inventory.filter(
+ (inv) =>
+ !inv.favorite &&
+ (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
+ );
+ }, [inventory, thresholdCents]);
+
+ const selectedItems = useMemo(
+ () => inventory.filter((inv) => selected.includes(inv.id)),
+ [inventory, selected]
+ );
+
+ const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
+ const selectedAllFavorite =
+ selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite);
+ const selectedCanSell = selectedItems.some((inv) => !inv.favorite);
+
+ const belowValue = useMemo(
+ () =>
+ belowThreshold.reduce(
+ (sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
+ 0
+ ),
+ [belowThreshold]
+ );
+
const toggle = (id) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
- const sell = async () => {
- if (!selected.length) return;
+ const sellIds = async (ids) => {
+ if (!ids.length) return;
setBusy(true);
setError('');
try {
- const data = await api.sellInventory(selected);
+ const data = await api.sellInventory(ids);
patchUser({ balance: data.user.balance });
- setSelected([]);
+ setSelected((prev) => prev.filter((id) => !ids.includes(id)));
await load();
} catch (err) {
setError(err.message);
@@ -72,6 +154,71 @@ export default function Dashboard() {
}
};
+ const sell = async () => {
+ const ids = selectedItems.filter((inv) => !inv.favorite).map((inv) => inv.id);
+ if (!ids.length) return;
+ await sellIds(ids);
+ };
+
+ const setFavorite = async (favorite) => {
+ if (!selected.length) return;
+ setBusy(true);
+ setError('');
+ try {
+ await api.setInventoryFavorite(selected, favorite);
+ await load();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const sellBelow = async () => {
+ const ids = belowThreshold.map((inv) => inv.id);
+ if (!ids.length || thresholdCents == null) return;
+ const favSkipped = inventory.filter(
+ (inv) =>
+ inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
+ ).length;
+ const ok = window.confirm(
+ `Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
+ (favSkipped ? ` (${favSkipped} favorite item${favSkipped === 1 ? '' : 's'} excluded)` : '')
+ );
+ if (!ok) return;
+ await sellIds(ids);
+ };
+
+ const saveAutoSell = async (enabled, thresholdCredits) => {
+ setSettingsBusy(true);
+ setError('');
+ try {
+ const data = await api.updateAutoSell({ enabled, thresholdCredits });
+ patchUser(data.user);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSettingsBusy(false);
+ }
+ };
+
+ const onAutoSellToggle = async (enabled) => {
+ setAutoSellEnabled(enabled);
+ const credits = Number(maxValue);
+ if (!Number.isFinite(credits) || credits < 0) return;
+ await saveAutoSell(enabled, credits);
+ };
+
+ const onThresholdBlur = async () => {
+ const credits = Number(maxValue);
+ if (!Number.isFinite(credits) || credits < 0) return;
+ const savedCents = user?.autoSellThresholdCents ?? 1000;
+ if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
+ return;
+ }
+ await saveAutoSell(autoSellEnabled, credits);
+ };
+
if (loading) return
Loading…
;
if (!user) return
;
if (user.role === 'admin') return
;
@@ -84,31 +231,44 @@ export default function Dashboard() {
Inventory
Select items to sell at market value.
-
{formatCredits(user.balance)} cr
-
-
setSort('recent')}
- >
- Most recent
-
-
setSort('value')}
- >
- Highest value
-
+
+
+ {
+ setSort('value');
+ saveInventorySort(user.id, 'value');
+ }}
+ >
+ Highest value
+
+ {
+ setSort('recent');
+ saveInventorySort(user.id, 'recent');
+ }}
+ >
+ Most recent
+
+
+ {inventory.length > 0 && (
+
+ {inventory.length} item{inventory.length === 1 ? '' : 's'}
+
+ )}
{selected.length > 0 && (
<>
{selected.length} selected · {formatCredits(selectedValue)} cr
+ {selectedHasFavorite ? ' · includes favorites' : ''}
Clear
-
+ {!selectedAllFavorite && (
+ setFavorite(true)}
+ disabled={busy}
+ >
+ Favorite
+
+ )}
+ {selectedItems.some((inv) => inv.favorite) && (
+ setFavorite(false)}
+ disabled={busy}
+ >
+ Unfavorite
+
+ )}
+
Sell
>
@@ -126,6 +312,74 @@ export default function Dashboard() {
+
+
{error &&
{error}
}
{inventory.length === 0 ? (
@@ -136,16 +390,42 @@ export default function Dashboard() {
.
) : (
-
- {sorted.map((inv) => (
- toggle(inv.id)}
- />
- ))}
-
+ <>
+
+ {pageItems.map((inv) => (
+ toggle(inv.id)}
+ />
+ ))}
+
+ {totalPages > 1 && (
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+ Previous
+
+
+ Page {currentPage} / {totalPages}
+
+ = totalPages}
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
+ >
+ Next
+
+
+ )}
+ >
)}
diff --git a/client/src/pages/DuelListPage.jsx b/client/src/pages/DuelListPage.jsx
index fc8946c..447498e 100644
--- a/client/src/pages/DuelListPage.jsx
+++ b/client/src/pages/DuelListPage.jsx
@@ -63,7 +63,7 @@ export default function DuelListPage() {
minItemValue: Math.round(Number(form.minItemValue) * 100),
minPlayersToStart: Number(form.minPlayersToStart),
});
- navigate(`/bet/1vx/${data.room.id}`);
+ navigate(`/battle/1vx/${data.room.id}`);
} catch (err) {
setError(err.message);
} finally {
@@ -82,7 +82,7 @@ export default function DuelListPage() {
1vX Case
Create a room or join an open duel.
-
+
Modes
@@ -145,7 +145,7 @@ export default function DuelListPage() {
) : (
- {inventory.length === 0 ? (
+ {inventoryByValue.length === 0 ? (
- {inventory.length === 0 ? (
+ {inventoryByValue.length === 0 ? (