This commit is contained in:
gpatruno
2026-07-18 16:26:08 +02:00
parent 49c444e8b9
commit 2614d679c8
85 changed files with 8074 additions and 710 deletions
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../AuthContext';
function formatUnlockedAt(iso) {
if (!iso) return null;
try {
return new Date(iso).toLocaleString();
} catch {
return null;
}
}
export default function AchievementsPage() {
const { user, loading } = useAuth();
const [achievements, setAchievements] = useState([]);
const [unlocked, setUnlocked] = useState(0);
const [total, setTotal] = useState(0);
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const data = await api.achievements();
if (cancelled) return;
setAchievements(data.achievements || []);
setUnlocked(data.unlocked || 0);
setTotal(data.total || 0);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [user?.id, user?.role]);
if (loading || busy) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Achievements</h1>
<p className="muted">
Unlock badges by playing. Earned badges show on your{' '}
<Link to="/profile">profile</Link>.
</p>
</div>
<div className="catalog-luck-pill">
{unlocked}/{total} unlocked
</div>
</div>
{error && <div className="error">{error}</div>}
{achievements.length === 0 && !error && (
<div className="empty">No achievements defined yet.</div>
)}
<div className="achievement-grid">
{achievements.map((a) => {
const done = Boolean(a.unlockedAt);
return (
<article
key={a.key}
className={`achievement-card${done ? ' unlocked' : ' locked'}`}
>
<div className="achievement-card-icon" aria-hidden>
{a.icon || '★'}
</div>
<div className="achievement-card-body">
<h2>{a.name}</h2>
<p className="muted">{a.description}</p>
{done ? (
<span className="achievement-card-meta">
Unlocked {formatUnlockedAt(a.unlockedAt)}
</span>
) : (
<span className="achievement-card-meta locked-label">Locked</span>
)}
</div>
</article>
);
})}
</div>
</section>
);
}
+2 -2
View File
@@ -432,7 +432,7 @@ export default function Admin() {
</select>
</label>
<label>
Value (cents)
Value (cents, Field-Tested ref)
<input
type="number"
value={itemForm.marketValue}
@@ -480,7 +480,7 @@ export default function Admin() {
</select>
</label>
<label>
Value (cents)
Value (cents, Field-Tested ref)
<input
type="number"
value={itemEdit.marketValue}
+257
View File
@@ -0,0 +1,257 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import { useCountdown } from '../hooks/useCountdown';
import { rarityColor } from '../rarities';
import { getSocket } from '../socket';
const TOP_ITEMS = 5;
function waitingDuelRooms(open) {
return (open || []).filter(
(r) =>
r.status === 'open' &&
((r.playerCount ?? r.players?.length ?? 0) > 0 || (r.deposits?.length ?? 0) > 0)
);
}
function depositsToReelItems(deposits) {
return (deposits || [])
.filter((d) => d.item)
.map((d) => ({
...d.item,
id: d.id,
ownerUsername: d.username,
ownerAvatarUrl: d.avatarUrl || '',
}));
}
export default function BattleHub() {
const { user, loading } = useAuth();
const [round, setRound] = useState(null);
const [duelOpen, setDuelOpen] = useState([]);
const [spinning, setSpinning] = useState(false);
const [spinActive, setSpinActive] = useState(false);
const [spinDeposits, setSpinDeposits] = useState([]);
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
const [lastWinner, setLastWinner] = useState(null);
const [revealWinner, setRevealWinner] = useState(false);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
if (cancelled) return;
setRound(jp.round);
setDuelOpen(duels.open || []);
if (jp.round?.status === 'spinning') {
setSpinDeposits(jp.round.deposits || []);
setSpinActive(true);
setSpinning(true);
setReelWinnerItemId(jp.round.reelWinnerItemId || null);
setLastWinner(jp.round.winnerUsername || null);
}
} catch {
/* hub still usable without live preview */
}
})();
const socket = getSocket();
const onState = (state) => setRound(state);
const onSpin = (state) => {
setRound(state);
setSpinDeposits(state.deposits || []);
setSpinActive(true);
setSpinning(true);
setRevealWinner(false);
setReelWinnerItemId(state.reelWinnerItemId);
setLastWinner(state.winnerUsername);
};
const onResult = (state) => setRound(state);
const onDuels = (list) => setDuelOpen(list.open || []);
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.on('jackpot:state', onState);
socket.on('jackpot:spin', onSpin);
socket.on('jackpot:result', onResult);
socket.on('duels:list', onDuels);
socket.on('connect', () => {
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
});
return () => {
cancelled = true;
socket.emit('jackpot:unsubscribe');
socket.emit('duels:unsubscribe');
socket.off('jackpot:state', onState);
socket.off('jackpot:spin', onSpin);
socket.off('jackpot:result', onResult);
socket.off('duels:list', onDuels);
};
}, [user]);
useEffect(() => {
if (!revealWinner) return undefined;
const t = setTimeout(() => {
setSpinActive(false);
setRevealWinner(false);
}, 4000);
return () => clearTimeout(t);
}, [revealWinner]);
const topItems = useMemo(() => {
const deposits = [...(round?.deposits || [])].filter((d) => d.item);
deposits.sort((a, b) => (b.valueCents || 0) - (a.valueCents || 0));
return deposits.slice(0, TOP_ITEMS);
}, [round]);
const potItems = useMemo(
() => depositsToReelItems(spinActive && spinDeposits.length ? spinDeposits : round?.deposits),
[round, spinDeposits, spinActive]
);
const showReel = spinActive && potItems.length > 0;
const waitingRooms = useMemo(() => waitingDuelRooms(duelOpen), [duelOpen]);
const waitingRoomCount = waitingRooms.length;
const liveTimerEndsAt =
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers && !spinning
? round.endsAt
: null;
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
const playerCount = round?.players?.length ?? 0;
const potValue = round?.totalValue ?? 0;
const itemCount = round?.deposits?.length ?? 0;
const statusLabel = !round
? '…'
: round.status === 'spinning' || spinning
? 'Spinning'
: round.waitingForPlayers
? 'Waiting for players'
: round.status === 'open'
? liveTimerEndsAt
? `Live ${liveTimerLabel}`
: 'Live'
: round.status;
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Battle</h1>
<p className="muted">Multiplayer item gambling modes.</p>
</div>
</div>
<div className="mode-grid">
<Link to="/battle/multi" className="mode-card mode-card-live">
<div className="mode-card-top">
<h2>MultiPlayers Case</h2>
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
{statusLabel}
</span>
</div>
<p className="muted mode-blurb">
Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items chance scales with
value.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Pot</span>
<strong>{formatCredits(potValue)} cr</strong>
</div>
<div>
<span className="muted">Players</span>
<strong>{playerCount}</strong>
</div>
<div>
<span className="muted">Items</span>
<strong>{itemCount}</strong>
</div>
</div>
{showReel ? (
<div className="mode-card-reel">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || round?.status === 'spinning'}
showOwners
compact
onDone={() => {
setSpinning(false);
setRevealWinner(true);
}}
/>
{revealWinner && lastWinner && (
<p className="mode-card-winner">
Winner: <strong>{lastWinner}</strong>
</p>
)}
</div>
) : topItems.length > 0 ? (
<div className="mode-pot-items" aria-label="Top items in pot">
{topItems.map((d) => (
<div
key={d.id}
className="mode-pot-thumb"
style={{ borderColor: rarityColor(d.item.rarity) }}
title={`${d.item.name} · ${formatCredits(d.valueCents)} cr`}
>
{d.item.imageUrl ? (
<img src={d.item.imageUrl} alt="" loading="lazy" />
) : (
<span>{d.item.name.slice(0, 2).toUpperCase()}</span>
)}
</div>
))}
{itemCount > topItems.length && (
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
)}
</div>
) : (
<p className="muted mode-pot-empty">No items in the pot yet be the first.</p>
)}
</Link>
<Link to="/battle/1vx" className="mode-card">
<div className="mode-card-top">
<h2>1vX Case</h2>
{waitingRoomCount > 0 && (
<span className="mode-status">
{waitingRoomCount} waiting
</span>
)}
</div>
<p className="muted mode-blurb">
Create or join custom rooms. Set player/item limits and min value. Spectate live duels.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Open rooms</span>
<strong>{waitingRoomCount}</strong>
</div>
</div>
</Link>
<div className="mode-card stub">
<h2>Coinflip</h2>
<p className="muted">Classic 1v1 item flip. Coming soon.</p>
<span className="badge">Soon</span>
</div>
</div>
</section>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { Link, Navigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
const MODES = [
{
to: '/bet/multi',
title: 'MultiPlayers Case',
blurb: 'Join the live pot. Bet up to 3 items — chance scales with value. Winner takes all.',
ready: true,
},
{
to: '/bet/1vx',
title: '1vX Case',
blurb: 'Create or join custom rooms. Set player/item limits and min value. Spectate live duels.',
ready: true,
},
{
to: '/bet/coinflip',
title: 'Coinflip',
blurb: 'Classic 1v1 item flip. Coming soon.',
ready: false,
},
];
export default function BetHub() {
const { user, loading } = useAuth();
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Bet Item</h1>
<p className="muted">Multiplayer item gambling modes.</p>
</div>
</div>
<div className="mode-grid">
{MODES.map((m) =>
m.ready ? (
<Link key={m.to} to={m.to} className="mode-card">
<h2>{m.title}</h2>
<p className="muted">{m.blurb}</p>
</Link>
) : (
<div key={m.to} className="mode-card stub">
<h2>{m.title}</h2>
<p className="muted">{m.blurb}</p>
<span className="badge">Soon</span>
</div>
)
)}
</div>
</section>
);
}
+409 -42
View File
@@ -1,27 +1,159 @@
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import ItemTile from '../components/ItemTile';
import { notifyInventoryChanged } from '../inventoryEvents';
import { rarityColor } from '../rarities';
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
const DEFAULT_MAX_OPENS = 10;
/** 15, then steps of 5 (10, 15…), plus max if not already listed. */
function buildOpenQuantityOptions(maxOpens) {
const cap = Math.max(1, maxOpens);
const options = [];
for (let n = 1; n <= Math.min(5, cap); n += 1) options.push(n);
for (let n = 10; n <= cap; n += 5) options.push(n);
if (cap > 5 && !options.includes(cap)) options.push(cap);
return options;
}
export default function CasePage() {
const { id } = useParams();
const { user, loading, updateBalance } = useAuth();
const [crate, setCrate] = useState(null);
const [error, setError] = useState('');
const [spinning, setSpinning] = useState(false);
const [winnerId, setWinnerId] = useState(null);
const [wonItem, setWonItem] = useState(null);
const [showResult, setShowResult] = useState(false);
const [keyMessage, setKeyMessage] = useState('');
const [catalogMessage, setCatalogMessage] = useState('');
const [quantity, setQuantity] = useState(1);
const [opening, setOpening] = useState(false);
const [spins, setSpins] = useState([]);
const [caseName, setCaseName] = useState('');
const announcedRef = useRef(false);
const finishingRef = useRef(false);
const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
const pendingAutoSellRef = useRef([]);
const quantityRef = useRef(1);
const caseIdRef = useRef(null);
const prefReadyRef = useRef(false);
const keyProgress = crate?.keyProgress ?? null;
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
quantityRef.current = quantity;
const persistQty = (caseId, qty) => {
if (user?.id && caseId != null) saveCaseOpenQty(user.id, caseId, qty);
};
useEffect(() => {
return () => {
if (user?.id && caseIdRef.current != null) {
saveCaseOpenQty(user.id, caseIdRef.current, quantityRef.current);
}
};
}, [user?.id]);
const announcePendingDrop = async (useKeepalive = false) => {
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
if (!ids.length || announcedRef.current) return;
announcedRef.current = true;
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
if (useKeepalive) {
fetch('/api/feed/announce', {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body,
}).catch(() => {
announcedRef.current = false;
});
return;
}
try {
await api.feedAnnounce(ids, announceCaseName);
} catch {
announcedRef.current = false;
}
};
const applyPendingAutoSellKeepalive = () => {
const ids = pendingAutoSellRef.current.filter(Boolean);
if (!ids.length) return;
pendingAutoSellRef.current = [];
fetch('/api/inventory/auto-sell/apply', {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inventoryItemIds: ids }),
}).catch(() => {});
};
const finalizeOpens = async (completedSpins, announceCaseName) => {
if (finishingRef.current) return;
finishingRef.current = true;
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
pendingAnnounceRef.current = { ids, caseName: announceCaseName };
try {
await announcePendingDrop(false);
const toSell = completedSpins
.filter((s) => s.item?.willAutoSell)
.map((s) => s.item.inventoryId)
.filter(Boolean);
pendingAutoSellRef.current = [];
if (toSell.length) {
const data = await api.applyPendingAutoSell(toSell);
updateBalance(data.user.balance);
notifyInventoryChanged();
const soldSet = new Set(data.soldIds);
setSpins((curr) =>
curr.map((s) =>
soldSet.has(s.item.inventoryId)
? {
...s,
item: {
...s.item,
autoSold: true,
soldForCents: s.item.valueCents,
},
}
: s
)
);
}
} catch (err) {
setError(err.message);
announcedRef.current = false;
} finally {
finishingRef.current = false;
}
};
useEffect(() => {
let cancelled = false;
prefReadyRef.current = false;
caseIdRef.current = null;
setCrate(null);
(async () => {
try {
const data = await api.case(id);
if (!cancelled) setCrate(data.case);
if (!cancelled) {
setCrate(data.case);
caseIdRef.current = data.case.id;
}
} catch (err) {
if (!cancelled) setError(err.message);
}
@@ -31,33 +163,185 @@ export default function CasePage() {
};
}, [id]);
const maxAffordable = useMemo(() => {
if (!crate || !user) return 1;
if (crate.price <= 0) return maxOpens;
const affordable = Math.floor(user.balance / crate.price);
return Math.min(maxOpens, Math.max(affordable, 0));
}, [crate, user, maxOpens]);
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
useLayoutEffect(() => {
if (!crate || !user?.id) return;
const saved = loadCaseOpenQty(user.id, crate.id);
const resolved = resolveOpenQty(saved, openOptions, maxAffordable);
if (!prefReadyRef.current) {
prefReadyRef.current = true;
setQuantity(resolved);
return;
}
setQuantity((q) => {
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
if (!allowed.length) return 1;
if (allowed.includes(q)) return q;
const below = allowed.filter((n) => n <= q);
return below.length ? below[below.length - 1] : allowed[0];
});
}, [crate, maxAffordable, openOptions, user?.id]);
useEffect(() => {
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
persistQty(crate.id, quantity);
}, [quantity, user?.id, crate?.id]);
useEffect(() => {
return () => {
announcePendingDrop(true);
applyPendingAutoSellKeepalive();
};
}, []);
const possibleDrops = useMemo(() => {
if (!crate?.items?.length) return [];
return [...crate.items].sort((a, b) => {
const ca = Number(a.chance ?? a.dropRate ?? 0);
const cb = Number(b.chance ?? b.dropRate ?? 0);
if (ca !== cb) return ca - cb;
return (b.marketValue || 0) - (a.marketValue || 0);
});
}, [crate]);
const progressPct = useMemo(() => {
if (!keyProgress?.opensRequired) return 0;
return Math.min(
100,
Math.round((keyProgress.opensSinceLastKey / keyProgress.opensRequired) * 100)
);
}, [keyProgress]);
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const totalCost = crate ? crate.price * quantity : 0;
const canOpen =
crate && !spinning && user.balance >= crate.price && crate.items.length > 0;
crate &&
!opening &&
crate.items.length > 0 &&
quantity >= 1 &&
quantity <= maxOpens &&
user.balance >= totalCost;
const allDone = spins.length > 0 && spins.every((s) => s.done);
const openCase = async () => {
if (!canOpen) return;
setError('');
setWonItem(null);
setShowResult(false);
setWinnerId(null);
setSpinning(false);
setKeyMessage('');
setCatalogMessage('');
setSpins([]);
setOpening(true);
announcedRef.current = false;
finishingRef.current = false;
try {
const result = await api.openCase(crate.id);
const result = await api.openCase(crate.id, quantity);
updateBalance(result.user.balance);
setWonItem(result.item);
setWinnerId(result.item.id);
// start spin on next frame so reel rebuilds with winner
requestAnimationFrame(() => setSpinning(true));
notifyInventoryChanged();
persistQty(crate.id, quantity);
if (result.keyProgress) {
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
}
if (result.keysGained > 0) {
setKeyMessage(
`+${result.keysGained} key${result.keysGained === 1 ? '' : 's'} unlocked · now x${result.keyProgress.maxOpens} max`
);
}
if (result.catalog) {
const parts = [];
if (result.catalog.newlyFilled?.length) {
const n = result.catalog.newlyFilled.length;
parts.push(
`${n} catalog slot${n === 1 ? '' : 's'} filled`
);
}
if (result.catalog.chapterJustCompleted) {
parts.push('chapter complete · +1% luck');
} else if (result.catalog.luckPercent > 0 && result.catalog.newlyFilled?.length) {
parts.push(`luck +${result.catalog.luckPercent}%`);
}
if (parts.length) setCatalogMessage(parts.join(' · '));
if (result.catalog.luckPercent != null) {
setCrate((prev) =>
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
);
if (result.catalog.chapterJustCompleted) {
api.case(crate.id).then((data) => {
setCrate((prev) =>
prev
? {
...prev,
...data.case,
keyProgress: result.keyProgress || data.case.keyProgress,
}
: data.case
);
}).catch(() => {});
}
}
}
const resolvedCaseName = result.caseName || crate.name;
setCaseName(resolvedCaseName);
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
pendingAnnounceRef.current = {
ids: inventoryIds,
caseName: resolvedCaseName || '',
};
pendingAutoSellRef.current = items
.filter((item) => item?.willAutoSell)
.map((item) => item.inventoryId)
.filter(Boolean);
const next = items.map((item, idx) => ({
key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`,
item,
spinning: false,
done: false,
}));
setSpins(next);
requestAnimationFrame(() => {
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
});
} catch (err) {
setError(err.message);
setOpening(false);
}
};
const onReelDone = (key) => {
setSpins((prev) => {
const alreadyDone = prev.find((s) => s.key === key)?.done;
if (alreadyDone) return prev;
const next = prev.map((s) =>
s.key === key ? { ...s, spinning: false, done: true } : s
);
const wasComplete = prev.length > 0 && prev.every((s) => s.done);
const nowComplete = next.length > 0 && next.every((s) => s.done);
if (nowComplete && !wasComplete) {
setOpening(false);
void finalizeOpens(next, caseName || crate?.name || '');
}
return next;
});
};
return (
<div className="case-page">
<Link to="/cases" className="muted">
@@ -66,14 +350,63 @@ export default function CasePage() {
{!crate && !error && <p className="muted">Loading case</p>}
{error && <div className="error">{error}</div>}
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
{crate && (
<>
<div className="case-hero">
<h1>{crate.name}</h1>
<p className="muted">
Cost {formatCredits(crate.price)} cr · Balance {formatCredits(user.balance)} cr
Cost {formatCredits(crate.price)} cr each · Balance{' '}
{formatCredits(user.balance)} cr
{crate.catalogLuckPercent > 0
? ` · Catalog luck +${crate.catalogLuckPercent}%`
: ''}
</p>
{keyProgress && (
<div className="case-key-progress">
<div className="case-key-head">
<span>
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
<strong>x{keyProgress.maxOpens}</strong>
</span>
<span className="muted">
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
</span>
</div>
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
</div>
</div>
)}
<div className="open-qty">
<label className="muted">
Open
<select
value={quantity}
disabled={opening}
onChange={(e) => {
const next = Number(e.target.value);
setQuantity(next);
persistQty(crate.id, next);
}}
>
{openOptions.map((n) => (
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
{n}×
</option>
))}
</select>
</label>
<span className="muted">
Total {formatCredits(totalCost)} cr
{maxAffordable < quantity ? ' · insufficient balance' : ''}
</span>
</div>
<button
type="button"
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
@@ -84,34 +417,62 @@ export default function CasePage() {
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
</button>
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
{spinning ? 'Opening…' : `Open for ${formatCredits(crate.price)} cr`}
{opening
? `Opening ${quantity}×…`
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
</button>
</div>
{(spinning || winnerId) && (
<CaseReel
items={crate.items}
winnerId={winnerId}
spinning={spinning}
onDone={() => {
setSpinning(false);
setShowResult(true);
}}
/>
{spins.length > 0 && (
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
{spins.map((spin, idx) => (
<div key={spin.key} className="multi-reel-slot">
{spins.length > 1 && (
<div className="muted multi-reel-label">#{idx + 1}</div>
)}
<CaseReel
items={crate.items}
winnerId={spin.item.id}
spinning={spin.spinning}
onDone={() => onReelDone(spin.key)}
/>
{spin.done && (
<div
className="won-banner compact"
style={{ '--rarity': rarityColor(spin.item.rarity) }}
>
{spin.item.imageUrl ? (
<img src={spin.item.imageUrl} alt="" className="won-img" />
) : null}
<strong>{spin.item.name}</strong>
<div style={{ color: rarityColor(spin.item.rarity) }}>
{spin.item.rarity}
</div>
<div className="muted">
{spin.item.autoSold ? (
<>Auto-sold for {formatCredits(spin.item.soldForCents)} cr</>
) : (
<>
{formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr
{spin.item.wear ? ` · ${spin.item.wear}` : ''}
</>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{showResult && wonItem && (
<div
className="won-banner"
style={{ '--rarity': rarityColor(wonItem.rarity) }}
>
{wonItem.imageUrl ? (
<img src={wonItem.imageUrl} alt="" className="won-img" />
) : null}
<div className="muted">You unboxed</div>
<strong>{wonItem.name}</strong>
<div style={{ color: rarityColor(wonItem.rarity) }}>{wonItem.rarity}</div>
<div className="muted">{formatCredits(wonItem.marketValue)} cr</div>
{allDone && spins.length > 1 && (
<div className="won-banner section">
<div className="muted">You unboxed {spins.length} items</div>
<div className="grid" style={{ marginTop: '0.75rem' }}>
{spins.map((s) => (
<ItemTile key={s.key} item={s.item} />
))}
</div>
</div>
)}
@@ -119,11 +480,17 @@ export default function CasePage() {
<div className="section-head">
<div>
<h2>Possible drops</h2>
<p className="muted">Relative drop odds for this case.</p>
<p className="muted">
Relative drop odds
{crate.catalogLuckPercent > 0
? ` (with +${crate.catalogLuckPercent}% catalog luck)`
: ''}{' '}
· prices shown at Field-Tested reference.
</p>
</div>
</div>
<div className="grid">
{crate.items.map((item) => (
{possibleDrops.map((item) => (
<div key={item.id}>
<ItemTile item={item} />
<div className="item-meta" style={{ marginTop: '0.35rem' }}>
+6
View File
@@ -46,6 +46,12 @@ export default function CasesPage() {
<div className="case-name">{c.name}</div>
<div className="case-meta">{formatCredits(c.price)} cr</div>
<div className="case-meta">{c.items.length} possible drops</div>
{c.keyProgress && (
<div className="case-meta case-key-badge">
{c.keyProgress.keys} key{c.keyProgress.keys === 1 ? '' : 's'} · x
{c.keyProgress.maxOpens}
</div>
)}
</Link>
))}
</div>
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import { rarityColor } from '../rarities';
export default function CatalogPage() {
const { user, loading } = useAuth();
const [chapters, setChapters] = useState([]);
const [luckPercent, setLuckPercent] = useState(0);
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const data = await api.catalog();
if (cancelled) return;
setChapters(data.chapters || []);
setLuckPercent(data.luckPercent || 0);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [user?.id, user?.role]);
if (loading || busy) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Catalog</h1>
<p className="muted">
Fill every slot in a case chapter. Each completed chapter grants +1% catalog luck on
opens.
</p>
</div>
<div className="catalog-luck-pill">
{luckPercent > 0 ? `+${luckPercent}% luck` : 'No luck bonus yet'}
</div>
</div>
{error && <div className="error">{error}</div>}
{chapters.length === 0 && !error && (
<div className="empty">No active cases to collect.</div>
)}
<div className="catalog-chapters">
{chapters.map((ch) => (
<article
key={ch.caseId}
className={`catalog-chapter${ch.completed ? ' completed' : ''}`}
>
<div className="catalog-chapter-head">
<div className="catalog-chapter-title">
{ch.imageUrl ? (
<img src={ch.imageUrl} alt="" className="catalog-case-thumb" />
) : (
<span className="catalog-case-thumb placeholder"></span>
)}
<div>
<h2>
<Link to={`/cases/${ch.caseId}`}>{ch.name}</Link>
</h2>
<p className="muted">
{ch.filled}/{ch.total} slots · {formatCredits(ch.price)} cr
</p>
</div>
</div>
{ch.completed ? (
<span className="catalog-complete-badge">Complete · +1%</span>
) : (
<div className="catalog-progress-bar" aria-hidden>
<div
className="catalog-progress-fill"
style={{
width: `${ch.total ? Math.round((ch.filled / ch.total) * 100) : 0}%`,
}}
/>
</div>
)}
</div>
<div className="catalog-slots">
{ch.slots.map((slot) => (
<div
key={slot.itemId}
className={`catalog-slot${slot.filled ? ' filled' : ' empty'}`}
style={slot.filled ? { '--rarity': rarityColor(slot.rarity) } : undefined}
title={slot.filled ? slot.name : 'Not collected yet'}
>
{slot.filled ? (
<>
{slot.imageUrl ? (
<img src={slot.imageUrl} alt="" loading="lazy" />
) : (
<span className="catalog-slot-fallback">
{slot.name.slice(0, 2).toUpperCase()}
</span>
)}
<span className="catalog-slot-name">{slot.name}</span>
</>
) : (
<span className="catalog-slot-empty">?</span>
)}
</div>
))}
</div>
</article>
))}
</div>
</section>
);
}
+3 -3
View File
@@ -15,17 +15,17 @@ export default function CoinflipStub() {
<h1>Coinflip</h1>
<p className="muted">1v1 item flip coming soon.</p>
</div>
<Link to="/bet" className="btn secondary">
<Link to="/battle" className="btn secondary">
Back
</Link>
</div>
<div className="empty stub-panel">
<p>This mode is not playable yet. Use MultiPlayers Case or 1vX Case in the meantime.</p>
<div className="row-actions" style={{ marginTop: '1rem' }}>
<Link to="/bet/multi" className="btn">
<Link to="/battle/multi" className="btn">
MultiPlayers
</Link>
<Link to="/bet/1vx" className="btn secondary">
<Link to="/battle/1vx" className="btn secondary">
1vX Case
</Link>
</div>
+314 -34
View File
@@ -3,14 +3,36 @@ import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import { onInventoryChanged } from '../inventoryEvents';
import { loadInventorySort, saveInventorySort } from '../userPrefs';
const PAGE_SIZE = 50;
export default function Dashboard() {
const { user, loading, patchUser } = useAuth();
const [inventory, setInventory] = useState([]);
const [error, setError] = useState('');
const [sort, setSort] = useState('recent');
const [sort, setSort] = useState('value');
const [page, setPage] = useState(1);
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
const [maxValue, setMaxValue] = useState('10');
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
const [settingsBusy, setSettingsBusy] = useState(false);
useEffect(() => {
if (!user?.id) return;
const saved = loadInventorySort(user.id);
if (saved) setSort(saved);
}, [user?.id]);
useEffect(() => {
if (!user) return;
setAutoSellEnabled(Boolean(user.autoSellEnabled));
if (user.autoSellThresholdCents != null) {
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
}
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
const load = async () => {
const inv = await api.inventory();
@@ -32,38 +54,98 @@ export default function Dashboard() {
};
}, [user]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
return onInventoryChanged(() => {
load().catch(() => {});
});
}, [user]);
const sorted = useMemo(() => {
const list = [...inventory];
if (sort === 'value') {
list.sort((a, b) => b.item.marketValue - a.item.marketValue);
list.sort(
(a, b) =>
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
}
return list;
}, [inventory, sort]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return sorted.slice(start, start + PAGE_SIZE);
}, [sorted, currentPage]);
useEffect(() => {
setPage(1);
}, [sort]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const selectedValue = useMemo(
() =>
inventory
.filter((inv) => selected.includes(inv.id))
.reduce((sum, inv) => sum + inv.item.marketValue, 0),
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
[inventory, selected]
);
const thresholdCents = useMemo(() => {
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return null;
return Math.round(credits * 100);
}, [maxValue]);
const belowThreshold = useMemo(() => {
if (thresholdCents == null) return [];
return inventory.filter(
(inv) =>
!inv.favorite &&
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
);
}, [inventory, thresholdCents]);
const selectedItems = useMemo(
() => inventory.filter((inv) => selected.includes(inv.id)),
[inventory, selected]
);
const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
const selectedAllFavorite =
selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite);
const selectedCanSell = selectedItems.some((inv) => !inv.favorite);
const belowValue = useMemo(
() =>
belowThreshold.reduce(
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
0
),
[belowThreshold]
);
const toggle = (id) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const sell = async () => {
if (!selected.length) return;
const sellIds = async (ids) => {
if (!ids.length) return;
setBusy(true);
setError('');
try {
const data = await api.sellInventory(selected);
const data = await api.sellInventory(ids);
patchUser({ balance: data.user.balance });
setSelected([]);
setSelected((prev) => prev.filter((id) => !ids.includes(id)));
await load();
} catch (err) {
setError(err.message);
@@ -72,6 +154,71 @@ export default function Dashboard() {
}
};
const sell = async () => {
const ids = selectedItems.filter((inv) => !inv.favorite).map((inv) => inv.id);
if (!ids.length) return;
await sellIds(ids);
};
const setFavorite = async (favorite) => {
if (!selected.length) return;
setBusy(true);
setError('');
try {
await api.setInventoryFavorite(selected, favorite);
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const sellBelow = async () => {
const ids = belowThreshold.map((inv) => inv.id);
if (!ids.length || thresholdCents == null) return;
const favSkipped = inventory.filter(
(inv) =>
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
).length;
const ok = window.confirm(
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
(favSkipped ? ` (${favSkipped} favorite item${favSkipped === 1 ? '' : 's'} excluded)` : '')
);
if (!ok) return;
await sellIds(ids);
};
const saveAutoSell = async (enabled, thresholdCredits) => {
setSettingsBusy(true);
setError('');
try {
const data = await api.updateAutoSell({ enabled, thresholdCredits });
patchUser(data.user);
} catch (err) {
setError(err.message);
} finally {
setSettingsBusy(false);
}
};
const onAutoSellToggle = async (enabled) => {
setAutoSellEnabled(enabled);
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return;
await saveAutoSell(enabled, credits);
};
const onThresholdBlur = async () => {
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return;
const savedCents = user?.autoSellThresholdCents ?? 1000;
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
return;
}
await saveAutoSell(autoSellEnabled, credits);
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
@@ -84,31 +231,44 @@ export default function Dashboard() {
<h1>Inventory</h1>
<p className="muted">Select items to sell at market value.</p>
</div>
<div className="balance-pill">{formatCredits(user.balance)} cr</div>
</div>
<div className="inv-toolbar">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
type="button"
className={sort === 'recent' ? 'active' : ''}
onClick={() => setSort('recent')}
>
Most recent
</button>
<button
type="button"
className={sort === 'value' ? 'active' : ''}
onClick={() => setSort('value')}
>
Highest value
</button>
<div className="inv-toolbar-left">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
type="button"
className={sort === 'value' ? 'active' : ''}
onClick={() => {
setSort('value');
saveInventorySort(user.id, 'value');
}}
>
Highest value
</button>
<button
type="button"
className={sort === 'recent' ? 'active' : ''}
onClick={() => {
setSort('recent');
saveInventorySort(user.id, 'recent');
}}
>
Most recent
</button>
</div>
{inventory.length > 0 && (
<span className="muted inv-count">
{inventory.length} item{inventory.length === 1 ? '' : 's'}
</span>
)}
</div>
<div className="row-actions">
{selected.length > 0 && (
<>
<span className="muted">
{selected.length} selected · {formatCredits(selectedValue)} cr
{selectedHasFavorite ? ' · includes favorites' : ''}
</span>
<button
type="button"
@@ -118,7 +278,33 @@ export default function Dashboard() {
>
Clear
</button>
<button type="button" className="btn" onClick={sell} disabled={busy}>
{!selectedAllFavorite && (
<button
type="button"
className="btn secondary"
onClick={() => setFavorite(true)}
disabled={busy}
>
Favorite
</button>
)}
{selectedItems.some((inv) => inv.favorite) && (
<button
type="button"
className="btn ghost"
onClick={() => setFavorite(false)}
disabled={busy}
>
Unfavorite
</button>
)}
<button
type="button"
className="btn"
onClick={sell}
disabled={busy || !selectedCanSell}
title={selectedHasFavorite ? 'Favorites cannot be sold' : undefined}
>
Sell
</button>
</>
@@ -126,6 +312,74 @@ export default function Dashboard() {
</div>
</div>
<div className="inv-bulk-sell">
{inventory.length > 0 && (
<>
<button
type="button"
className="btn"
disabled={busy || belowThreshold.length === 0}
onClick={sellBelow}
>
Sell below
</button>
<label className="inv-bulk-label">
<input
type="number"
min="0"
step="0.01"
value={maxValue}
disabled={busy || settingsBusy}
onChange={(e) => setMaxValue(e.target.value)}
onBlur={onThresholdBlur}
aria-label="Maximum market value in credits"
/>
<span className="muted">cr</span>
</label>
<span className="muted inv-bulk-preview">
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
{formatCredits(belowValue)} cr
</span>
</>
)}
{inventory.length === 0 && (
<label className="inv-bulk-label">
Threshold
<input
type="number"
min="0"
step="0.01"
value={maxValue}
disabled={busy || settingsBusy}
onChange={(e) => setMaxValue(e.target.value)}
onBlur={onThresholdBlur}
aria-label="Auto-sell threshold in credits"
/>
<span className="muted">cr</span>
</label>
)}
<div className="inv-auto-sell-box">
<label className="inv-auto-sell-toggle">
<input
type="checkbox"
checked={autoSellEnabled}
disabled={busy || settingsBusy}
onChange={(e) => onAutoSellToggle(e.target.checked)}
/>
<span>Auto-sell</span>
</label>
<span className="inv-auto-sell-at muted">
at{' '}
{formatCredits(
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
? Math.round(Number(maxValue) * 100)
: 0
)}{' '}
cr
</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{inventory.length === 0 ? (
<div className="empty">
@@ -136,16 +390,42 @@ export default function Dashboard() {
.
</div>
) : (
<div className="grid">
{sorted.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
<>
<div className="grid">
{pageItems.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
{totalPages > 1 && (
<div className="inv-pager">
<button
type="button"
className="btn ghost"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="muted">
Page {currentPage} / {totalPages}
</span>
<button
type="button"
className="btn ghost"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
</div>
)}
</>
)}
</section>
</div>
+4 -4
View File
@@ -63,7 +63,7 @@ export default function DuelListPage() {
minItemValue: Math.round(Number(form.minItemValue) * 100),
minPlayersToStart: Number(form.minPlayersToStart),
});
navigate(`/bet/1vx/${data.room.id}`);
navigate(`/battle/1vx/${data.room.id}`);
} catch (err) {
setError(err.message);
} finally {
@@ -82,7 +82,7 @@ export default function DuelListPage() {
<h1>1vX Case</h1>
<p className="muted">Create a room or join an open duel.</p>
</div>
<Link to="/bet" className="btn secondary">
<Link to="/battle" className="btn secondary">
Modes
</Link>
</div>
@@ -145,7 +145,7 @@ export default function DuelListPage() {
) : (
<div className="room-list">
{open.map((r) => (
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card">
<Link key={r.id} to={`/battle/1vx/${r.id}`} className="room-card">
<div className="room-title">Room #{r.id}</div>
<div className="muted">
by {r.creatorUsername} · {r.playerCount}/{r.maxPlayers} players ·{' '}
@@ -165,7 +165,7 @@ export default function DuelListPage() {
) : (
<div className="room-list">
{inProgress.map((r) => (
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card live">
<Link key={r.id} to={`/battle/1vx/${r.id}`} className="room-card live">
<div className="room-title">
Room #{r.id} <span className="badge">Live</span>
</div>
+25 -5
View File
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits, formatChance } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket';
export default function DuelRoomPage() {
@@ -22,6 +23,16 @@ export default function DuelRoomPage() {
setInventory(inv.inventory);
}, []);
const inventoryByValue = useMemo(
() =>
[...inventory].sort(
(a, b) =>
itemValueCents(b.item) - itemValueCents(a.item) ||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
),
[inventory]
);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
@@ -75,6 +86,14 @@ export default function DuelRoomPage() {
};
}, [user, id, loadInventory, refresh]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
return onInventoryChanged(() => {
loadInventory().catch(() => {});
refresh().catch(() => {});
});
}, [user, loadInventory, refresh]);
const isSpectator =
room &&
(room.status === 'in_progress' || room.status === 'spinning' || room.status === 'resolved') &&
@@ -176,7 +195,7 @@ export default function DuelRoomPage() {
{isSpectator ? ' · Spectating' : ''}
</p>
</div>
<Link to="/bet/1vx" className="btn secondary">
<Link to="/battle/1vx" className="btn secondary">
All rooms
</Link>
</div>
@@ -307,20 +326,21 @@ export default function DuelRoomPage() {
Deposit
</button>
</div>
{inventory.length === 0 ? (
{inventoryByValue.length === 0 ? (
<div className="empty">
No free items <Link to="/cases">open a case</Link>.
</div>
) : (
<div className="grid">
{inventory.map((inv) => (
{inventoryByValue.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
selected={selected.includes(inv.id)}
disabled={
(slotsLeft === 0 && !selected.includes(inv.id)) ||
inv.item.marketValue < (room.minItemValue || 0)
itemValueCents(inv.item) < (room.minItemValue || 0)
}
onClick={() => toggleSelect(inv.id)}
/>
+148 -24
View File
@@ -1,10 +1,69 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
const PODIUM_META = {
1: {
label: 'Champion',
bonus: 'Crown of the Forge',
placeClass: 'lb-place-1',
},
2: {
label: 'Challenger',
bonus: 'Silver Crest',
placeClass: 'lb-place-2',
},
3: {
label: 'Contender',
bonus: 'Bronze Mark',
placeClass: 'lb-place-3',
},
};
function PlayerAvatar({ username, avatarUrl, size = 'md' }) {
const initials = (username || '?').slice(0, 2).toUpperCase();
return (
<div className={`lb-avatar lb-avatar-${size}`} aria-hidden>
{avatarUrl ? <img src={avatarUrl} alt="" /> : <span>{initials}</span>}
</div>
);
}
function WealthBreakdown({ balance, inventoryValue, totalWealth, compact }) {
if (compact) {
return (
<div className="lb-wealth-compact">
<strong>{formatCredits(totalWealth)} cr</strong>
</div>
);
}
return (
<div className="lb-wealth">
<div className="lb-wealth-total">
<span className="lb-wealth-label">Total</span>
<strong>{formatCredits(totalWealth)} cr</strong>
</div>
<div className="lb-wealth-parts">
<span>
Balance <em>{formatCredits(balance)}</em>
</span>
<span>
Inventory <em>{formatCredits(inventoryValue)}</em>
</span>
</div>
</div>
);
}
export default function Leaderboard() {
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [error, setError] = useState('');
const openPlayer = (username) => {
navigate(`/player/${encodeURIComponent(username)}`);
};
useEffect(() => {
let cancelled = false;
(async () => {
@@ -20,40 +79,105 @@ export default function Leaderboard() {
};
}, []);
const top3 = rows.slice(0, 3);
const rest = rows.slice(3);
// Podium visual order: 2nd | 1st | 3rd
const podiumOrder = [top3[1], top3[0], top3[2]].filter(Boolean);
return (
<div>
<div className="lb-page">
<div className="section-head">
<div>
<h1>Leaderboard</h1>
<p className="muted">Richest players by balance + inventory value.</p>
</div>
</div>
{error && <div className="error">{error}</div>}
{rows.length === 0 && !error ? (
<div className="empty">No players yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>Balance</th>
<th>Inventory</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td className="rank">{row.rank}</td>
<td>{row.username}</td>
<td>{formatCredits(row.balance)}</td>
<td>{formatCredits(row.inventoryValue)}</td>
<td>{formatCredits(row.totalWealth)} cr</td>
</tr>
))}
</tbody>
</table>
<>
{top3.length > 0 && (
<section className="lb-podium" aria-label="Top three">
{podiumOrder.map((row) => {
const meta = PODIUM_META[row.rank];
return (
<button
key={row.id}
type="button"
className={`lb-podium-card ${meta.placeClass}`}
onClick={() => openPlayer(row.username)}
aria-label={`View ${row.username}'s profile, rank ${row.rank}`}
>
{row.rank === 1 && (
<span className="lb-fire" aria-hidden>
<span className="lb-fire-ring" />
</span>
)}
<div className="lb-podium-rank" aria-hidden>
#{row.rank}
</div>
<div className="lb-podium-frame">
<PlayerAvatar
username={row.username}
avatarUrl={row.avatarUrl}
size={row.rank === 1 ? 'xl' : 'lg'}
/>
</div>
<div className="lb-podium-title">{meta.label}</div>
<div className="lb-podium-name">@{row.username}</div>
<div className="lb-podium-bonus">{meta.bonus}</div>
<WealthBreakdown
balance={row.balance}
inventoryValue={row.inventoryValue}
totalWealth={row.totalWealth}
/>
</button>
);
})}
</section>
)}
{rest.length > 0 && (
<section className="lb-list" aria-label="Rankings">
<div className="lb-list-head" aria-hidden>
<span>Rank</span>
<span>Player</span>
<span className="lb-list-head-wealth">Total</span>
</div>
<ul className="lb-list-rows">
{rest.map((row) => (
<li key={row.id}>
<button
type="button"
className="lb-row"
onClick={() => openPlayer(row.username)}
aria-label={`View ${row.username}'s profile, rank ${row.rank}`}
>
<span className="lb-row-rank">{row.rank}</span>
<PlayerAvatar
username={row.username}
avatarUrl={row.avatarUrl}
size="sm"
/>
<span className="lb-row-name">@{row.username}</span>
<span className="lb-row-meta muted">
{row.itemCount} item{row.itemCount === 1 ? '' : 's'}
</span>
<WealthBreakdown
balance={row.balance}
inventoryValue={row.inventoryValue}
totalWealth={row.totalWealth}
compact
/>
</button>
</li>
))}
</ul>
</section>
)}
</>
)}
</div>
);
+44 -14
View File
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits, formatChance } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel';
import { useCountdown } from '../hooks/useCountdown';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket';
export default function MultiPlayersPage() {
@@ -22,7 +23,9 @@ export default function MultiPlayersPage() {
const [spinActive, setSpinActive] = useState(false);
const { label: timerLabel, remaining } = useCountdown(
round?.status === 'open' ? round.endsAt : null
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers
? round.endsAt
: null
);
const loadInventory = useCallback(async () => {
@@ -30,6 +33,16 @@ export default function MultiPlayersPage() {
setInventory(inv.inventory);
}, []);
const inventoryByValue = useMemo(
() =>
[...inventory].sort(
(a, b) =>
itemValueCents(b.item) - itemValueCents(a.item) ||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
),
[inventory]
);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
@@ -46,8 +59,6 @@ export default function MultiPlayersPage() {
const onState = (state) => {
setRound(state);
if (state?.status === 'open') {
setSpinning(false);
setReelWinnerItemId(null);
loadInventory().catch(() => {});
}
};
@@ -62,7 +73,6 @@ export default function MultiPlayersPage() {
};
const onResult = (state) => {
setRound(state);
setSpinning(false);
loadInventory().catch(() => {});
refresh().catch(() => {});
};
@@ -82,6 +92,14 @@ export default function MultiPlayersPage() {
};
}, [user, loadInventory, refresh]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
return onInventoryChanged(() => {
loadInventory().catch(() => {});
refresh().catch(() => {});
});
}, [user, loadInventory, refresh]);
const maxItems = round?.maxItemsPerPlayer || 3;
const myDeposits = useMemo(
() => (round?.deposits || []).filter((d) => d.userId === user?.id),
@@ -158,7 +176,7 @@ export default function MultiPlayersPage() {
<h1>MultiPlayers Case</h1>
<p className="muted">Live shared pot win chance = your value / total pot.</p>
</div>
<Link to="/bet" className="btn secondary">
<Link to="/battle" className="btn secondary">
Modes
</Link>
</div>
@@ -174,11 +192,24 @@ export default function MultiPlayersPage() {
<div className="timer-block">
<span className="muted">Timer</span>
<strong className="timer-digits">
{round?.status === 'open' ? timerLabel : round?.status === 'spinning' ? 'SPIN' : '—'}
{round?.status === 'spinning' || spinning
? 'SPIN'
: round?.waitingForPlayers
? 'WAIT'
: round?.status === 'open' && round?.endsAt
? timerLabel
: '—'}
</strong>
{round?.status === 'open' && remaining <= 5 && remaining > 0 && (
<span className="muted">closing</span>
{round?.waitingForPlayers && round?.status === 'open' && (
<span className="muted">
Need {round.minPlayers || 2} players ({round.players?.length || 0}/
{round.minPlayers || 2})
</span>
)}
{round?.status === 'open' &&
!round?.waitingForPlayers &&
remaining <= 5 &&
remaining > 0 && <span className="muted">closing</span>}
</div>
<div className="timer-block">
<span className="muted">Pot</span>
@@ -289,20 +320,19 @@ export default function MultiPlayersPage() {
Place bet
</button>
</div>
{inventory.length === 0 ? (
{inventoryByValue.length === 0 ? (
<div className="empty">
No free items <Link to="/cases">open a case</Link>.
</div>
) : (
<div className="grid">
{inventory.map((inv) => (
{inventoryByValue.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
selected={selected.includes(inv.id)}
disabled={
mySlotsLeft === 0 && !selected.includes(inv.id)
}
disabled={mySlotsLeft === 0 && !selected.includes(inv.id)}
onClick={() => toggleSelect(inv.id)}
/>
))}
+257
View File
@@ -0,0 +1,257 @@
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import ProfileCaseStats from '../components/ProfileCaseStats';
import ProfileCatalogBadges from '../components/ProfileCatalogBadges';
import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
import { Link, Navigate, useParams } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
const PAGE_SIZE = 50;
export default function PlayerProfilePage() {
const { username } = useParams();
const { user } = useAuth();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [sort, setSort] = useState('value');
const [page, setPage] = useState(1);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await api.playerProfile(username);
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, [username]);
const inventory = data?.inventory ?? [];
const sorted = useMemo(() => {
const list = [...inventory];
if (sort === 'value') {
list.sort(
(a, b) =>
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
}
return list;
}, [inventory, sort]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return sorted.slice(start, start + PAGE_SIZE);
}, [sorted, currentPage]);
useEffect(() => {
setPage(1);
}, [sort, username]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
if (user?.username === username) {
return <Navigate to="/profile" replace />;
}
if (error) {
return (
<div>
<div className="section-head">
<div>
<h1>Player profile</h1>
<p className="muted">@{username}</p>
</div>
</div>
<div className="error">{error}</div>
<Link to="/leaderboard" className="btn secondary" style={{ marginTop: '1rem' }}>
Back to leaderboard
</Link>
</div>
);
}
if (!data) return <p className="muted">Loading</p>;
const { user: player, profile } = data;
return (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>{player.username}</h1>
<p className="muted">Player profile</p>
</div>
<Link to="/leaderboard" className="btn ghost">
Leaderboard
</Link>
</div>
<div className="profile-layout">
<div className="profile-avatar-block admin-block">
<div className="avatar-preview">
{player.avatarUrl ? (
<img src={player.avatarUrl} alt="" />
) : (
<span>{player.username.slice(0, 2).toUpperCase()}</span>
)}
</div>
{player.bio ? (
<p style={{ marginTop: '0.75rem' }}>{player.bio}</p>
) : (
<p className="muted" style={{ marginTop: '0.75rem' }}>
No bio yet.
</p>
)}
<div className="muted" style={{ marginTop: '0.75rem' }}>
Balance {formatCredits(player.balance)} cr
</div>
</div>
<div className="profile-stats admin-block">
<h2>Stats</h2>
<ul className="stat-list">
<li>
Status{' '}
<strong
className={
profile.presence?.isConnected
? 'presence-online'
: 'presence-offline'
}
>
{profile.presence?.isConnected ? 'Online' : 'Offline'}
</strong>
</li>
<li>
Play time{' '}
<strong>{formatPlayTime(profile.presence?.playTimeSeconds)}</strong>
</li>
<li>
Last connection{' '}
<strong>
{formatLastConnection(profile.presence?.lastConnection)}
</strong>
</li>
<li>
Joined <strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
</li>
<li>
Inventory{' '}
<strong>
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
</strong>
</li>
<li>
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li>
</ul>
<ProfileAchievementBadges achievements={profile.achievements} />
<ProfileCatalogBadges
completed={profile.catalogCompleted}
luckPercent={profile.catalogLuckPercent}
/>
<ProfilePrestigeStats prestige={profile.prestige} />
{profile.caseOpens?.length > 0 && (
<div className="case-stats-block">
<h3>Opens by case</h3>
<ProfileCaseStats caseOpens={profile.caseOpens} />
</div>
)}
</div>
</div>
</section>
<section className="section">
<div className="section-head">
<div>
<h2>Inventory</h2>
<p className="muted">Items currently held by this player.</p>
</div>
</div>
{inventory.length > 0 && (
<div className="inv-toolbar">
<div className="inv-toolbar-left">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
type="button"
className={sort === 'value' ? 'active' : ''}
onClick={() => setSort('value')}
>
Highest value
</button>
<button
type="button"
className={sort === 'recent' ? 'active' : ''}
onClick={() => setSort('recent')}
>
Most recent
</button>
</div>
<span className="muted inv-count">
{inventory.length} item{inventory.length === 1 ? '' : 's'}
</span>
</div>
</div>
)}
{inventory.length === 0 ? (
<div className="empty">No items in inventory.</div>
) : (
<>
<div className="grid">
{pageItems.map((inv) => (
<ItemTile key={inv.id} item={inv.item} favorite={inv.favorite} />
))}
</div>
{totalPages > 1 && (
<div className="inv-pager">
<button
type="button"
className="btn ghost"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="muted">
Page {currentPage} / {totalPages}
</span>
<button
type="button"
className="btn ghost"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
</div>
)}
</>
)}
</section>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
const CHOICE_LABELS = {
chance: 'Chance',
gain: 'Gain',
key: 'Key progress',
};
export default function PrestigePage() {
const { user, loading, patchUser, refresh } = useAuth();
const [data, setData] = useState(null);
const [selected, setSelected] = useState(null);
const [confirmText, setConfirmText] = useState('');
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const load = async () => {
const result = await api.prestige();
setData(result);
};
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const result = await api.prestige();
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, [user?.id, user?.role]);
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
if (!data && !error) return <p className="muted">Loading</p>;
if (data && data.pageVisible === false) {
return <Navigate to="/dashboard" replace />;
}
const cost = data?.prestige?.costCents ?? 0;
const p = data?.prestige;
const unlockAt = data?.prestige?.visibleAtCents ?? 0;
const onPrestige = async () => {
if (!selected || confirmText !== 'PRESTIGE') return;
setBusy(true);
setError('');
setMessage('');
try {
const result = await api.doPrestige(selected);
patchUser(result.user);
await refresh();
setMessage(
`Prestige #${result.prestige.count} complete · chose ${CHOICE_LABELS[result.choice] || result.choice}. Account reset.`
);
setSelected(null);
setConfirmText('');
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<section className="section">
<div className="section-head">
<div>
<h1>Prestige</h1>
<p className="muted">
Pay {formatCredits(cost)} cr, pick one permanent boost, then reset balance, inventory,
keys, and catalog. History is kept. Unlocks at {formatCredits(unlockAt)} cr total wealth.
</p>
</div>
{p && (
<div className="prestige-count-pill">
Prestige <strong>{p.count}</strong>
</div>
)}
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{p && (
<div className="prestige-totals">
<div>
<span className="muted">Chance</span>
<strong>+{p.chanceBonus}%</strong>
</div>
<div>
<span className="muted">Gains</span>
<strong>+{p.gainBonus}%</strong>
</div>
<div>
<span className="muted">Key opens</span>
<strong>{p.keyOpenReduction}</strong>
</div>
</div>
)}
<div className="prestige-cost-row">
<span className="muted">Cost</span>
<strong className="gold">{formatCredits(cost)} cr</strong>
<span className="muted">Your balance</span>
<strong>{formatCredits(user.balance)} cr</strong>
</div>
{!data?.canAfford && (
<p className="muted">You need {formatCredits(cost)} cr to prestige.</p>
)}
{data?.lockedItems > 0 && (
<div className="error">
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles
before prestiging.
</div>
)}
<div className="prestige-choices">
{(data?.choices || []).map((c) => (
<button
key={c.id}
type="button"
className={`prestige-choice${selected === c.id ? ' selected' : ''}`}
onClick={() => setSelected(c.id)}
disabled={busy || !data?.canPrestige}
>
<h2>{c.title}</h2>
<p className="muted">{c.description}</p>
<div className="prestige-choice-meta">
<span>
Now <strong>+{c.current}{c.id === 'key' ? '' : '%'}</strong>
{c.id === 'key' ? ' opens reduced' : ''}
</span>
<span className="prestige-delta">
+{c.current + c.delta}
{c.id === 'key' ? '' : '%'}
</span>
</div>
</button>
))}
</div>
<div className="prestige-confirm admin-block">
<p>
This permanently spends {formatCredits(cost)} cr and wipes your balance, inventory, case
keys, and catalog. Type <strong>PRESTIGE</strong> to confirm.
</p>
<label>
Confirmation
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="PRESTIGE"
disabled={busy || !selected}
autoComplete="off"
/>
</label>
<button
type="button"
className="btn"
disabled={
busy || !data?.canPrestige || !selected || confirmText !== 'PRESTIGE'
}
onClick={onPrestige}
>
{busy ? 'Prestiging…' : 'Prestige now'}
</button>
</div>
{data?.history?.length > 0 && (
<div className="prestige-history">
<h2>History</h2>
<ul className="stat-list">
{data.history.map((h) => (
<li key={h.id}>
{CHOICE_LABELS[h.choice] || h.choice}{' '}
<span className="muted">
· {new Date(h.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
</div>
)}
</section>
);
}
+94 -25
View File
@@ -1,10 +1,15 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
import { useAuth } from '../AuthContext';
import ProfileCaseStats from '../components/ProfileCaseStats';
import ProfileCatalogBadges from '../components/ProfileCatalogBadges';
import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
export default function ProfilePage() {
const { user, loading, refresh } = useAuth();
const { user, loading, refresh, logout } = useAuth();
const navigate = useNavigate();
const [profile, setProfile] = useState(null);
const [bio, setBio] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
@@ -83,6 +88,17 @@ export default function ProfilePage() {
}
};
const onLogout = async () => {
setBusy(true);
try {
await logout();
navigate('/login', { replace: true });
} catch (err) {
setError(err.message);
setBusy(false);
}
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
@@ -95,6 +111,9 @@ export default function ProfilePage() {
<h1>Profile</h1>
<p className="muted">@{user.username}</p>
</div>
<Link to="/achievements" className="btn ghost">
Achievements
</Link>
</div>
{error && <div className="error">{error}</div>}
@@ -121,27 +140,67 @@ export default function ProfilePage() {
<div className="profile-stats admin-block">
<h2>Stats</h2>
{profile ? (
<ul className="stat-list">
<li>
Joined{' '}
<strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
</li>
<li>
Inventory{' '}
<strong>
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
</strong>
</li>
<li>
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li>
</ul>
<>
<ul className="stat-list">
<li>
Status{' '}
<strong
className={
profile.presence?.isConnected
? 'presence-online'
: 'presence-offline'
}
>
{profile.presence?.isConnected ? 'Online' : 'Offline'}
</strong>
</li>
<li>
Play time{' '}
<strong>{formatPlayTime(profile.presence?.playTimeSeconds)}</strong>
</li>
<li>
Last connection{' '}
<strong>
{formatLastConnection(profile.presence?.lastConnection)}
</strong>
</li>
<li>
Joined{' '}
<strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
</li>
<li>
Inventory{' '}
<strong>
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
</strong>
</li>
<li>
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li>
</ul>
<ProfileAchievementBadges
achievements={profile.achievements}
linkToAchievements
/>
<ProfileCatalogBadges
completed={profile.catalogCompleted}
luckPercent={profile.catalogLuckPercent}
linkToCatalog
/>
<ProfilePrestigeStats prestige={profile.prestige} />
{profile.caseOpens?.length > 0 && (
<div className="case-stats-block">
<h3>Opens by case</h3>
<ProfileCaseStats caseOpens={profile.caseOpens} />
</div>
)}
</>
) : (
<p className="muted">Loading stats</p>
)}
@@ -194,6 +253,16 @@ export default function ProfilePage() {
</button>
</form>
</section>
<section className="section admin-block">
<h2>Session</h2>
<p className="muted" style={{ marginBottom: '0.75rem' }}>
Sign out of CaseForge on this device.
</p>
<button type="button" className="btn danger" onClick={onLogout} disabled={busy}>
Logout
</button>
</section>
</div>
);
}
+16
View File
@@ -7,6 +7,7 @@ export default function Register() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
@@ -17,6 +18,10 @@ export default function Register() {
const onSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setBusy(true);
try {
await register(username, password);
@@ -56,6 +61,17 @@ export default function Register() {
minLength={6}
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
minLength={6}
/>
</label>
{error && <div className="error">{error}</div>}
<button className="btn" type="submit" disabled={busy}>
{busy ? 'Creating…' : 'Create account'}
+2 -2
View File
@@ -46,7 +46,7 @@ export default function ShopPage() {
}, [patchUser]);
useEffect(() => {
if (!user || user.role === 'admin') return;
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
@@ -58,7 +58,7 @@ export default function ShopPage() {
return () => {
cancelled = true;
};
}, [user, load]);
}, [user?.id, user?.role, load]);
const buy = async (packageId) => {
setBusy(true);