diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f1cc8e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +node_modules +**/node_modules +.git +.gitignore +*.md +.env +.env.* +!.env.prod.example +server/.env +server/uploads/** +!server/uploads/.gitkeep +server/*.db +server/*.db-journal +client/dist +.cursor +agent-transcripts +scripts +docker-compose*.yml diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..3951b8d --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,37 @@ +# Production environment example — copy to .env.prod and edit: +# cp .env.prod.example .env.prod + +# Must match the URL you open in the browser (scheme + host + port). +# HTTP direct (Docker port 3009): +CLIENT_ORIGIN=http://localhost:3009 +# HTTPS behind reverse proxy: +# CLIENT_ORIGIN=https://casegambling.example.com + +# Extra allowed origins (comma-separated), optional +# CORS_ORIGINS=http://192.168.1.10:3009 + +# Session secret — use a long random string in real prod +SESSION_SECRET=change-me-to-a-long-random-secret + +# Admin panel login (required — created/updated on every container start) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-strong-password + +# Starting balances for new accounts (credits in cents, osu as units) +STARTING_BALANCE=10000 +STARTING_OSU=100 + +# Jackpot +JACKPOT_ROUND_SECONDS=30 +JACKPOT_MAX_ITEMS=3 + +# Cookie Secure: +# false / auto → OK for plain HTTP (:3009) +# true → only with HTTPS (otherwise login cookie is dropped by the browser) +COOKIE_SECURE=false + +# Trust X-Forwarded-* (only behind nginx/caddy/traefik) +TRUST_PROXY=false + +# Host port mapped in docker-compose +HOST_PORT=3009 diff --git a/.gitignore b/.gitignore index 9389485..a6ba9e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ node_modules/ dist/ .env +.env.prod *.db *.db-journal .DS_Store +server/sessions.db server/uploads/ !server/uploads/.gitkeep - +client/dist/ +backups/ +scripts/backupscasegambling/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6c11563 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# CaseGambling — multi-stage production image +# Docker Hub: foufure/casegambling (repository names must be lowercase) + +FROM node:20-alpine AS client-build +WORKDIR /app/client +COPY client/package.json client/package-lock.json ./ +RUN npm ci +COPY client/ ./ +RUN npm run build + +FROM node:20-alpine AS server-deps +WORKDIR /app +RUN apk add --no-cache python3 make g++ +COPY server/package.json server/package-lock.json ./ +RUN npm ci +COPY server/prisma ./prisma +RUN npx prisma generate + +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3001 + +RUN apk add --no-cache openssl libc6-compat wget \ + && mkdir -p /data /app/uploads + +COPY --from=server-deps /app/node_modules ./node_modules +COPY --from=server-deps /app/package.json ./ +COPY server/ ./ +COPY --from=client-build /app/client/dist ./public + +EXPOSE 3001 + +VOLUME ["/data", "/app/uploads"] + +# --accept-data-loss: needed for safe widenings (e.g. Int → BigInt on money columns). +# Without it, prisma db push refuses to apply and the container restart-loops. +CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node src/index.js"] diff --git a/README.md b/README.md index 07f4ada..93cfea2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ CLIENT_ORIGIN=http://localhost:5173 SESSION_SECRET=dev-secret STARTING_BALANCE=10000 STARTING_OSU=100 -JACKPOT_ROUND_SECONDS=60 +JACKPOT_ROUND_SECONDS=30 JACKPOT_MAX_ITEMS=3 ``` @@ -40,28 +40,84 @@ JACKPOT_MAX_ITEMS=3 | Admin | `admin` | `admin123` | | Player| `player` | `player123`| +Local seed defaults; override admin via `ADMIN_USERNAME` / `ADMIN_PASSWORD`. In Docker, those env vars in `.env.prod` are required and applied on every start. + New registrations receive **100.00** starting credits and **100 osu** (`STARTING_BALANCE` / `STARTING_OSU` in `server/.env`; credits stored in cents). ## Features - Login / register (username + password) -- Inventory: sort by recent or value, multi-select and sell at market value -- **Feed Drop**: toggle in nav — live left column of latest case unboxes + player +- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles) +- **Auto-sell**: optional threshold (shared with bulk sell); on **case opens**, credits are applied after the reel finishes; on **battle wins**, items taken from other players below the threshold are sold immediately +- **Wear (float)**: each case drop gets a random float `0–1` and paint seed; grades FN / MW / FT / WW / BS (CS ranges). Catalog `marketValue` is the **Field-Tested** reference; instance `valueCents` scales by float within the wear band (FN up to +50%, MW +15–30%, FT mid ≈ catalog, WW down to −40%, BS lower) +- **Feed Drop**: toggle in nav — live left column of latest case unboxes + MultiPlayers Battle winners - Cases page: browse and open crates (CS-style reel, result rolled server-side) -- **Bet Item** hub: - - **MultiPlayers Case** — live shared pot, up to 3 items, win chance by value, Socket.IO sync +- **Case keys** (per crate): **1 key** → **x10**; each **100 opens** of that case earns +1 key up to **x20** (keys 1→11, +1 open per key: x11…x20); from **x20** onward each next key costs **200, 300, 400…** opens (100 × tier) and adds +1 max open (x21, x22…) — no cap +- **Battle** hub (`/battle`): + - **MultiPlayers Case** — live shared pot preview on the hub (top items, pot total, players), up to 3 items, win chance by value, Socket.IO sync - **1vX Case** — custom rooms (player/item limits, min value), spectate in-progress - **Coinflip** — stub (coming soon) -- **Shop**: spend osu on credit packs; watch an ad every 1 min for 100.00–200.00 cr -- Profile (via username in the nav): avatar, bio, password, stats -- Leaderboard: richest players (balance + inventory market value) +- **Shop** (nav balance pill → `/shop`): spend osu on credit packs; watch an ad every 1 min for 100.00–200.00 cr +- Profile (username in nav): avatar, bio, password, stats, logout +- **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles +- **Achievements** (`/achievements`, also from profile): 10 starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile +- **Leaderboard**: richest players (balance + inventory value); click a row to open a public **player profile** (`/player/:username`) with stats and inventory - Admin: manage cases, items, images (URL or upload), drop weights, enable/disable cases +## Docker (production) + +```bash +# Build & push to Docker Hub (foufure/casegambling) +docker login +./scripts/docker-build-push.sh +# optional version tag: +./scripts/docker-build-push.sh v1.0.0 + +# Run +cp .env.prod.example .env.prod # edit secrets / CLIENT_ORIGIN / ADMIN_* +docker compose --env-file .env.prod up -d +``` + +Set **`ADMIN_USERNAME`** and **`ADMIN_PASSWORD`** in `.env.prod` (required). On each container start the admin account is created or updated from these values. + +App listens on `HOST_PORT` (default **3009** → container 3001). Data: volume `casegambling_data` (SQLite), uploads: `casegambling_uploads`. + +### Backup / restore + +**On the Docker prod server** (export DB to bring home): + +```bash +# Copy these two scripts onto the server next to docker-compose.yml, then: +./scripts/docker-db-export.sh +# → backups/casegambling-… (+ .tar.gz) + +# Optional: restore a backup into Docker +./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz +``` + +**On your laptop** (local `server/prisma/dev.db`): + +```bash +# Import the archive you copied from prod (stop `npm run dev` first) +./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz +npm run db:push # sync schema if needed +npm run dev + +# Export local DB (e.g. to push back to Docker) +./scripts/local-db-export.sh +./scripts/docker-db-import.sh ./backups/casegambling-….tar.gz +``` + +Typical **prod → local** flow: `docker-db-export.sh` on the server → `scp` the `.tar.gz` → `local-db-import.sh` on the laptop. + ## Notes - Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop +- Catalog item price = Field-Tested reference; each inventory row stores `floatValue`, `wear`, `paintSeed`, `valueCents`, `favorite` +- Per-user auto-sell settings: `autoSellEnabled`, `autoSellThresholdCents` (default threshold **10.00** cr) +- Case key progress stored in `CaseKeyProgress` (per user + case); backfill: `npm run db:backfill-case-keys --prefix server` - Inventory items locked while deposited in a bet; unlocked or transferred on resolve -- `Transaction` types include `open_case`, `seed_credit`, `seed_osu`, `sell_item`, `shop_purchase`, `ad_reward`, `jackpot_bet`, `jackpot_win`, `duel_bet`, `duel_win` -- Avatars stored under `server/uploads/` and served at `/uploads` -- Real-time state via Socket.IO (`jackpot`, `duel:{id}`, `duels` rooms) +- Avatars / uploads under `server/uploads/` (volume in Docker) +- Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling` +- Max **10** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER` - Swap `DATABASE_URL` to PostgreSQL later without changing app logic diff --git a/client/index.html b/client/index.html index 8d20268..041d80d 100644 --- a/client/index.html +++ b/client/index.html @@ -2,7 +2,9 @@ - + + + diff --git a/client/public/apple-touch-icon.png b/client/public/apple-touch-icon.png new file mode 100644 index 0000000..72b9e52 Binary files /dev/null and b/client/public/apple-touch-icon.png differ diff --git a/client/public/favicon-32x32.png b/client/public/favicon-32x32.png new file mode 100644 index 0000000..9407ae7 Binary files /dev/null and b/client/public/favicon-32x32.png differ diff --git a/client/public/favicon.png b/client/public/favicon.png new file mode 100644 index 0000000..abe5854 Binary files /dev/null and b/client/public/favicon.png differ diff --git a/client/src/App.jsx b/client/src/App.jsx index e91e451..4ba9217 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,4 +1,4 @@ -import { Navigate, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes, useParams } from 'react-router-dom'; import Layout from './components/Layout'; import { useAuth } from './AuthContext'; import Admin from './pages/Admin'; @@ -8,12 +8,16 @@ import Dashboard from './pages/Dashboard'; import Leaderboard from './pages/Leaderboard'; import Login from './pages/Login'; import Register from './pages/Register'; -import BetHub from './pages/BetHub'; +import BattleHub from './pages/BattleHub'; import MultiPlayersPage from './pages/MultiPlayersPage'; import DuelListPage from './pages/DuelListPage'; import DuelRoomPage from './pages/DuelRoomPage'; import CoinflipStub from './pages/CoinflipStub'; import ProfilePage from './pages/ProfilePage'; +import PlayerProfilePage from './pages/PlayerProfilePage'; +import CatalogPage from './pages/CatalogPage'; +import PrestigePage from './pages/PrestigePage'; +import AchievementsPage from './pages/AchievementsPage'; import ShopPage from './pages/ShopPage'; function HomeRedirect() { @@ -24,6 +28,11 @@ function HomeRedirect() { return ; } +function BetDuelRedirect() { + const { id } = useParams(); + return ; +} + export default function App() { return ( @@ -35,12 +44,21 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> + } /> } /> } /> diff --git a/client/src/AuthContext.jsx b/client/src/AuthContext.jsx index cc46a6b..67fbe4b 100644 --- a/client/src/AuthContext.jsx +++ b/client/src/AuthContext.jsx @@ -45,13 +45,23 @@ export function AuthProvider({ children }) { setUser(null); }; - const updateBalance = (balance) => { + const updateBalance = useCallback((balance) => { setUser((prev) => (prev ? { ...prev, balance } : prev)); - }; + }, []); - const patchUser = (partial) => { - setUser((prev) => (prev ? { ...prev, ...partial } : prev)); - }; + const patchUser = useCallback((partial) => { + setUser((prev) => { + if (!prev) return prev; + let changed = false; + for (const key of Object.keys(partial)) { + if (prev[key] !== partial[key]) { + changed = true; + break; + } + } + return changed ? { ...prev, ...partial } : prev; + }); + }, []); return ( request('/api/auth/logout', { method: 'POST' }), cases: () => request('/api/cases'), case: (id) => request(`/api/cases/${id}`), - openCase: (id) => request(`/api/cases/${id}/open`, { method: 'POST' }), + catalog: () => request('/api/catalog'), + prestige: () => request('/api/prestige'), + doPrestige: (choice) => + request('/api/prestige', { + method: 'POST', + body: JSON.stringify({ choice }), + }), + openCase: (id, count = 1) => + request(`/api/cases/${id}/open`, { + method: 'POST', + body: JSON.stringify({ count }), + }), inventory: () => request('/api/inventory'), sellInventory: (inventoryItemIds) => request('/api/inventory/sell', { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), + updateAutoSell: (body) => + request('/api/inventory/auto-sell', { + method: 'PATCH', + body: JSON.stringify(body), + }), + applyPendingAutoSell: (inventoryItemIds) => + request('/api/inventory/auto-sell/apply', { + method: 'POST', + body: JSON.stringify({ inventoryItemIds }), + }), + setInventoryFavorite: (inventoryItemIds, favorite) => + request('/api/inventory/favorite', { + method: 'PATCH', + body: JSON.stringify({ inventoryItemIds, favorite }), + }), shop: () => request('/api/shop'), shopBuy: (packageId) => request('/api/shop/buy', { @@ -47,6 +73,15 @@ export const api = { }), shopAd: () => request('/api/shop/ad', { method: 'POST' }), feed: () => request('/api/feed'), + feedAnnounce: (inventoryItemIds, caseName) => { + const ids = Array.isArray(inventoryItemIds) + ? inventoryItemIds + : [inventoryItemIds]; + return request('/api/feed/announce', { + method: 'POST', + body: JSON.stringify({ inventoryItemIds: ids, caseName }), + }); + }, leaderboard: () => request('/api/leaderboard'), jackpot: () => request('/api/jackpot'), jackpotBet: (inventoryItemIds) => @@ -72,6 +107,9 @@ export const api = { duelCancel: (id) => request(`/api/duels/${id}/cancel`, { method: 'POST' }), duelStart: (id) => request(`/api/duels/${id}/start`, { method: 'POST' }), profile: () => request('/api/profile'), + playerProfile: (username) => + request(`/api/profile/player/${encodeURIComponent(username)}`), + achievements: () => request('/api/achievements'), updateProfile: (body) => request('/api/profile', { method: 'PATCH', body: JSON.stringify(body) }), changePassword: (currentPassword, newPassword) => @@ -122,9 +160,34 @@ export const api = { }; export function formatCredits(cents) { - return (Number(cents) / 100).toFixed(2); + const fixed = (Number(cents) / 100).toFixed(2); + const neg = fixed.startsWith('-'); + const body = neg ? fixed.slice(1) : fixed; + const [intPart, dec] = body.split('.'); + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f'); + return `${neg ? '-' : ''}${grouped}.${dec}`; } export function formatChance(pct) { return `${Number(pct || 0).toFixed(1)}%`; } + +/** Format seconds as e.g. "2h 15m" or "45m". */ +export function formatPlayTime(seconds) { + const s = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + if (h <= 0 && m <= 0) return s > 0 ? `${s}s` : '0m'; + if (h <= 0) return `${m}m`; + if (m <= 0) return `${h}h`; + return `${h}h ${m}m`; +} + +export function formatLastConnection(iso) { + if (!iso) return 'Never'; + try { + return new Date(iso).toLocaleString(); + } catch { + return '—'; + } +} diff --git a/client/src/components/CaseReel.jsx b/client/src/components/CaseReel.jsx index 8ac67ce..75f7820 100644 --- a/client/src/components/CaseReel.jsx +++ b/client/src/components/CaseReel.jsx @@ -2,60 +2,142 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { rarityColor } from '../rarities'; const ITEM_WIDTH = 120; +const ITEM_WIDTH_COMPACT = 72; const SPIN_DURATION_MS = 5500; +const SPIN_SLOTS = 42; +const TAIL_SLOTS = 8; -function buildTrack(items, winnerId, spins = 42) { - const track = []; - for (let i = 0; i < spins; i += 1) { - track.push(items[i % items.length]); +function pickRandom(items, avoidId) { + if (!items.length) return null; + if (items.length === 1) return items[0]; + let pick = items[Math.floor(Math.random() * items.length)]; + for (let i = 0; i < 10 && pick.id === avoidId; i += 1) { + pick = items[Math.floor(Math.random() * items.length)]; } + return pick; +} + +function shuffledPool(items) { + const pool = [...items]; + for (let i = pool.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + return pool; +} + +function buildTrack(items, winnerId, spins = SPIN_SLOTS) { + if (!items?.length) return { track: [], winnerIndex: 0 }; + const winner = items.find((i) => i.id === winnerId) || items[0]; - track.push(winner); - // pad a few after so the reel doesn't end abruptly - for (let i = 0; i < 8; i += 1) { - track.push(items[(i + 3) % items.length]); + const track = []; + let lastId = null; + let bag = shuffledPool(items); + let bagIndex = 0; + + const nextItem = () => { + // Draw from a reshuffled bag so the strip covers the pool, not a fixed cycle + if (bagIndex >= bag.length) { + bag = shuffledPool(items); + bagIndex = 0; + if (bag.length > 1 && bag[0].id === lastId) { + const swap = 1 + Math.floor(Math.random() * (bag.length - 1)); + [bag[0], bag[swap]] = [bag[swap], bag[0]]; + } + } + let item = bag[bagIndex]; + bagIndex += 1; + if (item.id === lastId && items.length > 1) { + item = pickRandom(items, lastId); + } + return item; + }; + + for (let i = 0; i < spins; i += 1) { + const item = nextItem(); + track.push(item); + lastId = item.id; } + + track.push(winner); + lastId = winner.id; + + for (let i = 0; i < TAIL_SLOTS; i += 1) { + const item = nextItem(); + track.push(item); + lastId = item.id; + } + return { track, winnerIndex: spins }; } -export default function CaseReel({ items, winnerId, spinning, onDone, showOwners = false }) { +export default function CaseReel({ + items, + winnerId, + spinning, + onDone, + showOwners = false, + compact = false, +}) { const trackRef = useRef(null); const [offset, setOffset] = useState(0); + const [locked, setLocked] = useState(null); // { track, winnerIndex, key } const onDoneRef = useRef(onDone); onDoneRef.current = onDone; + const itemWidth = compact ? ITEM_WIDTH_COMPACT : ITEM_WIDTH; - const { track, winnerIndex } = useMemo(() => { - if (!items?.length || !winnerId) { - return { track: items || [], winnerIndex: 0 }; - } - return buildTrack(items, winnerId); - }, [items, winnerId]); + const spinKey = + winnerId && items?.length ? `${winnerId}:${items.map((i) => i.id).join(',')}` : ''; + + const idleTrack = useMemo(() => { + if (!items?.length) return []; + return shuffledPool(items); + }, [items]); + + const track = + locked?.key === spinKey && locked.track.length ? locked.track : idleTrack; useEffect(() => { - if (!spinning || !winnerId || !items?.length) return undefined; + if (!spinning) return undefined; + if (!items?.length || !winnerId) return undefined; + + const key = `${winnerId}:${items.map((i) => i.id).join(',')}`; + const built = buildTrack(items, winnerId); + setLocked({ ...built, key }); + let finished = false; const viewportCenter = (trackRef.current?.parentElement?.clientWidth || 600) / 2; const target = - winnerIndex * ITEM_WIDTH - viewportCenter + ITEM_WIDTH / 2 + (Math.random() * 40 - 20); + built.winnerIndex * itemWidth - + viewportCenter + + itemWidth / 2 + + (Math.random() * 40 - 20); - // force reflow then animate setOffset(0); const id = requestAnimationFrame(() => { requestAnimationFrame(() => setOffset(target)); }); const timer = setTimeout(() => { + if (finished) return; + finished = true; onDoneRef.current?.(); }, SPIN_DURATION_MS + 80); return () => { cancelAnimationFrame(id); clearTimeout(timer); + finished = true; }; - }, [spinning, winnerId, winnerIndex, items]); + // intentionally only re-run when a new spin starts + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [spinning, winnerId, itemWidth]); return ( -
+
{item.imageUrl ? ( diff --git a/client/src/components/DropFeed.jsx b/client/src/components/DropFeed.jsx index 44cc683..b4b3955 100644 --- a/client/src/components/DropFeed.jsx +++ b/client/src/components/DropFeed.jsx @@ -36,31 +36,57 @@ export default function DropFeed() { useEffect(() => { let cancelled = false; - - (async () => { - try { - const data = await api.feed(); - if (!cancelled) setDrops(pruneDrops(data.drops || [])); - } catch { - /* ignore — socket history may fill */ - } - })(); - const socket = getSocket(); - const onHistory = (list) => { - setDrops(pruneDrops(Array.isArray(list) ? list.slice(0, MAX_BUFFER) : [])); - }; - const onDrop = (entry) => { - setDrops((prev) => pruneDrops([entry, ...prev].slice(0, MAX_BUFFER))); + + const applyList = (list) => { + if (cancelled) return; + const seen = new Set(); + const unique = []; + for (const d of list || []) { + if (!d?.id || seen.has(d.id)) continue; + seen.add(d.id); + unique.push(d); + } + setDrops(pruneDrops(unique.slice(0, MAX_BUFFER))); }; + const onHistory = (list) => applyList(list); + const onDrop = (entry) => { + if (!entry?.id) return; + setDrops((prev) => { + if (prev.some((d) => d.id === entry.id)) return prev; + // Also skip near-identical batch (same user + item + count) within a few seconds + const nearDup = prev.find( + (d) => + d.username === entry.username && + d.item?.id === entry.item?.id && + (d.count || 1) === (entry.count || 1) && + (d.openCount || 1) === (entry.openCount || 1) && + Math.abs(new Date(d.at) - new Date(entry.at)) < 15000 + ); + if (nearDup) return prev; + return pruneDrops([entry, ...prev].slice(0, MAX_BUFFER)); + }); + }; + + // Prefer socket history; HTTP is fallback if socket is slow socket.emit('feed:subscribe'); socket.on('feed:history', onHistory); socket.on('feed:drop', onDrop); socket.on('connect', () => socket.emit('feed:subscribe')); + const t = setTimeout(() => { + if (cancelled) return; + api.feed() + .then((data) => { + setDrops((prev) => (prev.length ? prev : pruneDrops(data.drops || []))); + }) + .catch(() => {}); + }, 400); + return () => { cancelled = true; + clearTimeout(t); socket.emit('feed:unsubscribe'); socket.off('feed:history', onHistory); socket.off('feed:drop', onDrop); @@ -100,7 +126,13 @@ export default function DropFeed() { )}
-
{d.item?.name}
+
+ {d.item?.name} + {d.count > 1 ? x{d.count} : null} +
+ {d.item?.wear ? ( +
{d.item.wear}
+ ) : null}
{d.avatarUrl ? ( @@ -109,11 +141,29 @@ export default function DropFeed() { {(d.username || '?').slice(0, 1).toUpperCase()} )} - {d.username} + + {d.source === 'jackpot' ? ( + <> + Battle Winner{' '} + {d.username} + + ) : ( + d.username + )} +
- {d.caseName ? `${d.caseName} · ` : ''} + {d.source === 'jackpot' + ? 'MultiPlayers Case · ' + : d.caseName + ? `${d.caseName} · ` + : ''} {formatCredits(d.item?.marketValue || 0)} cr + {d.openCount > 1 + ? d.source === 'jackpot' + ? ` · ${d.openCount} items` + : ` · ${d.openCount}× open` + : ''}
{formatDropTime(d.at)}
diff --git a/client/src/components/ItemTile.jsx b/client/src/components/ItemTile.jsx index b196bdb..280ef9f 100644 --- a/client/src/components/ItemTile.jsx +++ b/client/src/components/ItemTile.jsx @@ -1,26 +1,52 @@ import { formatCredits } from '../api'; import { rarityColor } from '../rarities'; +/** Instance sell/bet price, else catalog FT reference */ +export function itemValueCents(item) { + if (!item) return 0; + if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) { + return Number(item.valueCents); + } + return Number(item.marketValue) || 0; +} + +export function formatFloat(f) { + const n = Number(f); + if (!Number.isFinite(n)) return ''; + return n.toFixed(6); +} + export default function ItemTile({ item, showValue = true, + showWear = true, selected = false, + favorite = false, onClick, disabled = false, }) { const color = rarityColor(item.rarity); const clickable = typeof onClick === 'function'; const hasImage = Boolean(item.imageUrl); + const value = itemValueCents(item); + const wear = item.wear; + const floatTitle = + wear && item.floatValue != null + ? `${wear} · ${formatFloat(item.floatValue)}${ + item.paintSeed != null ? ` · seed ${item.paintSeed}` : '' + }` + : wear || undefined; return (
{ @@ -33,6 +59,7 @@ export default function ItemTile({ } >
+ {favorite ? : null} {hasImage ? ( ) : ( @@ -43,8 +70,13 @@ export default function ItemTile({
{item.rarity}
+ {showWear && wear ? ( +
+ {wear} +
+ ) : null} {showValue && ( -
{formatCredits(item.marketValue)} cr
+
{formatCredits(value)} cr
)}
); diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index edabd69..5f09027 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -1,13 +1,50 @@ import { useEffect, useState } from 'react'; -import { NavLink, Outlet } from 'react-router-dom'; +import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { useAuth } from '../AuthContext'; -import { formatCredits } from '../api'; +import { api, formatCredits } from '../api'; import DropFeed from './DropFeed'; +import SessionLimitOverlay from './SessionLimitOverlay'; +import { onInventoryChanged } from '../inventoryEvents'; +import { getSocket, onSessionLimitChange } from '../socket'; const FEED_KEY = 'caseforge-feed-drop'; +function ScrollToTop() { + const { pathname } = useLocation(); + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + return null; +} + +function countWaitingBattles(jackpotRound, duelOpen) { + let n = 0; + + // MultiPlayers: only if someone has already bet and round still waits for more players + const jpPlayers = jackpotRound?.players?.length ?? 0; + const jpDeposits = jackpotRound?.deposits?.length ?? 0; + if ( + jackpotRound?.waitingForPlayers && + jackpotRound.status !== 'spinning' && + (jpPlayers > 0 || jpDeposits > 0) + ) { + n += 1; + } + + // 1vX: each open room that already has at least one player with a bet + for (const room of duelOpen || []) { + if (room.status !== 'open') continue; + const hasBet = + (room.playerCount ?? room.players?.length ?? 0) > 0 || + (room.deposits?.length ?? 0) > 0; + if (hasBet) n += 1; + } + + return n; +} + export default function Layout() { - const { user, logout } = useAuth(); + const { user } = useAuth(); const [feedOpen, setFeedOpen] = useState(() => { try { return localStorage.getItem(FEED_KEY) === '1'; @@ -15,6 +52,9 @@ export default function Layout() { return false; } }); + const [waitingBattles, setWaitingBattles] = useState(0); + const [prestigeVisible, setPrestigeVisible] = useState(false); + const [sessionLimit, setSessionLimit] = useState(null); useEffect(() => { try { @@ -24,53 +64,176 @@ export default function Layout() { } }, [feedOpen]); + useEffect(() => { + if (!user || user.role === 'admin') { + setPrestigeVisible(false); + return undefined; + } + if ((user.prestigeCount ?? 0) > 0) { + setPrestigeVisible(true); + } + let cancelled = false; + const refreshVisibility = async () => { + try { + const data = await api.prestige(); + if (!cancelled) setPrestigeVisible(Boolean(data.pageVisible)); + } catch { + /* nav link optional */ + } + }; + refreshVisibility(); + const offInv = onInventoryChanged(() => { + refreshVisibility(); + }); + return () => { + cancelled = true; + offInv(); + }; + }, [user?.id, user?.role, user?.balance, user?.prestigeCount]); + + useEffect(() => { + if (!user || user.role === 'admin') { + setWaitingBattles(0); + return undefined; + } + + let jackpotRound = null; + let duelOpen = []; + let cancelled = false; + + const sync = () => { + if (cancelled) return; + setWaitingBattles((prev) => { + const next = countWaitingBattles(jackpotRound, duelOpen); + return prev === next ? prev : next; + }); + }; + + (async () => { + try { + const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]); + if (cancelled) return; + jackpotRound = jp.round; + duelOpen = duels.open || []; + sync(); + } catch { + /* nav badge is optional */ + } + })(); + + const socket = getSocket(); + const offLimitChange = onSessionLimitChange((payload) => { + setSessionLimit(payload); + }); + const onJackpot = (state) => { + jackpotRound = state; + sync(); + }; + const onDuels = (list) => { + duelOpen = list.open || []; + sync(); + }; + + socket.emit('jackpot:subscribe'); + socket.emit('duels:subscribe'); + socket.on('jackpot:state', onJackpot); + socket.on('jackpot:spin', onJackpot); + socket.on('jackpot:result', onJackpot); + socket.on('duels:list', onDuels); + socket.on('connect', () => { + socket.emit('jackpot:subscribe'); + socket.emit('duels:subscribe'); + }); + + return () => { + cancelled = true; + offLimitChange(); + socket.emit('jackpot:unsubscribe'); + socket.emit('duels:unsubscribe'); + socket.off('jackpot:state', onJackpot); + socket.off('jackpot:spin', onJackpot); + socket.off('jackpot:result', onJackpot); + socket.off('duels:list', onDuels); + }; + }, [user?.id, user?.role]); + + const isPlayer = user && user.role !== 'admin'; + return ( -
+
+ + {sessionLimit && }
- - CaseForge - +
+ + CaseForge + + {isPlayer && ( + + )} +
- {feedOpen && user && user.role !== 'admin' && } + {feedOpen && isPlayer && }
diff --git a/client/src/components/ProfileAchievementBadges.jsx b/client/src/components/ProfileAchievementBadges.jsx new file mode 100644 index 0000000..be335b3 --- /dev/null +++ b/client/src/components/ProfileAchievementBadges.jsx @@ -0,0 +1,51 @@ +import { Link } from 'react-router-dom'; + +export default function ProfileAchievementBadges({ + achievements = [], + linkToAchievements = false, +}) { + if (!achievements.length) return null; + + return ( +
+
+

Achievements

+ {linkToAchievements && ( + + View all + + )} +
+
+ {achievements.map((a) => { + const inner = ( + <> + + {a.icon || '★'} + + {a.name} + + ); + return linkToAchievements ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ); + })} +
+
+ ); +} diff --git a/client/src/components/ProfileCaseStats.jsx b/client/src/components/ProfileCaseStats.jsx new file mode 100644 index 0000000..3363bb7 --- /dev/null +++ b/client/src/components/ProfileCaseStats.jsx @@ -0,0 +1,33 @@ +import { Link } from 'react-router-dom'; + +export default function ProfileCaseStats({ caseOpens = [] }) { + if (!caseOpens.length) { + return

No cases opened yet.

; + } + + return ( +
    + {caseOpens.map((row) => ( +
  • + + + {row.imageUrl ? ( + + ) : ( + '▣' + )} + + {row.caseName} + + {row.count} + opened + + + {row.keys} key{row.keys === 1 ? '' : 's'} + + +
  • + ))} +
+ ); +} diff --git a/client/src/components/ProfileCatalogBadges.jsx b/client/src/components/ProfileCatalogBadges.jsx new file mode 100644 index 0000000..a4bcbae --- /dev/null +++ b/client/src/components/ProfileCatalogBadges.jsx @@ -0,0 +1,55 @@ +import { Link } from 'react-router-dom'; + +export default function ProfileCatalogBadges({ + completed = [], + luckPercent = 0, + linkToCatalog = false, +}) { + if (!completed.length) return null; + + return ( +
+
+

Catalog

+ {luckPercent > 0 && ( + +{luckPercent}% luck + )} +
+
+ {completed.map((ch) => { + const inner = ( + <> + {ch.imageUrl ? ( + + ) : ( + + )} + + ✓ + + {ch.name} + + ); + return linkToCatalog ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ); + })} +
+
+ ); +} diff --git a/client/src/components/ProfilePrestigeStats.jsx b/client/src/components/ProfilePrestigeStats.jsx new file mode 100644 index 0000000..95446c6 --- /dev/null +++ b/client/src/components/ProfilePrestigeStats.jsx @@ -0,0 +1,23 @@ +export default function ProfilePrestigeStats({ prestige }) { + if (!prestige || !prestige.count) return null; + + return ( +
+
+

Prestige

+ ×{prestige.count} +
+
    +
  • + Chance +{prestige.chanceBonus}% +
  • +
  • + Sell gains +{prestige.gainBonus}% +
  • +
  • + Key opens −{prestige.keyOpenReduction} +
  • +
+
+ ); +} diff --git a/client/src/components/SessionLimitOverlay.jsx b/client/src/components/SessionLimitOverlay.jsx new file mode 100644 index 0000000..5899256 --- /dev/null +++ b/client/src/components/SessionLimitOverlay.jsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; + +const FAKE_ADS = [ + { + brand: 'ServerFuel™', + tagline: 'Keep the forge warm', + blurb: 'Fictional energy drink for fictional servers. 0 calories, 100% vibes.', + }, + { + brand: 'CloudCrate Hosting', + tagline: 'Boxes in the sky', + blurb: 'Not a real host. Just a polite reminder that bandwidth isn’t free.', + }, + { + brand: 'FairPlay Shield', + tagline: 'One account, few tabs', + blurb: 'Demo ad. Thanks for not multi-boxing the backend into orbit.', + }, +]; + +export default function SessionLimitOverlay({ max = 10 }) { + const [ad] = useState( + () => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)] + ); + const [phase, setPhase] = useState('idle'); // idle | watching | done + const [secondsLeft, setSecondsLeft] = useState(5); + + useEffect(() => { + if (phase !== 'watching') return undefined; + if (secondsLeft <= 0) { + setPhase('done'); + return undefined; + } + const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [phase, secondsLeft]); + + const startAd = () => { + setSecondsLeft(5); + setPhase('watching'); + }; + + return ( +
+
+

Connection limit

+

Too many windows

+

+ This account already has {max} tabs/windows connected. + Close another one, then reload this page. +

+

+ Merci de ne pas abuser ni exploiter de bugs pour multiplier les sessions — + ça charge le serveur pour tout le monde. Un compte, un usage raisonnable. +

+ +
+
Sponsored · fictif
+
+ {phase === 'idle' && ( + <> +
{ad.brand}
+
{ad.tagline}
+

{ad.blurb}

+ + + )} + {phase === 'watching' && ( + <> +
{ad.brand}
+
+
+
+

Ad playing… {secondsLeft}s

+

Fictional spot — thanks for the support gesture.

+ + )} + {phase === 'done' && ( + <> +
Thanks!
+

+ Pub fictive terminée. Pour débloquer cette page : ferme un autre onglet, + puis recharge. +

+ + + )} +
+
+
+
+ ); +} diff --git a/client/src/index.css b/client/src/index.css index b369c81..32a1c02 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -99,6 +99,13 @@ h3 { text-transform: uppercase; } +.topnav-left { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + .nav-links { display: flex; gap: 1rem; @@ -116,6 +123,41 @@ h3 { color: var(--text); } +.nav-battle { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding-right: 0.15rem; +} + +.nav-battle.nav-battle-waiting, +.nav-battle.nav-battle-waiting:hover, +.nav-battle.nav-battle-waiting.active { + color: var(--accent); +} + +.nav-battle-badge { + position: absolute; + top: -0.55rem; + right: -0.75rem; + min-width: 1.1rem; + height: 1.1rem; + padding: 0; + border: none; + border-radius: 0; + display: inline-grid; + place-items: center; + font-family: var(--font-display); + font-size: 0.7rem; + font-weight: 600; + line-height: 1; + color: inherit; + background: transparent; + box-shadow: none; + pointer-events: none; +} + .nav-toggle { background: transparent; border: 1px solid var(--stroke); @@ -214,6 +256,17 @@ h3 { white-space: nowrap; } +.drop-feed-count { + color: var(--gold); + font-weight: 700; + margin-left: 0.15rem; +} + +.drop-feed-wear { + font-size: 0.72rem; + margin-top: 0.1rem; +} + .drop-feed-player { display: flex; align-items: center; @@ -222,6 +275,13 @@ h3 { margin-top: 0.15rem; } +.drop-feed-winner-label { + color: var(--gold); + font-family: var(--font-display); + font-weight: 600; + font-size: 0.78rem; +} + .drop-feed-avatar { width: 18px; height: 18px; @@ -276,6 +336,19 @@ h3 { padding: 0.35rem 0.7rem; } +a.balance-pill-link { + color: var(--gold); + text-decoration: none; + cursor: pointer; +} + +a.balance-pill-link:hover, +a.balance-pill-link.active { + color: var(--gold); + background: rgba(228, 174, 57, 0.15); + border-color: rgba(228, 174, 57, 0.5); +} + .main { width: min(1100px, 100%); margin: 0 auto; @@ -382,6 +455,148 @@ h3 { font-size: 0.9rem; } +.session-limit-banner { + padding: 0.65rem 1rem; + text-align: center; + font-size: 0.88rem; + font-family: var(--font-display); + color: var(--text); + background: color-mix(in srgb, var(--danger) 18%, var(--bg-panel)); + border-bottom: 1px solid color-mix(in srgb, var(--danger) 45%, var(--stroke)); +} + +.app-shell.session-locked .topnav, +.app-shell.session-locked .app-body { + filter: grayscale(0.85) brightness(0.45); + pointer-events: none; + user-select: none; +} + +.session-limit-overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: grid; + place-items: center; + padding: 1.25rem; + background: rgba(6, 10, 14, 0.72); +} + +.session-limit-box { + width: min(28rem, 100%); + padding: 1.5rem 1.35rem 1.35rem; + border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--stroke)); + background: + linear-gradient(165deg, rgba(235, 75, 75, 0.08) 0%, transparent 40%), + var(--bg-panel); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); + text-align: center; +} + +.session-limit-kicker { + margin: 0 0 0.35rem; + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--danger); + font-family: var(--font-display); +} + +.session-limit-box h2 { + margin: 0 0 0.65rem; + font-size: 1.45rem; +} + +.session-limit-lead { + margin: 0 0 0.75rem; + font-size: 0.95rem; + line-height: 1.45; +} + +.session-limit-respect { + margin: 0 0 1.15rem; + padding: 0.75rem 0.85rem; + font-size: 0.88rem; + line-height: 1.45; + color: var(--muted); + border: 1px solid var(--stroke); + background: var(--bg-deep); + text-align: left; +} + +.session-limit-ad { + text-align: left; + border: 1px solid var(--stroke); + background: var(--bg-elevated); +} + +.session-limit-ad-label { + padding: 0.35rem 0.75rem; + font-size: 0.65rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--stroke); + font-family: var(--font-display); +} + +.session-limit-ad-screen { + padding: 1rem 0.9rem 1.05rem; + display: flex; + flex-direction: column; + gap: 0.45rem; + align-items: flex-start; + min-height: 8.5rem; +} + +.session-limit-ad-screen.playing { + background: + radial-gradient(ellipse at 30% 20%, rgba(61, 214, 198, 0.12), transparent 55%), + var(--bg-elevated); +} + +.session-limit-ad-brand { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--accent); +} + +.session-limit-ad-tag { + font-family: var(--font-display); + font-size: 0.85rem; + color: var(--text); +} + +.session-limit-ad-screen .muted { + margin: 0; + font-size: 0.82rem; +} + +.session-limit-ad-screen .btn { + margin-top: 0.35rem; +} + +.session-limit-ad-progress { + width: 100%; + height: 0.35rem; + background: var(--bg-deep); + border: 1px solid var(--stroke); + overflow: hidden; +} + +.session-limit-ad-progress-fill { + height: 100%; + background: var(--accent); + transition: width 0.9s linear; +} + +.session-limit-ad-countdown { + margin: 0; + font-family: var(--font-display); + font-size: 0.95rem; +} + .section { margin-bottom: 2.5rem; } @@ -430,6 +645,7 @@ h3 { .item-visual, .case-visual { + position: relative; flex: 1; display: grid; place-items: center; @@ -451,6 +667,14 @@ h3 { font-size: 0.85rem; } +.item-wear { + color: var(--gold); + font-size: 0.72rem; + font-family: var(--font-display); + letter-spacing: 0.02em; + line-height: 1.2; +} + .empty { padding: 1.5rem; border: 1px dashed var(--stroke); @@ -460,18 +684,120 @@ h3 { .case-page { display: flex; flex-direction: column; - gap: 1.5rem; + gap: 0.5rem; + margin-top: -0.5rem; +} + +.case-page > a.muted { + margin: 0; + line-height: 1.2; } .case-hero { display: flex; flex-direction: column; align-items: center; - gap: 0.75rem; - padding: 1.5rem 1rem 0.5rem; + gap: 0.35rem; + padding: 0.35rem 1rem 0.25rem; text-align: center; } +.case-hero > p.muted { + margin: 0; +} + +.case-key-progress { + width: min(420px, 100%); + margin: 0.35rem 0 0.15rem; +} + +.case-key-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.35rem 0.75rem; + font-size: 0.9rem; + margin-bottom: 0.4rem; +} + +.case-key-bar { + height: 6px; + border-radius: 999px; + background: var(--stroke); + overflow: hidden; +} + +.case-key-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-dim), var(--accent)); + border-radius: inherit; + transition: width 300ms ease; +} + +.case-key-badge { + color: var(--gold); + font-family: var(--font-display); +} + +.open-qty { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: center; +} + +.open-qty label { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.open-qty select { + background: var(--bg-deep); + border: 1px solid var(--stroke); + color: var(--text); + padding: 0.4rem 0.55rem; + font-family: var(--font-display); +} + +.multi-reels { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1rem; +} + +.multi-reels.many { + gap: 1.25rem; +} + +.multi-reel-slot { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.multi-reel-label { + font-family: var(--font-display); + font-size: 0.85rem; +} + +.won-banner.compact { + padding: 0.65rem; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem 0.85rem; +} + +.won-banner.compact .won-img { + width: 48px; + height: 48px; + margin: 0; +} + .case-hero .case-icon { width: 140px; height: 140px; @@ -664,6 +990,439 @@ h3 { color: var(--accent); } +.table tbody tr.table-row-clickable { + cursor: pointer; +} + +.table tbody tr.table-row-clickable:hover, +.table tbody tr.table-row-clickable:focus-visible { + background: rgba(61, 214, 198, 0.06); + outline: none; +} + +/* —— Leaderboard —— */ +.lb-page { + max-width: 960px; +} + +.lb-avatar { + flex-shrink: 0; + display: grid; + place-items: center; + overflow: hidden; + background: var(--bg-deep); + border: 1px solid var(--stroke); + font-family: var(--font-display); + font-weight: 600; + color: var(--muted); + letter-spacing: 0.02em; +} + +.lb-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.lb-avatar-sm { + width: 2.35rem; + height: 2.35rem; + font-size: 0.7rem; +} + +.lb-avatar-lg { + width: 3.4rem; + height: 3.4rem; + font-size: 0.9rem; +} + +.lb-avatar-xl { + width: 4.5rem; + height: 4.5rem; + font-size: 1.15rem; +} + +.lb-podium { + display: grid; + grid-template-columns: 1fr 1.15fr 1fr; + gap: 0.65rem; + align-items: end; + margin: 0.25rem 0 1.25rem; +} + +.lb-podium-card { + appearance: none; + width: 100%; + text-align: center; + cursor: pointer; + color: inherit; + font: inherit; + padding: 0.7rem 0.7rem 0.75rem; + border: 1px solid var(--stroke); + background: var(--bg-panel); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + transition: + border-color 0.2s ease, + transform 0.2s ease, + background 0.2s ease; +} + +.lb-podium-card:hover, +.lb-podium-card:focus-visible { + outline: none; + transform: translateY(-2px); +} + +.lb-podium-rank { + font-family: var(--font-display); + font-weight: 700; + font-size: 0.75rem; + letter-spacing: 0.08em; + color: var(--muted); +} + +.lb-podium-frame { + margin: 0.15rem 0 0.1rem; + padding: 0.2rem; + border: 1px solid var(--stroke); +} + +.lb-podium-title { + font-family: var(--font-display); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); +} + +.lb-podium-name { + font-family: var(--font-display); + font-size: 0.95rem; + font-weight: 600; +} + +.lb-podium-bonus { + font-size: 0.68rem; + color: var(--accent); + margin-bottom: 0.1rem; +} + +.lb-wealth { + width: 100%; + margin-top: 0.2rem; + padding-top: 0.45rem; + border-top: 1px solid var(--stroke); +} + +.lb-wealth-total { + display: flex; + flex-direction: column; + gap: 0.05rem; +} + +.lb-wealth-label { + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); +} + +.lb-wealth-total strong { + font-family: var(--font-display); + font-size: 0.95rem; + font-weight: 600; +} + +.lb-wealth-parts { + display: flex; + flex-direction: column; + gap: 0.1rem; + margin-top: 0.3rem; + font-size: 0.68rem; + color: var(--muted); +} + +.lb-wealth-parts em { + font-style: normal; + color: var(--text); + font-family: var(--font-display); +} + +.lb-wealth-compact strong { + font-family: var(--font-display); + font-weight: 600; + white-space: nowrap; +} + +/* 1st — most impressive */ +.lb-place-1 { + position: relative; + z-index: 1; + overflow: visible; + isolation: isolate; + padding: 0.95rem 0.85rem 0.9rem; + border-color: color-mix(in srgb, var(--gold) 55%, var(--stroke)); + background: + linear-gradient(165deg, rgba(228, 174, 57, 0.16) 0%, transparent 42%), + linear-gradient(180deg, #243040 0%, var(--bg-panel) 100%); + box-shadow: inset 0 1px 0 rgba(228, 174, 57, 0.25); +} + +.lb-fire { + position: absolute; + inset: -4px; + pointer-events: none; + z-index: -1; +} + +.lb-fire-ring { + position: absolute; + inset: 0; + padding: 2px; + border: none; + background: linear-gradient( + 120deg, + #7a1a00, + #e85d04, + #ffba08, + #faa307, + #dc2f02, + #7a1a00 + ); + background-size: 280% 280%; + animation: lb-fire-ring 2.4s linear infinite; + opacity: 0.95; + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +@keyframes lb-fire-ring { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 100% 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .lb-fire-ring { + animation: none; + background-position: 40% 50%; + } +} + +.lb-place-1 .lb-podium-rank { + font-size: 0.95rem; + color: var(--gold); +} + +.lb-place-1 .lb-podium-frame { + border-color: color-mix(in srgb, var(--gold) 70%, var(--stroke)); + padding: 0.28rem; + background: color-mix(in srgb, var(--gold) 10%, transparent); +} + +.lb-place-1 .lb-podium-title { + color: var(--gold); + font-size: 0.72rem; +} + +.lb-place-1 .lb-podium-name { + font-size: 1.15rem; +} + +.lb-place-1 .lb-podium-bonus { + color: var(--gold); + font-family: var(--font-display); + font-size: 0.75rem; +} + +.lb-place-1 .lb-wealth-total strong { + font-size: 1.1rem; + color: var(--gold); +} + +.lb-place-1:hover, +.lb-place-1:focus-visible { + border-color: var(--gold); + transform: translateY(-3px); +} + +/* 2nd */ +.lb-place-2 { + border-color: color-mix(in srgb, #c5d0db 40%, var(--stroke)); + background: + linear-gradient(165deg, rgba(197, 208, 219, 0.1) 0%, transparent 45%), + var(--bg-panel); +} + +.lb-place-2 .lb-podium-rank { + color: #c5d0db; +} + +.lb-place-2 .lb-podium-frame { + border-color: color-mix(in srgb, #c5d0db 55%, var(--stroke)); +} + +.lb-place-2 .lb-podium-title, +.lb-place-2 .lb-podium-bonus { + color: #b8c4d0; +} + +.lb-place-2:hover, +.lb-place-2:focus-visible { + border-color: #c5d0db; +} + +/* 3rd */ +.lb-place-3 { + border-color: color-mix(in srgb, #c47a45 45%, var(--stroke)); + background: + linear-gradient(165deg, rgba(196, 122, 69, 0.12) 0%, transparent 45%), + var(--bg-panel); +} + +.lb-place-3 .lb-podium-rank { + color: #d4925c; +} + +.lb-place-3 .lb-podium-frame { + border-color: color-mix(in srgb, #c47a45 55%, var(--stroke)); +} + +.lb-place-3 .lb-podium-title, +.lb-place-3 .lb-podium-bonus { + color: #d4925c; +} + +.lb-place-3:hover, +.lb-place-3:focus-visible { + border-color: #d4925c; +} + +.lb-list { + border-top: 1px solid var(--stroke); + padding-top: 1rem; +} + +.lb-list-head { + display: grid; + grid-template-columns: 3rem 2.35rem 1fr auto auto; + gap: 0.75rem; + align-items: center; + padding: 0 0.85rem 0.55rem; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.lb-list-head span:nth-child(2) { + grid-column: 2 / 4; +} + +.lb-list-head-wealth { + text-align: right; +} + +.lb-list-rows { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.lb-row { + appearance: none; + width: 100%; + display: grid; + grid-template-columns: 3rem 2.35rem minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + padding: 0.65rem 0.85rem; + border: 1px solid transparent; + border-bottom-color: var(--stroke); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.lb-row:hover, +.lb-row:focus-visible { + outline: none; + background: rgba(61, 214, 198, 0.05); + border-color: color-mix(in srgb, var(--accent) 25%, transparent); +} + +.lb-row-rank { + font-family: var(--font-display); + font-weight: 600; + color: var(--accent-dim); +} + +.lb-row-name { + font-family: var(--font-display); + font-weight: 560; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lb-row-meta { + font-size: 0.78rem; + display: none; +} + +.lb-row .lb-wealth-compact { + text-align: right; +} + +@media (min-width: 640px) { + .lb-row-meta { + display: block; + } +} + +@media (max-width: 720px) { + .lb-podium { + grid-template-columns: 1fr; + gap: 0.65rem; + } + + .lb-place-1 { + order: -1; + } + + .lb-list-head { + grid-template-columns: 2.5rem 2.35rem 1fr auto; + } + + .lb-list-head span:nth-child(2) { + grid-column: 2 / 3; + } + + .lb-row { + grid-template-columns: 2.5rem 2.35rem minmax(0, 1fr) auto; + } + + .lb-row-meta { + display: none; + } +} + .admin-tabs { display: flex; gap: 0.5rem; @@ -767,6 +1526,22 @@ h3 { outline-offset: 2px; } +.item-tile.favorite { + box-shadow: inset 0 0 0 1px rgba(228, 174, 57, 0.45); +} + +.item-favorite-badge { + position: absolute; + top: 0.35rem; + right: 0.35rem; + z-index: 1; + color: var(--gold); + font-size: 0.95rem; + line-height: 1; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65); + pointer-events: none; +} + .item-tile.disabled { opacity: 0.4; pointer-events: none; @@ -788,6 +1563,159 @@ h3 { position: relative; } +.mode-card-live { + grid-column: 1 / -1; + min-height: 220px; +} + +.mode-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.mode-card-top h2 { + margin: 0; +} + +.mode-status { + flex-shrink: 0; + font-family: var(--font-display); + font-size: 0.75rem; + color: var(--accent); + border: 1px solid rgba(61, 214, 198, 0.35); + background: rgba(61, 214, 198, 0.08); + padding: 0.2rem 0.45rem; + white-space: nowrap; +} + +.mode-status.spinning { + color: var(--gold); + border-color: rgba(228, 174, 57, 0.4); + background: rgba(228, 174, 57, 0.1); +} + +.mode-blurb { + margin: 0.5rem 0 0.85rem; + font-size: 0.9rem; +} + +.mode-pot-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 12rem)); + gap: 0.75rem 1.5rem; + margin-bottom: 0.85rem; +} + +.mode-pot-stats > div { + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: 0.8rem; +} + +.mode-pot-stats strong { + font-family: var(--font-display); + font-size: 0.95rem; + color: var(--text); +} + +.mode-pot-items { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.mode-pot-thumb { + width: 4.5rem; + height: 4.5rem; + border: 1px solid var(--stroke); + border-top-width: 2px; + background: rgba(0, 0, 0, 0.25); + display: grid; + place-items: center; + overflow: hidden; + font-size: 0.85rem; + font-family: var(--font-display); + color: var(--muted); +} + +.mode-pot-thumb img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.mode-pot-more { + font-size: 0.8rem; +} + +.mode-pot-empty { + margin: 0; + font-size: 0.85rem; +} + +.mode-card-reel { + margin-top: 0.15rem; +} + +.mode-card-winner { + margin: 0.5rem 0 0; + font-size: 0.9rem; + color: var(--gold); +} + +.reel-wrap-compact { + height: 118px; +} + +.reel-wrap-compact.reel-wrap-owners { + height: 148px; +} + +.reel-wrap-compact::before, +.reel-wrap-compact::after { + width: 48px; +} + +.reel-wrap-compact .reel-item { + flex: 0 0 72px; + width: 72px; + padding: 0.25rem; + gap: 0.2rem; + border-top-width: 3px; +} + +.reel-wrap-compact .reel-item .glyph { + font-size: 1rem; +} + +.reel-wrap-compact .reel-img { + max-height: 2.4rem; +} + +.reel-wrap-compact .reel-item .label { + font-size: 0.62rem; +} + +.reel-wrap-compact .reel-owner { + gap: 0.2rem; +} + +.reel-wrap-compact .reel-owner-avatar, +.reel-wrap-compact .reel-owner-fallback { + width: 0.9rem; + height: 0.9rem; + font-size: 0.55rem; +} + +.reel-wrap-compact .reel-owner-name { + font-size: 0.58rem; + max-width: 3.5rem; +} + a.mode-card:hover { transform: translateY(-3px); border-color: var(--accent-dim); @@ -960,6 +1888,305 @@ a.mode-card:hover { gap: 0.55rem; } +.case-stats-block { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--stroke); +} + +.catalog-badges-block { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--stroke); +} + +.catalog-badges-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.65rem; +} + +.catalog-badges-head h3 { + margin: 0; + font-family: var(--font-display); + font-size: 0.95rem; + font-weight: 600; +} + +.catalog-badges-luck { + font-family: var(--font-display); + font-size: 0.75rem; + color: var(--accent); + border: 1px solid rgba(61, 214, 198, 0.35); + background: rgba(61, 214, 198, 0.08); + padding: 0.15rem 0.45rem; + white-space: nowrap; +} + +.catalog-badges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.catalog-badge { + position: relative; + width: 3.4rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + text-decoration: none; + color: inherit; +} + +a.catalog-badge:hover .catalog-badge-name { + color: var(--accent); +} + +.catalog-badge img, +.catalog-badge-fallback { + width: 2.75rem; + height: 2.75rem; + object-fit: contain; + border: 1px solid rgba(61, 214, 198, 0.4); + background: rgba(61, 214, 198, 0.08); + display: grid; + place-items: center; + color: var(--accent); + font-size: 1rem; +} + +.catalog-badge-check { + position: absolute; + top: -0.2rem; + right: 0.1rem; + font-size: 0.65rem; + font-weight: 700; + color: var(--bg-deep); + background: var(--accent); + width: 0.95rem; + height: 0.95rem; + display: grid; + place-items: center; + line-height: 1; +} + +.catalog-badge-name { + font-size: 0.62rem; + color: var(--muted); + text-align: center; + line-height: 1.15; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.presence-online { + color: #3ecf8e; +} + +.presence-offline { + color: var(--muted); +} + +.achievement-badges-block { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--stroke); +} + +.achievement-badges-link { + font-size: 0.75rem; + color: var(--accent); + text-decoration: none; +} + +.achievement-badges-link:hover { + text-decoration: underline; +} + +.achievement-badges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.achievement-badge { + position: relative; + width: 3.4rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + text-decoration: none; + color: inherit; +} + +a.achievement-badge:hover .achievement-badge-name { + color: var(--accent); +} + +.achievement-badge-icon { + width: 2.75rem; + height: 2.75rem; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--stroke); + background: color-mix(in srgb, var(--accent) 12%, transparent); + font-family: var(--font-display); + font-size: 0.95rem; + color: var(--accent); +} + +.achievement-badge-name { + font-size: 0.62rem; + color: var(--muted); + text-align: center; + line-height: 1.15; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.achievement-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 0.85rem; + margin-top: 1rem; +} + +.achievement-card { + display: flex; + gap: 0.85rem; + padding: 0.9rem 1rem; + border: 1px solid var(--stroke); + background: color-mix(in srgb, var(--panel) 88%, transparent); +} + +.achievement-card.locked { + opacity: 0.55; +} + +.achievement-card.unlocked .achievement-card-icon { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, var(--stroke)); + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.achievement-card-icon { + flex-shrink: 0; + width: 2.75rem; + height: 2.75rem; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--stroke); + font-family: var(--font-display); + font-size: 1.1rem; +} + +.achievement-card-body h2 { + margin: 0 0 0.25rem; + font-family: var(--font-display); + font-size: 1rem; + font-weight: 600; +} + +.achievement-card-body p { + margin: 0 0 0.45rem; + font-size: 0.85rem; +} + +.achievement-card-meta { + font-size: 0.72rem; + color: var(--muted); +} + +.achievement-card-meta.locked-label { + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.case-stats-block h3 { + margin: 0 0 0.65rem; + font-family: var(--font-display); + font-size: 0.95rem; + font-weight: 600; +} + +.case-stats-empty { + margin: 0; + font-size: 0.85rem; +} + +.case-stat-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.case-stat-row { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 0.55rem 0.75rem; + padding: 0.45rem 0.55rem; + border: 1px solid var(--stroke); + border-radius: 8px; + background: rgba(0, 0, 0, 0.12); + color: inherit; + text-decoration: none; + transition: border-color 150ms ease, background 150ms ease; +} + +.case-stat-row:hover { + border-color: var(--accent-dim); + background: rgba(61, 214, 198, 0.06); +} + +.case-stat-visual { + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + font-size: 1rem; + color: var(--accent); +} + +.case-stat-visual img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.case-stat-name { + font-family: var(--font-display); + font-size: 0.9rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.case-stat-count { + font-size: 0.85rem; + white-space: nowrap; +} + +.case-stat-keys { + font-size: 0.8rem; + white-space: nowrap; +} + .ok-msg { color: var(--accent); font-size: 0.9rem; @@ -1099,6 +2326,98 @@ a.mode-card:hover { margin-bottom: 1rem; } +.inv-toolbar-left { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.inv-count { + font-size: 0.85rem; +} + +.inv-pager { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-top: 1.25rem; + flex-wrap: wrap; +} + +.inv-bulk-sell { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + margin-bottom: 1rem; + padding: 0.75rem 0; + border-top: 1px solid var(--stroke); +} + +.inv-bulk-label { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-size: 0.9rem; + color: var(--text); +} + +.inv-bulk-label input[type='number'] { + width: 5.5rem; + background: var(--panel); + border: 1px solid var(--stroke); + color: var(--text); + padding: 0.4rem 0.55rem; + font-family: inherit; +} + +.inv-bulk-preview { + font-size: 0.85rem; +} + +.inv-auto-sell-box { + margin-left: auto; + border: 1px solid rgba(61, 214, 198, 0.35); + background: rgba(61, 214, 198, 0.08); + border-radius: 8px; + padding: 0.45rem 0.85rem; + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.inv-auto-sell-at { + font-family: var(--font-display); + font-size: 0.85rem; + white-space: nowrap; +} + +.inv-auto-sell-toggle { + display: inline-flex; + align-items: center; + gap: 0.55rem; + cursor: pointer; + font-family: var(--font-display); + font-size: 0.9rem; + color: var(--text); + user-select: none; +} + +.inv-auto-sell-toggle input[type='checkbox'] { + width: 1.15rem; + height: 1.15rem; + margin: 0; + accent-color: var(--accent); + cursor: pointer; +} + +.inv-auto-sell-toggle span { + white-space: nowrap; +} + .sort-toggle { display: inline-flex; border: 1px solid var(--stroke); @@ -1150,3 +2469,283 @@ a.mode-card:hover { background-position: -100% 0; } } + +/* Catalog */ +.catalog-luck-pill { + font-family: var(--font-display); + color: var(--accent); + border: 1px solid rgba(61, 214, 198, 0.35); + background: rgba(61, 214, 198, 0.08); + padding: 0.35rem 0.7rem; + white-space: nowrap; +} + +.catalog-chapters { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.catalog-chapter { + border: 1px solid var(--stroke); + background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel)); + padding: 1rem 1.15rem 1.15rem; +} + +.catalog-chapter.completed { + border-color: rgba(61, 214, 198, 0.4); +} + +.catalog-chapter-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.85rem; + flex-wrap: wrap; +} + +.catalog-chapter-title { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.catalog-chapter-title h2 { + margin: 0; + font-size: 1.15rem; +} + +.catalog-chapter-title h2 a { + color: var(--text); +} + +.catalog-chapter-title h2 a:hover { + color: var(--accent); +} + +.catalog-chapter-title p { + margin: 0.2rem 0 0; + font-size: 0.85rem; +} + +.catalog-case-thumb { + width: 3rem; + height: 3rem; + object-fit: contain; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--stroke); + flex-shrink: 0; +} + +.catalog-case-thumb.placeholder { + display: grid; + place-items: center; + color: var(--accent); + font-size: 1.2rem; +} + +.catalog-complete-badge { + font-family: var(--font-display); + font-size: 0.8rem; + color: var(--accent); + border: 1px solid rgba(61, 214, 198, 0.35); + background: rgba(61, 214, 198, 0.08); + padding: 0.25rem 0.55rem; + white-space: nowrap; +} + +.catalog-progress-bar { + width: 7rem; + height: 0.35rem; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--stroke); + flex-shrink: 0; +} + +.catalog-progress-fill { + height: 100%; + background: var(--accent); + transition: width 200ms ease; +} + +.catalog-slots { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); + gap: 0.5rem; +} + +.catalog-slot { + aspect-ratio: 1; + border: 1px solid var(--stroke); + background: rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.35rem; + overflow: hidden; + position: relative; +} + +.catalog-slot.filled { + border-top: 3px solid var(--rarity, var(--stroke)); +} + +.catalog-slot.empty { + opacity: 0.55; +} + +.catalog-slot img { + width: 70%; + height: 55%; + object-fit: contain; +} + +.catalog-slot-fallback { + font-family: var(--font-display); + font-size: 0.95rem; + color: var(--muted); +} + +.catalog-slot-name { + font-size: 0.62rem; + color: var(--muted); + text-align: center; + line-height: 1.15; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.catalog-slot-empty { + font-family: var(--font-display); + font-size: 1.4rem; + color: var(--muted); +} + + +/* Prestige */ +.prestige-count-pill { + font-family: var(--font-display); + color: var(--gold); + border: 1px solid rgba(228, 174, 57, 0.4); + background: rgba(228, 174, 57, 0.08); + padding: 0.35rem 0.7rem; + white-space: nowrap; +} + +.prestige-totals { + display: grid; + grid-template-columns: repeat(3, minmax(0, 10rem)); + gap: 0.75rem 1.5rem; + margin-bottom: 1.25rem; +} + +.prestige-totals > div { + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: 0.85rem; +} + +.prestige-totals strong { + font-family: var(--font-display); + font-size: 1.05rem; + color: var(--text); +} + +.prestige-cost-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 1rem; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.prestige-choices { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.85rem; + margin-bottom: 1.25rem; +} + +.prestige-choice { + text-align: left; + background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel)); + border: 1px solid var(--stroke); + color: inherit; + padding: 1rem; + cursor: pointer; + font: inherit; +} + +.prestige-choice:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.prestige-choice.selected { + border-color: rgba(228, 174, 57, 0.55); + box-shadow: inset 0 0 0 1px rgba(228, 174, 57, 0.25); +} + +.prestige-choice h2 { + margin: 0 0 0.4rem; + font-size: 1.05rem; +} + +.prestige-choice p { + margin: 0 0 0.75rem; + font-size: 0.85rem; +} + +.prestige-choice-meta { + display: flex; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.8rem; +} + +.prestige-delta { + color: var(--gold); + font-family: var(--font-display); +} + +.prestige-confirm { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 32rem; +} + +.prestige-confirm label { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.prestige-history { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--stroke); +} + +.prestige-history h2 { + margin: 0 0 0.65rem; + font-size: 1.05rem; +} + +.prestige-profile-block { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--stroke); +} + +.prestige-profile-stats { + margin-top: 0.35rem; +} diff --git a/client/src/inventoryEvents.js b/client/src/inventoryEvents.js new file mode 100644 index 0000000..79395be --- /dev/null +++ b/client/src/inventoryEvents.js @@ -0,0 +1,10 @@ +export function notifyInventoryChanged() { + if (typeof window === 'undefined') return; + window.dispatchEvent(new Event('inventory:changed')); +} + +export function onInventoryChanged(handler) { + if (typeof window === 'undefined') return () => {}; + window.addEventListener('inventory:changed', handler); + return () => window.removeEventListener('inventory:changed', handler); +} diff --git a/client/src/pages/AchievementsPage.jsx b/client/src/pages/AchievementsPage.jsx new file mode 100644 index 0000000..48c7815 --- /dev/null +++ b/client/src/pages/AchievementsPage.jsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { Link, Navigate } from 'react-router-dom'; +import { api } from '../api'; +import { useAuth } from '../AuthContext'; + +function formatUnlockedAt(iso) { + if (!iso) return null; + try { + return new Date(iso).toLocaleString(); + } catch { + return null; + } +} + +export default function AchievementsPage() { + const { user, loading } = useAuth(); + const [achievements, setAchievements] = useState([]); + const [unlocked, setUnlocked] = useState(0); + const [total, setTotal] = useState(0); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(true); + + useEffect(() => { + if (!user || user.role === 'admin') return undefined; + let cancelled = false; + (async () => { + try { + const data = await api.achievements(); + if (cancelled) return; + setAchievements(data.achievements || []); + setUnlocked(data.unlocked || 0); + setTotal(data.total || 0); + } catch (err) { + if (!cancelled) setError(err.message); + } finally { + if (!cancelled) setBusy(false); + } + })(); + return () => { + cancelled = true; + }; + }, [user?.id, user?.role]); + + if (loading || busy) return

Loading…

; + if (!user) return ; + if (user.role === 'admin') return ; + + return ( +
+
+
+

Achievements

+

+ Unlock badges by playing. Earned badges show on your{' '} + profile. +

+
+
+ {unlocked}/{total} unlocked +
+
+ + {error &&
{error}
} + + {achievements.length === 0 && !error && ( +
No achievements defined yet.
+ )} + +
+ {achievements.map((a) => { + const done = Boolean(a.unlockedAt); + return ( +
+
+ {a.icon || '★'} +
+
+

{a.name}

+

{a.description}

+ {done ? ( + + Unlocked {formatUnlockedAt(a.unlockedAt)} + + ) : ( + Locked + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/client/src/pages/Admin.jsx b/client/src/pages/Admin.jsx index 7aff016..1c0ad2e 100644 --- a/client/src/pages/Admin.jsx +++ b/client/src/pages/Admin.jsx @@ -432,7 +432,7 @@ export default function Admin() {
{error &&
{error}
} @@ -121,27 +140,67 @@ export default function ProfilePage() {

Stats

{profile ? ( -
    -
  • - Joined{' '} - {new Date(profile.createdAt).toLocaleDateString()} -
  • -
  • - Inventory{' '} - - {profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr - -
  • -
  • - Cases opened {profile.casesOpened} -
  • -
  • - Jackpot wins {profile.jackpotWins} -
  • -
  • - Duel wins {profile.duelWins} -
  • -
+ <> +
    +
  • + Status{' '} + + {profile.presence?.isConnected ? 'Online' : 'Offline'} + +
  • +
  • + Play time{' '} + {formatPlayTime(profile.presence?.playTimeSeconds)} +
  • +
  • + Last connection{' '} + + {formatLastConnection(profile.presence?.lastConnection)} + +
  • +
  • + Joined{' '} + {new Date(profile.createdAt).toLocaleDateString()} +
  • +
  • + Inventory{' '} + + {profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr + +
  • +
  • + Cases opened {profile.casesOpened} +
  • +
  • + Jackpot wins {profile.jackpotWins} +
  • +
  • + Duel wins {profile.duelWins} +
  • +
+ + + + {profile.caseOpens?.length > 0 && ( +
+

Opens by case

+ +
+ )} + ) : (

Loading stats…

)} @@ -194,6 +253,16 @@ export default function ProfilePage() { + +
+

Session

+

+ Sign out of CaseForge on this device. +

+ +
); } diff --git a/client/src/pages/Register.jsx b/client/src/pages/Register.jsx index 3754310..bad265f 100644 --- a/client/src/pages/Register.jsx +++ b/client/src/pages/Register.jsx @@ -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} /> + {error &&
{error}
}