nice update
This commit is contained in:
+365
-107
@@ -3,6 +3,55 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
|
||||
const emptyItem = { name: '', rarity: 'MilSpec', marketValue: 100, imageUrl: '' };
|
||||
|
||||
function ImageField({ value, onChange, disabled }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const onFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const data = await api.adminUpload(file);
|
||||
onChange(data.url);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="image-field">
|
||||
{value ? (
|
||||
<div className="admin-thumb">
|
||||
<img src={value} alt="" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-thumb empty">No img</div>
|
||||
)}
|
||||
<input
|
||||
placeholder="Image URL or upload"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label className="btn secondary file-btn">
|
||||
{uploading ? '…' : 'Upload'}
|
||||
<input type="file" accept="image/*" hidden onChange={onFile} disabled={disabled || uploading} />
|
||||
</label>
|
||||
{value ? (
|
||||
<button type="button" className="btn ghost" onClick={() => onChange('')} disabled={disabled}>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const { user, loading, adminLogin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -15,13 +64,12 @@ export default function Admin() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [caseForm, setCaseForm] = useState({ name: '', price: 100, imageUrl: '', active: true });
|
||||
const [itemForm, setItemForm] = useState({
|
||||
name: '',
|
||||
rarity: 'MilSpec',
|
||||
marketValue: 100,
|
||||
imageUrl: '',
|
||||
});
|
||||
const [caseForm, setCaseForm] = useState(emptyCase);
|
||||
const [itemForm, setItemForm] = useState(emptyItem);
|
||||
const [editingCaseId, setEditingCaseId] = useState(null);
|
||||
const [editingItemId, setEditingItemId] = useState(null);
|
||||
const [caseEdit, setCaseEdit] = useState(null);
|
||||
const [itemEdit, setItemEdit] = useState(null);
|
||||
const [dropForm, setDropForm] = useState({ caseId: '', itemId: '', dropRate: 1 });
|
||||
|
||||
const load = async () => {
|
||||
@@ -39,8 +87,8 @@ export default function Admin() {
|
||||
if (!dropForm.itemId && i.items[0]) {
|
||||
setDropForm((f) => ({ ...f, itemId: String(i.items[0].id) }));
|
||||
}
|
||||
if (r.rarities.length && !itemForm.rarity) {
|
||||
setItemForm((f) => ({ ...f, rarity: r.rarities[0] }));
|
||||
if (r.rarities.length) {
|
||||
setItemForm((f) => ({ ...f, rarity: f.rarity || r.rarities[0] }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,6 +144,62 @@ export default function Admin() {
|
||||
);
|
||||
}
|
||||
|
||||
const startEditCase = (c) => {
|
||||
setEditingCaseId(c.id);
|
||||
setCaseEdit({
|
||||
name: c.name,
|
||||
price: c.price,
|
||||
imageUrl: c.imageUrl || '',
|
||||
active: c.active,
|
||||
});
|
||||
};
|
||||
|
||||
const saveCaseEdit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.updateCase(editingCaseId, {
|
||||
name: caseEdit.name,
|
||||
price: Number(caseEdit.price),
|
||||
imageUrl: caseEdit.imageUrl,
|
||||
active: caseEdit.active,
|
||||
});
|
||||
setEditingCaseId(null);
|
||||
setCaseEdit(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditItem = (item) => {
|
||||
setEditingItemId(item.id);
|
||||
setItemEdit({
|
||||
name: item.name,
|
||||
rarity: item.rarity,
|
||||
marketValue: item.marketValue,
|
||||
imageUrl: item.imageUrl || '',
|
||||
});
|
||||
};
|
||||
|
||||
const saveItemEdit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.updateItem(editingItemId, {
|
||||
name: itemEdit.name,
|
||||
rarity: itemEdit.rarity,
|
||||
marketValue: Number(itemEdit.marketValue),
|
||||
imageUrl: itemEdit.imageUrl,
|
||||
});
|
||||
setEditingItemId(null);
|
||||
setItemEdit(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main" style={{ paddingTop: '1.5rem' }}>
|
||||
<header className="topnav" style={{ margin: '-1.5rem -1.5rem 1.5rem', position: 'static' }}>
|
||||
@@ -117,7 +221,7 @@ export default function Admin() {
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Admin panel</h1>
|
||||
<p className="muted">Cases, loot pool, and drop weights.</p>
|
||||
<p className="muted">Cases, loot pool, images, and drop weights.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +245,7 @@ export default function Admin() {
|
||||
<div className="admin-block">
|
||||
<h3>Create case</h3>
|
||||
<form
|
||||
className="admin-row"
|
||||
className="form"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -149,66 +253,135 @@ export default function Admin() {
|
||||
...caseForm,
|
||||
price: Number(caseForm.price),
|
||||
});
|
||||
setCaseForm({ name: '', price: 100, imageUrl: '', active: true });
|
||||
setCaseForm(emptyCase);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={caseForm.name}
|
||||
onChange={(e) => setCaseForm({ ...caseForm, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Price (cents)"
|
||||
value={caseForm.price}
|
||||
onChange={(e) => setCaseForm({ ...caseForm, price: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="Image URL"
|
||||
value={caseForm.imageUrl}
|
||||
onChange={(e) => setCaseForm({ ...caseForm, imageUrl: e.target.value })}
|
||||
/>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={caseForm.name}
|
||||
onChange={(e) => setCaseForm({ ...caseForm, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Price (cents)
|
||||
<input
|
||||
type="number"
|
||||
value={caseForm.price}
|
||||
onChange={(e) => setCaseForm({ ...caseForm, price: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Image
|
||||
<ImageField
|
||||
value={caseForm.imageUrl}
|
||||
onChange={(imageUrl) => setCaseForm({ ...caseForm, imageUrl })}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit">
|
||||
Add
|
||||
Add case
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{cases.map((c) => (
|
||||
<div key={c.id} className="admin-block">
|
||||
<div className="admin-row">
|
||||
<strong>{c.name}</strong>
|
||||
<span className="muted">{formatCredits(c.price)} cr</span>
|
||||
<span className="muted">{c.active ? 'active' : 'inactive'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={async () => {
|
||||
await api.updateCase(c.id, { active: !c.active });
|
||||
await load();
|
||||
}}
|
||||
>
|
||||
{c.active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete case ${c.name}?`)) return;
|
||||
await api.deleteCase(c.id);
|
||||
await load();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div className="muted">{c.items.length} drops configured</div>
|
||||
{editingCaseId === c.id && caseEdit ? (
|
||||
<form className="form" onSubmit={saveCaseEdit}>
|
||||
<h3>Edit case #{c.id}</h3>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={caseEdit.name}
|
||||
onChange={(e) => setCaseEdit({ ...caseEdit, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Price (cents)
|
||||
<input
|
||||
type="number"
|
||||
value={caseEdit.price}
|
||||
onChange={(e) => setCaseEdit({ ...caseEdit, price: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Image
|
||||
<ImageField
|
||||
value={caseEdit.imageUrl}
|
||||
onChange={(imageUrl) => setCaseEdit({ ...caseEdit, imageUrl })}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={caseEdit.active}
|
||||
onChange={(e) => setCaseEdit({ ...caseEdit, active: e.target.checked })}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<div className="row-actions">
|
||||
<button className="btn" type="submit">
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setEditingCaseId(null);
|
||||
setCaseEdit(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-row">
|
||||
{c.imageUrl ? (
|
||||
<div className="admin-thumb">
|
||||
<img src={c.imageUrl} alt="" />
|
||||
</div>
|
||||
) : null}
|
||||
<strong>{c.name}</strong>
|
||||
<span className="muted">{formatCredits(c.price)} cr</span>
|
||||
<span className="muted">{c.active ? 'active' : 'inactive'}</span>
|
||||
<button type="button" className="btn secondary" onClick={() => startEditCase(c)}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={async () => {
|
||||
await api.updateCase(c.id, { active: !c.active });
|
||||
await load();
|
||||
}}
|
||||
>
|
||||
{c.active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete case ${c.name}?`)) return;
|
||||
await api.deleteCase(c.id);
|
||||
await load();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div className="muted">{c.items.length} drops configured</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -219,7 +392,7 @@ export default function Admin() {
|
||||
<div className="admin-block">
|
||||
<h3>Create item</h3>
|
||||
<form
|
||||
className="admin-row"
|
||||
className="form"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -228,10 +401,8 @@ export default function Admin() {
|
||||
marketValue: Number(itemForm.marketValue),
|
||||
});
|
||||
setItemForm({
|
||||
name: '',
|
||||
...emptyItem,
|
||||
rarity: rarities[0] || 'MilSpec',
|
||||
marketValue: 100,
|
||||
imageUrl: '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -239,55 +410,139 @@ export default function Admin() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={itemForm.name}
|
||||
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={itemForm.rarity}
|
||||
onChange={(e) => setItemForm({ ...itemForm, rarity: e.target.value })}
|
||||
>
|
||||
{rarities.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Value (cents)"
|
||||
value={itemForm.marketValue}
|
||||
onChange={(e) => setItemForm({ ...itemForm, marketValue: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={itemForm.name}
|
||||
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Rarity
|
||||
<select
|
||||
value={itemForm.rarity}
|
||||
onChange={(e) => setItemForm({ ...itemForm, rarity: e.target.value })}
|
||||
>
|
||||
{rarities.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value (cents)
|
||||
<input
|
||||
type="number"
|
||||
value={itemForm.marketValue}
|
||||
onChange={(e) => setItemForm({ ...itemForm, marketValue: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Image
|
||||
<ImageField
|
||||
value={itemForm.imageUrl}
|
||||
onChange={(imageUrl) => setItemForm({ ...itemForm, imageUrl })}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit">
|
||||
Add
|
||||
Add item
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-block">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="admin-row">
|
||||
<span>
|
||||
{item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete ${item.name}?`)) return;
|
||||
await api.deleteItem(item.id);
|
||||
await load();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="admin-block">
|
||||
{editingItemId === item.id && itemEdit ? (
|
||||
<form className="form" onSubmit={saveItemEdit}>
|
||||
<h3>Edit item #{item.id}</h3>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={itemEdit.name}
|
||||
onChange={(e) => setItemEdit({ ...itemEdit, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Rarity
|
||||
<select
|
||||
value={itemEdit.rarity}
|
||||
onChange={(e) => setItemEdit({ ...itemEdit, rarity: e.target.value })}
|
||||
>
|
||||
{rarities.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value (cents)
|
||||
<input
|
||||
type="number"
|
||||
value={itemEdit.marketValue}
|
||||
onChange={(e) => setItemEdit({ ...itemEdit, marketValue: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Image
|
||||
<ImageField
|
||||
value={itemEdit.imageUrl}
|
||||
onChange={(imageUrl) => setItemEdit({ ...itemEdit, imageUrl })}
|
||||
/>
|
||||
</label>
|
||||
<div className="row-actions">
|
||||
<button className="btn" type="submit">
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setEditingItemId(null);
|
||||
setItemEdit(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="admin-row">
|
||||
{item.imageUrl ? (
|
||||
<div className="admin-thumb">
|
||||
<img src={item.imageUrl} alt="" />
|
||||
</div>
|
||||
) : null}
|
||||
<span>
|
||||
{item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => startEditItem(item)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete ${item.name}?`)) return;
|
||||
await api.deleteItem(item.id);
|
||||
await load();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -352,7 +607,10 @@ export default function Admin() {
|
||||
{c.items.length === 0 && <div className="muted">No drops yet.</div>}
|
||||
{c.items.map((ci) => (
|
||||
<div key={ci.id} className="drop-row">
|
||||
<span>
|
||||
<span className="drop-item-cell">
|
||||
{ci.item.imageUrl ? (
|
||||
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
|
||||
) : null}
|
||||
{ci.item.name} ({ci.item.rarity})
|
||||
</span>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -60,8 +60,8 @@ export default function CasePage() {
|
||||
|
||||
return (
|
||||
<div className="case-page">
|
||||
<Link to="/dashboard" className="muted">
|
||||
← Back to dashboard
|
||||
<Link to="/cases" className="muted">
|
||||
← Back to cases
|
||||
</Link>
|
||||
|
||||
{!crate && !error && <p className="muted">Loading case…</p>}
|
||||
@@ -76,12 +76,12 @@ export default function CasePage() {
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`case-icon ${canOpen ? '' : 'disabled'}`}
|
||||
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
|
||||
onClick={openCase}
|
||||
disabled={!canOpen}
|
||||
title="Open case"
|
||||
>
|
||||
▣
|
||||
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
|
||||
{spinning ? 'Opening…' : `Open for ${formatCredits(crate.price)} cr`}
|
||||
@@ -105,6 +105,9 @@ export default function CasePage() {
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function CasesPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const [cases, setCases] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const cs = await api.cases();
|
||||
if (!cancelled) setCases(cs.cases);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
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>Cases</h1>
|
||||
<p className="muted">Pick a crate to open.</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="grid case-grid">
|
||||
{cases.map((c) => (
|
||||
<Link key={c.id} to={`/cases/${c.id}`} className="case-tile">
|
||||
<div className={`case-visual${c.imageUrl ? ' has-img' : ''}`} style={{ color: 'var(--accent)' }}>
|
||||
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
|
||||
</div>
|
||||
<div className="case-name">{c.name}</div>
|
||||
<div className="case-meta">{formatCredits(c.price)} cr</div>
|
||||
<div className="case-meta">{c.items.length} possible drops</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{cases.length === 0 && !error && <div className="empty">No active cases.</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function CoinflipStub() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Coinflip</h1>
|
||||
<p className="muted">1v1 item flip — coming soon.</p>
|
||||
</div>
|
||||
<Link to="/bet" 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">
|
||||
MultiPlayers
|
||||
</Link>
|
||||
<Link to="/bet/1vx" className="btn secondary">
|
||||
1vX Case
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+106
-33
@@ -1,25 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [cases, setCases] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [sort, setSort] = useState('recent');
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const inv = await api.inventory();
|
||||
setInventory(inv.inventory);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [inv, cs] = await Promise.all([api.inventory(), api.cases()]);
|
||||
if (!cancelled) {
|
||||
setInventory(inv.inventory);
|
||||
setCases(cs.cases);
|
||||
}
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
@@ -29,6 +32,46 @@ export default function Dashboard() {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...inventory];
|
||||
if (sort === 'value') {
|
||||
list.sort((a, b) => b.item.marketValue - a.item.marketValue);
|
||||
} else {
|
||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||
}
|
||||
return list;
|
||||
}, [inventory, sort]);
|
||||
|
||||
const selectedValue = useMemo(
|
||||
() =>
|
||||
inventory
|
||||
.filter((inv) => selected.includes(inv.id))
|
||||
.reduce((sum, inv) => sum + inv.item.marketValue, 0),
|
||||
[inventory, selected]
|
||||
);
|
||||
|
||||
const toggle = (id) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const sell = async () => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.sellInventory(selected);
|
||||
patchUser({ balance: data.user.balance });
|
||||
setSelected([]);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
@@ -39,42 +82,72 @@ export default function Dashboard() {
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Inventory</h1>
|
||||
<p className="muted">Your drops and loot.</p>
|
||||
<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>
|
||||
<div className="row-actions">
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span className="muted">
|
||||
{selected.length} selected · {formatCredits(selectedValue)} cr
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setSelected([])}
|
||||
disabled={busy}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={sell} disabled={busy}>
|
||||
Sell
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">Empty for now — open a case below.</div>
|
||||
<div className="empty">
|
||||
Empty for now —{' '}
|
||||
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
||||
open a case
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{inventory.map((inv) => (
|
||||
<ItemTile key={inv.id} item={inv.item} />
|
||||
{sorted.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Cases</h2>
|
||||
<p className="muted">Pick a crate to open.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid case-grid">
|
||||
{cases.map((c) => (
|
||||
<Link key={c.id} to={`/cases/${c.id}`} className="case-tile">
|
||||
<div className="case-visual" style={{ color: 'var(--accent)' }}>
|
||||
▣
|
||||
</div>
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
const defaults = {
|
||||
maxPlayers: 4,
|
||||
maxItemsPerPlayer: 3,
|
||||
minItemValue: 0,
|
||||
minPlayersToStart: 2,
|
||||
};
|
||||
|
||||
export default function DuelListPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState([]);
|
||||
const [inProgress, setInProgress] = useState([]);
|
||||
const [form, setForm] = useState(defaults);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const list = await api.duels();
|
||||
if (!cancelled) {
|
||||
setOpen(list.open || []);
|
||||
setInProgress(list.inProgress || []);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const onList = (list) => {
|
||||
setOpen(list.open || []);
|
||||
setInProgress(list.inProgress || []);
|
||||
};
|
||||
socket.emit('duels:subscribe');
|
||||
socket.on('duels:list', onList);
|
||||
socket.on('connect', () => socket.emit('duels:subscribe'));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('duels:unsubscribe');
|
||||
socket.off('duels:list', onList);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const create = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.createDuel({
|
||||
maxPlayers: Number(form.maxPlayers),
|
||||
maxItemsPerPlayer: Number(form.maxItemsPerPlayer),
|
||||
minItemValue: Math.round(Number(form.minItemValue) * 100),
|
||||
minPlayersToStart: Number(form.minPlayersToStart),
|
||||
});
|
||||
navigate(`/bet/1vx/${data.room.id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>1vX Case</h1>
|
||||
<p className="muted">Create a room or join an open duel.</p>
|
||||
</div>
|
||||
<Link to="/bet" className="btn secondary">
|
||||
Modes
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<section className="section admin-block">
|
||||
<h2>Create room</h2>
|
||||
<form className="form form-inline" onSubmit={create}>
|
||||
<label>
|
||||
Max players (2–10)
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={10}
|
||||
value={form.maxPlayers}
|
||||
onChange={(e) => setForm({ ...form, maxPlayers: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Max items / player (1–10)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={form.maxItemsPerPlayer}
|
||||
onChange={(e) => setForm({ ...form, maxItemsPerPlayer: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Min item value (cr)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={form.minItemValue}
|
||||
onChange={(e) => setForm({ ...form, minItemValue: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Min players to start
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={10}
|
||||
value={form.minPlayersToStart}
|
||||
onChange={(e) => setForm({ ...form, minPlayersToStart: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Open rooms</h2>
|
||||
{open.length === 0 ? (
|
||||
<div className="empty">No open rooms.</div>
|
||||
) : (
|
||||
<div className="room-list">
|
||||
{open.map((r) => (
|
||||
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card">
|
||||
<div className="room-title">Room #{r.id}</div>
|
||||
<div className="muted">
|
||||
by {r.creatorUsername} · {r.playerCount}/{r.maxPlayers} players ·{' '}
|
||||
{r.maxItemsPerPlayer} items max · min {formatCredits(r.minItemValue)} cr
|
||||
</div>
|
||||
<div className="muted">Pot {formatCredits(r.totalValue)} cr</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>In progress</h2>
|
||||
{inProgress.length === 0 ? (
|
||||
<div className="empty">No live rooms.</div>
|
||||
) : (
|
||||
<div className="room-list">
|
||||
{inProgress.map((r) => (
|
||||
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card live">
|
||||
<div className="room-title">
|
||||
Room #{r.id} <span className="badge">Live</span>
|
||||
</div>
|
||||
<div className="muted">
|
||||
{r.playerCount} players · pot {formatCredits(r.totalValue)} cr · Spectate
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
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 CaseReel from '../components/CaseReel';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
export default function DuelRoomPage() {
|
||||
const { id } = useParams();
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [room, setRoom] = useState(null);
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
|
||||
|
||||
const loadInventory = useCallback(async () => {
|
||||
const inv = await api.inventory();
|
||||
setInventory(inv.inventory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [data] = await Promise.all([api.duel(id), loadInventory()]);
|
||||
if (!cancelled) setRoom(data.room);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const roomId = Number(id);
|
||||
|
||||
const onState = (state) => {
|
||||
if (Number(state.id) !== roomId) return;
|
||||
setRoom(state);
|
||||
if (state.status === 'open' || state.status === 'resolved') {
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
}
|
||||
};
|
||||
const onSpin = (state) => {
|
||||
if (Number(state.id) !== roomId) return;
|
||||
setRoom(state);
|
||||
setSpinning(true);
|
||||
setReelWinnerItemId(state.reelWinnerItemId);
|
||||
};
|
||||
const onResult = (state) => {
|
||||
if (Number(state.id) !== roomId) return;
|
||||
setRoom(state);
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
};
|
||||
|
||||
socket.emit('duel:subscribe', roomId);
|
||||
socket.on('duel:state', onState);
|
||||
socket.on('duel:spin', onSpin);
|
||||
socket.on('duel:result', onResult);
|
||||
socket.on('connect', () => socket.emit('duel:subscribe', roomId));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('duel:unsubscribe', roomId);
|
||||
socket.off('duel:state', onState);
|
||||
socket.off('duel:spin', onSpin);
|
||||
socket.off('duel:result', onResult);
|
||||
};
|
||||
}, [user, id, loadInventory, refresh]);
|
||||
|
||||
const isSpectator =
|
||||
room &&
|
||||
(room.status === 'in_progress' || room.status === 'spinning' || room.status === 'resolved') &&
|
||||
!(room.deposits || []).some((d) => d.userId === user?.id);
|
||||
|
||||
const myDeposits = useMemo(
|
||||
() => (room?.deposits || []).filter((d) => d.userId === user?.id),
|
||||
[room, user]
|
||||
);
|
||||
const slotsLeft = Math.max(0, (room?.maxItemsPerPlayer || 0) - myDeposits.length);
|
||||
const potItems = useMemo(
|
||||
() =>
|
||||
(room?.deposits || [])
|
||||
.filter((d) => d.item)
|
||||
.map((d) => ({ ...d.item, id: d.id })),
|
||||
[room]
|
||||
);
|
||||
const canBet = room?.status === 'open';
|
||||
const isCreator = room?.creatorId === user?.id;
|
||||
|
||||
const toggleSelect = (invId) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(invId)) return prev.filter((x) => x !== invId);
|
||||
if (prev.length >= slotsLeft) return prev;
|
||||
return [...prev, invId];
|
||||
});
|
||||
};
|
||||
|
||||
const placeBet = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.duelBet(id, selected);
|
||||
setRoom(data.room);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const leave = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.duelLeave(id);
|
||||
setRoom(data.room);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.duelCancel(id);
|
||||
setRoom(data.room);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.duelStart(id);
|
||||
setRoom(data.room);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
if (!room && !error) return <p className="muted">Loading room…</p>;
|
||||
|
||||
return (
|
||||
<div className="bet-page">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Room #{id}</h1>
|
||||
<p className="muted">
|
||||
{room?.maxPlayers} players max · {room?.maxItemsPerPlayer} items · min{' '}
|
||||
{formatCredits(room?.minItemValue || 0)} cr
|
||||
{isSpectator ? ' · Spectating' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/bet/1vx" className="btn secondary">
|
||||
All rooms
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="jackpot-status">
|
||||
<div className="timer-block">
|
||||
<span className="muted">Status</span>
|
||||
<span className={`status-pill ${room?.status || ''}`}>{room?.status}</span>
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Pot</span>
|
||||
<strong className="gold">{formatCredits(room?.totalValue || 0)} cr</strong>
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Players</span>
|
||||
<strong>
|
||||
{room?.playerCount || 0}/{room?.maxPlayers}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(spinning || room?.status === 'spinning') && potItems.length > 0 && (
|
||||
<div className="section">
|
||||
<CaseReel
|
||||
items={potItems}
|
||||
winnerId={reelWinnerItemId || potItems[0]?.id}
|
||||
spinning={spinning || room?.status === 'spinning'}
|
||||
onDone={() => {}}
|
||||
/>
|
||||
{room?.winnerUsername && (
|
||||
<div className="won-banner" style={{ marginTop: '0.75rem' }}>
|
||||
Winner: <strong>{room.winnerUsername}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{room?.status === 'resolved' && room.winnerUsername && (
|
||||
<div className="won-banner section">
|
||||
Winner: <strong>{room.winnerUsername}</strong> took the pot (
|
||||
{formatCredits(room.totalValue)} cr)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="section">
|
||||
<h2>Players & chances</h2>
|
||||
{(room?.players || []).length === 0 ? (
|
||||
<div className="empty">No deposits yet.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Value</th>
|
||||
<th>Items</th>
|
||||
<th>Chance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{room.players.map((p) => (
|
||||
<tr key={p.userId} className={p.userId === user.id ? 'row-me' : ''}>
|
||||
<td>{p.username}</td>
|
||||
<td>{formatCredits(p.valueCents)} cr</td>
|
||||
<td>{p.itemCount}</td>
|
||||
<td>{formatChance(p.chance)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Deposited items</h2>
|
||||
{(room?.deposits || []).length === 0 ? (
|
||||
<div className="empty">Empty.</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{room.deposits.map((d) => (
|
||||
<div key={d.id} className="deposit-wrap">
|
||||
<ItemTile item={d.item} />
|
||||
<div className="deposit-owner muted">{d.username}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{canBet && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Your bets</h2>
|
||||
<p className="muted">
|
||||
{myDeposits.length}/{room.maxItemsPerPlayer} items · {slotsLeft} slot
|
||||
{slotsLeft === 1 ? '' : 's'} left
|
||||
</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
{myDeposits.length > 0 && (
|
||||
<button type="button" className="btn danger" disabled={busy} onClick={leave}>
|
||||
Leave / withdraw
|
||||
</button>
|
||||
)}
|
||||
{isCreator && (
|
||||
<>
|
||||
<button type="button" className="btn secondary" disabled={busy} onClick={cancel}>
|
||||
Cancel room
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={start}>
|
||||
Start now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-head">
|
||||
<p className="muted">Select items to bet</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !selected.length || slotsLeft === 0}
|
||||
onClick={placeBet}
|
||||
>
|
||||
Deposit
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">
|
||||
No free items — <Link to="/cases">open a case</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{inventory.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
selected={selected.includes(inv.id)}
|
||||
disabled={
|
||||
(slotsLeft === 0 && !selected.includes(inv.id)) ||
|
||||
inv.item.marketValue < (room.minItemValue || 0)
|
||||
}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
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 CaseReel from '../components/CaseReel';
|
||||
import { useCountdown } from '../hooks/useCountdown';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
export default function MultiPlayersPage() {
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [round, setRound] = useState(null);
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
|
||||
const [lastWinner, setLastWinner] = useState(null);
|
||||
const [revealWinner, setRevealWinner] = useState(false);
|
||||
const [spinDeposits, setSpinDeposits] = useState([]);
|
||||
const [spinActive, setSpinActive] = useState(false);
|
||||
|
||||
const { label: timerLabel, remaining } = useCountdown(
|
||||
round?.status === 'open' ? round.endsAt : null
|
||||
);
|
||||
|
||||
const loadInventory = useCallback(async () => {
|
||||
const inv = await api.inventory();
|
||||
setInventory(inv.inventory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [jp] = await Promise.all([api.jackpot(), loadInventory()]);
|
||||
if (!cancelled) setRound(jp.round);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const onState = (state) => {
|
||||
setRound(state);
|
||||
if (state?.status === 'open') {
|
||||
setSpinning(false);
|
||||
setReelWinnerItemId(null);
|
||||
loadInventory().catch(() => {});
|
||||
}
|
||||
};
|
||||
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);
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
};
|
||||
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.on('jackpot:state', onState);
|
||||
socket.on('jackpot:spin', onSpin);
|
||||
socket.on('jackpot:result', onResult);
|
||||
socket.on('connect', () => socket.emit('jackpot:subscribe'));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.off('jackpot:state', onState);
|
||||
socket.off('jackpot:spin', onSpin);
|
||||
socket.off('jackpot:result', onResult);
|
||||
};
|
||||
}, [user, loadInventory, refresh]);
|
||||
|
||||
const maxItems = round?.maxItemsPerPlayer || 3;
|
||||
const myDeposits = useMemo(
|
||||
() => (round?.deposits || []).filter((d) => d.userId === user?.id),
|
||||
[round, user]
|
||||
);
|
||||
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
|
||||
|
||||
const potItems = useMemo(() => {
|
||||
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
|
||||
return source
|
||||
.filter((d) => d.item)
|
||||
.map((d) => ({
|
||||
...d.item,
|
||||
id: d.id,
|
||||
ownerUsername: d.username,
|
||||
ownerAvatarUrl: d.avatarUrl || '',
|
||||
}));
|
||||
}, [round, spinDeposits, spinActive]);
|
||||
|
||||
const showReel = spinActive && potItems.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealWinner) return undefined;
|
||||
const t = setTimeout(() => setSpinActive(false), 5000);
|
||||
return () => clearTimeout(t);
|
||||
}, [revealWinner]);
|
||||
|
||||
const toggleSelect = (invId) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(invId)) return prev.filter((id) => id !== invId);
|
||||
if (prev.length >= mySlotsLeft) return prev;
|
||||
return [...prev, invId];
|
||||
});
|
||||
};
|
||||
|
||||
const placeBet = async () => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.jackpotBet(selected);
|
||||
setRound(data.round);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const withdraw = async (inventoryItemIds) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.jackpotWithdraw(inventoryItemIds);
|
||||
setRound(data.round);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
return (
|
||||
<div className="bet-page">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>MultiPlayers Case</h1>
|
||||
<p className="muted">Live shared pot — win chance = your value / total pot.</p>
|
||||
</div>
|
||||
<Link to="/bet" className="btn secondary">
|
||||
Modes
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="jackpot-status">
|
||||
<div className="timer-block">
|
||||
<span className="muted">Round</span>
|
||||
<strong>#{round?.id || '—'}</strong>
|
||||
<span className={`status-pill ${round?.status || ''}`}>{round?.status || '…'}</span>
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Timer</span>
|
||||
<strong className="timer-digits">
|
||||
{round?.status === 'open' ? timerLabel : round?.status === 'spinning' ? 'SPIN' : '—'}
|
||||
</strong>
|
||||
{round?.status === 'open' && remaining <= 5 && remaining > 0 && (
|
||||
<span className="muted">closing…</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Pot</span>
|
||||
<strong className="gold">{formatCredits(round?.totalValue || 0)} cr</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showReel && (
|
||||
<div className="section">
|
||||
<CaseReel
|
||||
items={potItems}
|
||||
winnerId={reelWinnerItemId || potItems[0]?.id}
|
||||
spinning={spinning || round?.status === 'spinning'}
|
||||
showOwners
|
||||
onDone={() => {
|
||||
setSpinning(false);
|
||||
setRevealWinner(true);
|
||||
}}
|
||||
/>
|
||||
{revealWinner && lastWinner && (
|
||||
<div className="won-banner" style={{ marginTop: '0.75rem' }}>
|
||||
Winner: <strong>{lastWinner}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!showReel && revealWinner && lastWinner && (
|
||||
<div className="won-banner section">
|
||||
Winner: <strong>{lastWinner}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="section">
|
||||
<h2>Players</h2>
|
||||
{(round?.players || []).length === 0 ? (
|
||||
<div className="empty">No bets yet — be the first.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Value</th>
|
||||
<th>Items</th>
|
||||
<th>Chance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{round.players.map((p) => (
|
||||
<tr key={p.userId} className={p.userId === user.id ? 'row-me' : ''}>
|
||||
<td>{p.username}</td>
|
||||
<td>{formatCredits(p.valueCents)} cr</td>
|
||||
<td>{p.itemCount}</td>
|
||||
<td>{formatChance(p.chance)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Pot items</h2>
|
||||
{(round?.deposits || []).length === 0 ? (
|
||||
<div className="empty">Empty pot.</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{round.deposits.map((d) => (
|
||||
<div key={d.id} className="deposit-wrap">
|
||||
<ItemTile item={d.item} />
|
||||
<div className="deposit-owner muted">{d.username}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{myDeposits.length > 0 && round?.status === 'open' && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<h2>Your bets ({myDeposits.length}/{maxItems})</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
disabled={busy}
|
||||
onClick={() => withdraw(myDeposits.map((d) => d.inventoryItemId))}
|
||||
>
|
||||
Withdraw all
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{round?.status === 'open' && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Bet from inventory</h2>
|
||||
<p className="muted">
|
||||
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !selected.length || mySlotsLeft === 0}
|
||||
onClick={placeBet}
|
||||
>
|
||||
Place bet
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">
|
||||
No free items — <Link to="/cases">open a case</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{inventory.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
selected={selected.includes(inv.id)}
|
||||
disabled={
|
||||
mySlotsLeft === 0 && !selected.includes(inv.id)
|
||||
}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [bio, setBio] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.profile();
|
||||
if (!cancelled) {
|
||||
setProfile(data.profile);
|
||||
setBio(data.user.bio || '');
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const saveBio = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await api.updateProfile({ bio });
|
||||
await refresh();
|
||||
setMessage('Profile updated.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await api.changePassword(currentPassword, newPassword);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setMessage('Password changed.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAvatar = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await api.uploadAvatar(file);
|
||||
await refresh();
|
||||
setMessage('Avatar updated.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Profile</h1>
|
||||
<p className="muted">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="ok-msg">{message}</div>}
|
||||
|
||||
<div className="profile-layout">
|
||||
<div className="profile-avatar-block admin-block">
|
||||
<div className="avatar-preview">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{user.username.slice(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="btn secondary file-btn">
|
||||
Change photo
|
||||
<input type="file" accept="image/*" hidden onChange={onAvatar} disabled={busy} />
|
||||
</label>
|
||||
<div className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
Balance {formatCredits(user.balance)} cr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<p className="muted">Loading stats…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section admin-block">
|
||||
<h2>Bio</h2>
|
||||
<form className="form" onSubmit={saveBio}>
|
||||
<label>
|
||||
About you
|
||||
<textarea
|
||||
rows={3}
|
||||
maxLength={280}
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Save bio
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="section admin-block">
|
||||
<h2>Change password</h2>
|
||||
<form className="form" onSubmit={changePassword}>
|
||||
<label>
|
||||
Current password
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Update password
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
function useAdCountdown(nextAdAt) {
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nextAdAt) {
|
||||
setRemaining(0);
|
||||
return undefined;
|
||||
}
|
||||
const tick = () => {
|
||||
const ms = new Date(nextAdAt).getTime() - Date.now();
|
||||
setRemaining(Math.max(0, Math.ceil(ms / 1000)));
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 250);
|
||||
return () => clearInterval(id);
|
||||
}, [nextAdAt]);
|
||||
|
||||
return remaining;
|
||||
}
|
||||
|
||||
export default function ShopPage() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [packages, setPackages] = useState([]);
|
||||
const [ad, setAd] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [watching, setWatching] = useState(false);
|
||||
|
||||
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.shop();
|
||||
setPackages(data.packages || []);
|
||||
setAd(data.ad);
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
lastAdAt: data.user.lastAdAt,
|
||||
});
|
||||
}, [patchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user, load]);
|
||||
|
||||
const buy = async (packageId) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const data = await api.shopBuy(packageId);
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
});
|
||||
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const watchAd = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
setWatching(true);
|
||||
// Simulated short ad
|
||||
await new Promise((r) => setTimeout(r, 1800));
|
||||
try {
|
||||
const data = await api.shopAd();
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
lastAdAt: data.user.lastAdAt,
|
||||
});
|
||||
setAd(data.ad);
|
||||
setMessage(`Ad reward: +${formatCredits(data.reward)} cr`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
if (err.message?.includes('available in') || err.nextAdAt) {
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setWatching(false);
|
||||
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 />;
|
||||
|
||||
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Shop</h1>
|
||||
<p className="muted">Spend osu for credit packs, or watch an ad every minute.</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<span className="osu-pill">{user.osuBalance ?? 0} osu</span>
|
||||
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="ok-msg">{message}</div>}
|
||||
|
||||
<div className="admin-block ad-panel">
|
||||
<h2>Watch an ad</h2>
|
||||
<p className="muted">
|
||||
Earn between {formatCredits(ad?.rewardMin || 10000)} and{' '}
|
||||
{formatCredits(ad?.rewardMax || 20000)} cr. Cooldown: 1 minute.
|
||||
</p>
|
||||
{watching ? (
|
||||
<div className="ad-sim">
|
||||
<div className="ad-bar" />
|
||||
<p className="muted">Playing ad…</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !adReady}
|
||||
onClick={watchAd}
|
||||
>
|
||||
{adReady
|
||||
? 'Watch ad'
|
||||
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
|
||||
remaining % 60
|
||||
).padStart(2, '0')}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Credit packs</h2>
|
||||
<p className="muted" style={{ marginBottom: '1rem' }}>
|
||||
Buy credits with osu (demo currency).
|
||||
</p>
|
||||
<div className="mode-grid">
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className="mode-card shop-pack">
|
||||
<h2>{p.name}</h2>
|
||||
<p className="pack-credits">{p.blurb}</p>
|
||||
<p className="muted">{p.osuCost} osu</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || (user.osuBalance ?? 0) < p.osuCost}
|
||||
onClick={() => buy(p.id)}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user