feat: Add Admin and Game pages with stats, user management, and slot game functionality

- Implement AdminPage for managing user stats, transactions, and win chance settings.
- Create GamePage for slot game mechanics, including wallet management and leaderboard display.
- Update API to support new admin stats and transaction endpoints.
- Integrate React Router for navigation between pages.
- Enhance user experience with error handling and loading states.
This commit is contained in:
2025-11-23 12:44:42 +01:00
parent f1c50dadcd
commit 0ad33488dc
9 changed files with 841 additions and 1649 deletions

View File

@@ -1,6 +1,8 @@
// backend/src/routes/admin.ts
import { Router } from "express";
import { pool, query } from "../db";
import { config } from "../config";
import { getWinChance, setWinChance } from "../services/gameSettings";
export const adminRouter = Router();
@@ -55,13 +57,89 @@ async function requireAdmin(req: any, res: any, next: any) {
}
}
// NEUE ROUTE: GET /admin/users/search?q=...
// Sucht Benutzer nach ihrem Discord-Namen (case-insensitive)
// --- NEUE ENDPUNKTE ---
// GET /admin/stats -> Liefert Dashboard-Statistiken
adminRouter.get("/stats", requireAdmin, async (req, res) => {
try {
// 1. "Online" User (aktiv in den letzten 5 Minuten)
const onlineUsersRes = await pool.query(
"SELECT COUNT(*) FROM session WHERE expire > NOW() - INTERVAL '5 minutes'"
);
const online_users = parseInt(onlineUsersRes.rows[0].count, 10);
// 2. Gesamte "Geldmenge" (Inflation)
const supplyRes = await pool.query(
"SELECT SUM(balance) as total_supply FROM wallets"
);
const total_supply = parseInt(supplyRes.rows[0].total_supply, 10);
// 3. Gesamtzahl der registrierten User
const totalUsersRes = await pool.query("SELECT COUNT(*) FROM users");
const total_users = parseInt(totalUsersRes.rows[0].count, 10);
res.json({
online_users,
total_users,
total_supply,
});
} catch (err) {
console.error("GET /admin/stats error:", err);
res.status(500).json({ error: "Failed to fetch stats" });
}
});
// GET /admin/user/:userId/transactions -> Holt die letzten 50 Transaktionen eines Users
adminRouter.get("/user/:userId/transactions", requireAdmin, async (req, res) => {
const userId = parseInt(req.params.userId, 10);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).json({ error: "Invalid userId" });
}
try {
const { rows } = await pool.query(
`
SELECT id, amount, reason, created_at
FROM wallet_transactions
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50
`,
[userId]
);
res.json(rows);
} catch (err) {
console.error("GET /admin/user/:userId/transactions error:", err);
res.status(500).json({ error: "Failed to fetch transactions" });
}
});
// GET /admin/settings/win-chance -> Holt die aktuelle Gewinnchance
adminRouter.get("/settings/win-chance", requireAdmin, (req, res) => {
res.json({ win_chance_modifier: getWinChance() });
});
// POST /admin/settings/win-chance -> Setzt eine neue Gewinnchance
adminRouter.post("/settings/win-chance", requireAdmin, (req, res) => {
const { win_chance_modifier } = req.body;
const modifier = parseFloat(win_chance_modifier);
if (!Number.isFinite(modifier) || modifier < 0.1 || modifier > 5.0) {
return res.status(400).json({ error: "Invalid modifier (must be between 0.1 and 5.0)" });
}
setWinChance(modifier);
res.json({ win_chance_modifier: getWinChance() });
});
// --- ALTE ENDPUNKTE BLEIBEN ---
// GET /admin/users/search?q=...
adminRouter.get("/users/search", requireAdmin, async (req, res) => {
const searchQuery = req.query.q as string | undefined;
if (!searchQuery || searchQuery.trim().length < 2) {
// Suchen erst ab 2 Zeichen, um die DB nicht zu überlasten
return res.json([]);
}
@@ -83,7 +161,7 @@ adminRouter.get("/users/search", requireAdmin, async (req, res) => {
ORDER BY discord_name
LIMIT 10
`,
[`%${searchQuery.trim()}%`] // ILIKE für case-insensitive Suche mit Wildcards
[`%${searchQuery.trim()}%`]
);
res.json(rows);
@@ -93,7 +171,7 @@ adminRouter.get("/users/search", requireAdmin, async (req, res) => {
}
});
// GET /admin/me -> zeigt ob aktueller User Admin ist
// GET /admin/me
adminRouter.get("/me", requireAuth, async (req: any, res) => {
try {
const user = await getSessionUser(req);
@@ -114,7 +192,6 @@ adminRouter.get("/me", requireAuth, async (req: any, res) => {
});
// GET /admin/user/by-discord/:discordId
// Sucht User + Wallet per Discord-ID
adminRouter.get(
"/user/by-discord/:discordId",
requireAdmin,
@@ -166,7 +243,6 @@ adminRouter.get(
);
// POST /admin/user/:userId/adjust-balance
// Body: { amount: number, reason?: string }
adminRouter.post(
"/user/:userId/adjust-balance",
requireAdmin,
@@ -189,10 +265,9 @@ adminRouter.post(
try {
await client.query("BEGIN");
// Wallet holen/erzeugen
const wRes = await client.query<{
user_id: number;
balance: number | string; // Wichtig: Kann auch ein String sein!
balance: number | string;
}>(
`
SELECT user_id, balance
@@ -222,8 +297,6 @@ adminRouter.post(
wallet = wRes.rows[0];
}
// --- DER FIX ---
// Wandle das Guthaben explizit in eine Zahl um, bevor gerechnet wird.
const currentBalance = Number(wallet.balance) || 0;
const newBalance = currentBalance + amount;
@@ -265,7 +338,6 @@ adminRouter.post(
);
// POST /admin/user/:userId/reset-wallet
// Body (optional): { reset_balance_to?: number }
adminRouter.post(
"/user/:userId/reset-wallet",
requireAdmin,

View File

@@ -0,0 +1,13 @@
// backend/src/services/gameSettings.ts
// Wir speichern die Einstellung im Speicher des Servers.
// Für eine echte Produktionsumgebung würde man dies in der Datenbank speichern.
let winChanceModifier = 1.0; // 1.0 = Normal, <1.0 = schlechter, >1.0 = besser
export function getWinChance(): number {
return winChanceModifier;
}
export function setWinChance(modifier: number): void {
console.log(`[GameSettings] Win chance modifier changed from ${winChanceModifier} to ${modifier}`);
winChanceModifier = modifier;
}

View File

@@ -1,5 +1,6 @@
// path: backend/src/services/slotService.ts
// backend/src/services/slotService.ts
import { randomInt } from "crypto";
import { getWinChance } from "./gameSettings"; // Importieren
// backend/src/services/slotService.ts
export type SymbolId =
@@ -68,13 +69,25 @@ export interface SpinResult {
}
function randomSymbol(): SymbolId {
const totalWeight = SYMBOLS.reduce((s, sym) => s + sym.weight, 0);
const modifier = getWinChance();
// Erstelle eine temporäre, modifizierte Symbol-Liste
const modifiedSymbols = SYMBOLS.map(sym => {
let weight = sym.weight;
// Erhöhe die Chance auf wertvolle Symbole und das Buch
if (["MUG", "BARREL", "BARON", "BOOK"].includes(sym.id)) {
weight *= modifier;
}
return { ...sym, weight };
});
const totalWeight = modifiedSymbols.reduce((s, sym) => s + sym.weight, 0);
let r = Math.random() * totalWeight;
for (const sym of SYMBOLS) {
for (const sym of modifiedSymbols) {
r -= sym.weight;
if (r <= 0) return sym.id;
}
return SYMBOLS[0].id;
return modifiedSymbols[0].id;
}
// 5 Walzen x 3 Reihen

View File

@@ -9,7 +9,9 @@
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1",
"recharts": "^2.12.7"
},
"devDependencies": {
"@types/react": "^18.3.3",
@@ -18,4 +20,4 @@
"typescript": "^5.6.3",
"vite": "^5.3.4"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
// frontend/src/api.ts
const API_BASE = "";
async function apiGet<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: "include",
@@ -41,9 +43,6 @@ export interface MeResponse {
discord_name: string;
avatar_url: string | null;
created_at: string;
// Wallet-Daten sind jetzt hier enthalten
balance: number | null;
last_claim_at: string | null;
}
export interface WalletResponse {
@@ -90,6 +89,7 @@ export interface AdminMeResponse {
discord_id: string;
discord_name: string;
}
export interface AdminUserSearchResult {
user_id: number;
discord_id: string;
@@ -106,6 +106,19 @@ export interface AdminUserSummary {
last_claim_at: string | null;
}
export interface AdminStats {
online_users: number;
total_users: number;
total_supply: number;
}
export interface AdminTransaction {
id: number;
amount: number;
reason: string;
created_at: string;
}
// --- Auth / User ---
export async function getMe(): Promise<MeResponse> {
@@ -113,7 +126,6 @@ export async function getMe(): Promise<MeResponse> {
}
export function getLoginUrl(): string {
// Die Route im Backend lautet /auth/discord
return `${API_BASE}/auth/discord`;
}
@@ -190,4 +202,22 @@ export async function adminResetWallet(
`/admin/user/${userId}/reset-wallet`,
{ reset_balance_to: resetBalanceTo }
);
}
export async function getAdminStats(): Promise<AdminStats> {
return apiGet<AdminStats>("/admin/stats");
}
export async function getAdminUserTransactions(userId: number): Promise<AdminTransaction[]> {
return apiGet<AdminTransaction[]>(`/admin/user/${userId}/transactions`);
}
export async function getWinChance(): Promise<{ win_chance_modifier: number }> {
return apiGet<{ win_chance_modifier: number }>("/admin/settings/win-chance");
}
export async function setWinChance(modifier: number): Promise<{ win_chance_modifier: number }> {
return apiPost<{ win_chance_modifier: number }>("/admin/settings/win-chance", {
win_chance_modifier: modifier,
});
}

View File

@@ -1,9 +1,13 @@
// frontend/src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
);

View File

@@ -0,0 +1,225 @@
// frontend/src/pages/AdminPage.tsx
import React, { useEffect, useState } from "react";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar } from 'recharts';
import {
AdminMeResponse,
AdminStats,
getAdminStats,
AdminUserSearchResult,
adminSearchUsers,
adminFindUserByDiscord,
AdminUserSummary,
getAdminUserTransactions,
AdminTransaction,
getWinChance,
setWinChance,
adminAdjustBalance,
adminResetWallet
} from "../api";
interface AdminPageProps {
adminInfo: AdminMeResponse | null;
}
export const AdminPage: React.FC<AdminPageProps> = ({ adminInfo }) => {
const [stats, setStats] = useState<AdminStats | null>(null);
const [statsHistory, setStatsHistory] = useState<AdminStats[]>([]);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<AdminUserSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<AdminUserSummary | null>(null);
const [transactions, setTransactions] = useState<AdminTransaction[]>([]);
const [adjustAmount, setAdjustAmount] = useState<number>(0);
const [adjustReason, setAdjustReason] = useState<string>("");
const [winChance, setWinChance] = useState<number>(1.0);
const [isBusy, setIsBusy] = useState(false);
useEffect(() => {
const fetchAllData = async () => {
try {
const statsData = await getAdminStats();
setStats(statsData);
setStatsHistory(prev => [...prev.slice(-9), statsData]); // Keep last 10 entries
const chanceData = await getWinChance();
setWinChance(chanceData.win_chance_modifier);
} catch (err: any) {
setError(err.message || "Failed to load initial admin data");
}
};
fetchAllData();
const interval = setInterval(fetchAllData, 30000); // Refresh stats every 30 seconds
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (searchQuery.trim().length < 2) {
setSearchResults([]);
return;
}
const debounceTimer = setTimeout(async () => {
setIsSearching(true);
try {
const results = await adminSearchUsers(searchQuery);
setSearchResults(results);
} catch (err: any) { setError(err.message || "Suche fehlgeschlagen"); }
finally { setIsSearching(false); }
}, 300);
return () => clearTimeout(debounceTimer);
}, [searchQuery]);
const handleSelectUser = async (user: AdminUserSearchResult) => {
setIsBusy(true);
setError(null);
setSearchQuery(user.discord_name);
setSearchResults([]);
try {
const [fullUserData, transactionsData] = await Promise.all([
adminFindUserByDiscord(user.discord_id),
getAdminUserTransactions(user.user_id)
]);
setSelectedUser(fullUserData);
setTransactions(transactionsData);
setAdjustAmount(0);
setAdjustReason("");
} catch (err: any) {
setError(err.message || "User-Daten konnten nicht geladen werden");
setSelectedUser(null);
} finally {
setIsBusy(false);
}
};
const handleUpdateWinChance = async (modifier: number) => {
try {
const res = await setWinChance(modifier);
setWinChance(res.win_chance_modifier);
} catch (err: any) {
setError(err.message || "Einstellung konnte nicht gespeichert werden");
}
};
const handleAdjustBalance = async () => {
if (!selectedUser) return;
setIsBusy(true);
try {
const res = await adminAdjustBalance(selectedUser.user_id, adjustAmount, adjustReason);
setSelectedUser(prev => prev ? { ...prev, balance: res.balance } : null);
setAdjustAmount(0);
setAdjustReason("");
// Refresh transactions
const transactionsData = await getAdminUserTransactions(selectedUser.user_id);
setTransactions(transactionsData);
} catch (err: any) {
setError(err.message || "Anpassung fehlgeschlagen");
} finally {
setIsBusy(false);
}
};
return (
<div style={{ color: '#f5f5f5' }}>
{error && <div style={{ color: 'red', marginBottom: '1rem' }}>{error}</div>}
{/* Stats Section */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px' }}>
<h4>Online User (5min)</h4>
<p style={{ fontSize: '2rem', margin: 0 }}>{stats?.online_users ?? '...'}</p>
</div>
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px' }}>
<h4>Registrierte User</h4>
<p style={{ fontSize: '2rem', margin: 0 }}>{stats?.total_users ?? '...'}</p>
</div>
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px' }}>
<h4>Bierkästen im Umlauf</h4>
<p style={{ fontSize: '2rem', margin: 0 }}>{stats?.total_supply.toLocaleString('de-DE') ?? '...'}</p>
</div>
</div>
{/* Graph Section */}
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px', marginBottom: '24px' }}>
<h3>Geldmengen-Verlauf (Inflation)</h3>
<div style={{ width: '100%', height: 300 }}>
<ResponsiveContainer>
<BarChart data={statsHistory}>
<XAxis dataKey="timestamp" tickFormatter={(ts) => new Date().toLocaleTimeString()} />
<YAxis allowDecimals={false} />
<Tooltip contentStyle={{ background: '#0b0b10', border: '1px solid #555' }} />
<Legend />
<Bar dataKey="total_supply" name="Bierkästen im Umlauf" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Management Section */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '24px' }}>
{/* Left Column: Settings & User Search */}
<div>
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px', marginBottom: '16px' }}>
<h4>Globale Einstellungen</h4>
<label>Gewinnchance-Modifikator: <strong>{winChance.toFixed(1)}x</strong></label>
<input
type="range"
min="0.1"
max="5.0"
step="0.1"
value={winChance}
onChange={(e) => setWinChance(parseFloat(e.target.value))}
onMouseUp={(e) => handleUpdateWinChance(parseFloat(e.currentTarget.value))}
style={{ width: '100%', marginTop: '8px' }}
/>
</div>
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px', position: 'relative' }}>
<h4>User Management</h4>
<input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="User suchen..." style={{ width: '100%', padding: '8px', background: '#0b0b10', border: '1px solid #555', color: 'white', borderRadius: '4px' }} />
{searchResults.length > 0 && (
<div style={{ position: 'absolute', background: '#2c2c4a', border: '1px solid #555', borderRadius: '4px', width: '100%', zIndex: 10 }}>
{searchResults.map(u => <div key={u.user_id} onClick={() => handleSelectUser(u)} style={{ padding: '8px', cursor: 'pointer' }}>{u.discord_name}</div>)}
</div>
)}
</div>
</div>
{/* Right Column: Selected User Details */}
<div>
{selectedUser ? (
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px' }}>
<h4>Details für: {selectedUser.discord_name}</h4>
<p>Guthaben: {selectedUser.balance.toLocaleString('de-DE')} Bierkästen</p>
{/* Adjust Balance */}
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '16px' }}>
<input type="number" value={adjustAmount} onChange={e => setAdjustAmount(parseInt(e.target.value))} style={{ padding: '8px', background: '#0b0b10', border: '1px solid #555', color: 'white', borderRadius: '4px' }} />
<input type="text" value={adjustReason} onChange={e => setAdjustReason(e.target.value)} placeholder="Grund" style={{ flex: 1, padding: '8px', background: '#0b0b10', border: '1px solid #555', color: 'white', borderRadius: '4px' }} />
<button onClick={handleAdjustBalance} disabled={isBusy}>Buchen</button>
</div>
{/* Transactions */}
<h5>Letzte Transaktionen</h5>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{transactions.map(t => (
<div key={t.id} style={{ display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #333', padding: '4px 0' }}>
<span>{new Date(t.created_at).toLocaleString('de-DE')}</span>
<span>{t.reason}</span>
<span style={{ color: t.amount >= 0 ? 'lightgreen' : 'salmon' }}>{t.amount > 0 ? '+' : ''}{t.amount.toLocaleString('de-DE')}</span>
</div>
))}
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px', background: '#1a1a2e', borderRadius: '8px' }}>
<p>Bitte einen User suchen und auswählen, um Details anzuzeigen.</p>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,400 @@
// frontend/src/pages/GamePage.tsx
import React, { useEffect, useRef, useState, useMemo } from "react";
import {
getWallet,
claimWallet,
spinBookOfBier,
getBalanceLeaderboard,
getBigWinLeaderboard,
MeResponse,
WalletResponse,
SlotSpinResponse,
BalanceLeaderboardEntry,
BigWinLeaderboardEntry,
} from "../api";
// Helper-Funktionen und Konstanten, die aus der alten App.tsx kopiert wurden
function formatMs(ms: number): string {
if (ms <= 0) return "jetzt";
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const parts: string[] = [];
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (s > 0 && h === 0) parts.push(`${s}s`);
return parts.join(" ");
}
function renderSymbol(sym: string): string {
switch (sym) {
case "TEN": return "10";
case "J": return "J";
case "Q": return "Q";
case "K": return "K";
case "A": return "A";
case "MUG": return "🍺";
case "BARREL": return "🛢️";
case "BARON": return "👑";
case "BOOK": return "📖";
default: return sym;
}
}
const ALL_SYMBOLS = ["TEN", "J", "Q", "K", "A", "MUG", "BARREL", "BARON", "BOOK"];
const PAYLINES: [number, number][][] = [
[[0, 1],[1, 1],[2, 1],[3, 1],[4, 1]], [[0, 0],[1, 0],[2, 0],[3, 0],[4, 0]],
[[0, 2],[1, 2],[2, 2],[3, 2],[4, 2]], [[0, 0],[1, 1],[2, 2],[3, 1],[4, 0]],
[[0, 2],[1, 1],[2, 0],[3, 1],[4, 2]], [[0, 0],[1, 1],[2, 2],[3, 2],[4, 2]],
[[0, 2],[1, 1],[2, 0],[3, 0],[4, 0]], [[0, 1],[1, 0],[2, 1],[3, 2],[4, 1]],
[[0, 1],[1, 2],[2, 1],[3, 0],[4, 1]], [[0, 0],[1, 1],[2, 0],[3, 1],[4, 0]],
];
function createRandomGrid(cols = 5, rows = 3): string[][] {
const grid: string[][] = [];
for (let c = 0; c < cols; c++) {
const col: string[] = [];
for (let r = 0; r < rows; r++) {
col.push(ALL_SYMBOLS[Math.floor(Math.random() * ALL_SYMBOLS.length)]);
}
grid.push(col);
}
return grid;
}
function advanceReels(prev: string[][] | null, reelStopped: boolean[]): string[][] {
const cols = 5, rows = 3;
const base = prev && prev.length === cols ? prev : createRandomGrid(cols, rows);
const next: string[][] = [];
for (let c = 0; c < cols; c++) {
if (reelStopped[c]) {
next.push([...base[c]]);
continue;
}
const col = base[c] || [];
const topNew = ALL_SYMBOLS[Math.floor(Math.random() * ALL_SYMBOLS.length)];
const mid = col[0] ?? topNew;
const bottom = col[1] ?? mid;
next.push([topNew, mid, bottom].slice(0, rows));
}
return next;
}
const MIN_SPIN_MS = 2458;
const REEL_STOP_STEP_MS = 180;
interface GamePageProps {
me: MeResponse | null;
}
export const GamePage: React.FC<GamePageProps> = ({ me }) => {
const [wallet, setWallet] = useState<WalletResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [claiming, setClaiming] = useState(false);
const [slotBet, setSlotBet] = useState<number>(10);
const [slotSpinning, setSlotSpinning] = useState(false);
const [lastSpin, setLastSpin] = useState<SlotSpinResponse | null>(null);
const [displayGrid, setDisplayGrid] = useState<string[][] | null>(null);
const [reelStopped, setReelStopped] = useState<boolean[]>([false, false, false, false, false]);
const reelStoppedRef = useRef<boolean[]>([false, false, false, false, false]);
const spinIntervalRef = useRef<number | null>(null);
const spinStartTimeRef = useRef<number | null>(null);
const pendingResultRef = useRef<SlotSpinResponse | null>(null);
const spinAudioRef = useRef<HTMLAudioElement | null>(null);
const [balanceLb, setBalanceLb] = useState<BalanceLeaderboardEntry[] | null>(null);
const [bigWinLb, setBigWinLb] = useState<BigWinLeaderboardEntry[] | null>(null);
const [lbLoading, setLbLoading] = useState(false);
const [lbError, setLbError] = useState<string | null>(null);
const updateReelStopped = (updater: (prev: boolean[]) => boolean[]) => {
setReelStopped((prev) => {
const next = updater(prev);
reelStoppedRef.current = next;
return next;
});
};
async function loadGameData() {
if (!me) return;
try {
setError(null);
const walletRes = await getWallet();
setWallet(walletRes);
if (!displayGrid) setDisplayGrid(createRandomGrid());
} catch (err: any) {
setError(err.message || "Fehler beim Laden der Spieldaten");
}
}
async function loadLeaderboard() {
try {
setLbLoading(true);
setLbError(null);
const [balance, bigwin] = await Promise.all([getBalanceLeaderboard(), getBigWinLeaderboard()]);
setBalanceLb(balance);
setBigWinLb(bigwin);
} catch (err: any) {
setLbError(err.message || "Fehler beim Laden des Leaderboards");
} finally {
setLbLoading(false);
}
}
useEffect(() => {
spinAudioRef.current = new Audio("/sounds/spin.mp3");
if (spinAudioRef.current) {
spinAudioRef.current.loop = false;
spinAudioRef.current.volume = 0.8;
}
return () => {
if (spinIntervalRef.current !== null) window.clearInterval(spinIntervalRef.current);
if (spinAudioRef.current) spinAudioRef.current.pause();
};
}, []);
useEffect(() => {
if (me) {
loadGameData();
loadLeaderboard();
} else {
setWallet(null);
setBalanceLb(null);
setBigWinLb(null);
}
}, [me]);
const handleClaim = async () => {
setClaiming(true);
try {
const res = await claimWallet();
setWallet(res);
loadLeaderboard();
} catch (err: any) {
setError(err.message || "Claim fehlgeschlagen");
} finally {
setClaiming(false);
}
};
const playSpinAudio = () => {
const audio = spinAudioRef.current;
if (!audio) return;
try {
audio.pause();
audio.currentTime = 0;
const p = audio.play();
if (p && typeof p.catch === "function") p.catch(() => {});
} catch {}
};
const winningPositions = useMemo(() => {
const set = new Set<string>();
if (!lastSpin) return set;
lastSpin.line_wins.forEach((lw) => {
const line = PAYLINES[lw.lineIndex];
for (let i = 0; i < lw.count && i < line.length; i++) {
const [col, row] = line[i];
set.add(`${col}-${row}`);
}
});
return set;
}, [lastSpin]);
const handleSpin = async () => {
if (!wallet) return;
if (slotBet <= 0) { setError("Einsatz muss > 0 sein"); return; }
if (slotBet > wallet.balance) { setError("Nicht genug Bierkästen für diesen Einsatz"); return; }
setSlotSpinning(true);
setError(null);
pendingResultRef.current = null;
spinStartTimeRef.current = Date.now();
updateReelStopped(() => [false, false, false, false, false]);
playSpinAudio();
if (spinIntervalRef.current !== null) window.clearInterval(spinIntervalRef.current);
spinIntervalRef.current = window.setInterval(() => {
setDisplayGrid((prev) => advanceReels(prev, reelStoppedRef.current));
}, 70);
try {
const res = await spinBookOfBier(slotBet);
pendingResultRef.current = res;
const start = spinStartTimeRef.current || Date.now();
const elapsed = Date.now() - start;
const baseDelay = Math.max(0, MIN_SPIN_MS - elapsed);
for (let reelIndex = 0; reelIndex < 5; reelIndex++) {
const delay = baseDelay + reelIndex * REEL_STOP_STEP_MS;
window.setTimeout(() => {
const result = pendingResultRef.current;
if (!result) return;
setDisplayGrid((prev) => {
const current = prev || createRandomGrid();
const next = current.map((col) => [...col]);
next[reelIndex] = [...result.grid[reelIndex]];
return next;
});
updateReelStopped((prev) => {
const next = [...prev];
next[reelIndex] = true;
return next;
});
if (reelIndex === 4) {
if (spinIntervalRef.current !== null) window.clearInterval(spinIntervalRef.current);
spinIntervalRef.current = null;
setLastSpin(result);
setWallet((prev) => prev ? { ...prev, balance: result.balance_after } : null);
setSlotSpinning(false);
loadLeaderboard();
}
}, delay);
}
} catch (err: any) {
if (spinIntervalRef.current !== null) window.clearInterval(spinIntervalRef.current);
if (spinAudioRef.current) spinAudioRef.current.pause();
setSlotSpinning(false);
setError(err.message || "Spin fehlgeschlagen");
}
};
if (!me) {
return (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<h2>Willkommen im Bierbaron Casino!</h2>
<p>Bitte logge dich mit Discord ein, um zu spielen.</p>
</div>
);
}
if (!wallet) {
return <div style={{ textAlign: 'center', padding: '40px 0' }}>Lade Spieldaten...</div>;
}
const gridToShow = displayGrid;
const isBigWin = lastSpin && lastSpin.win_amount >= lastSpin.bet_amount * 20;
return (
<>
{error && <div style={{ marginBottom: 12, padding: "8px 10px", borderRadius: 8, background: "rgba(255,0,0,0.12)", color: "#ff9d9d", fontSize: "0.85rem" }}>{error}</div>}
{/* Top-Karten: Wallet + Claim */}
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: "16px", alignItems: "stretch", marginBottom: 10 }}>
<div style={{ flex: "1 1 260px", maxWidth: 380, padding: "16px", borderRadius: 12, background: "linear-gradient(145deg, #171725, #11111b)", border: "1px solid rgba(255,255,255,0.04)" }}>
<h2 style={{ marginTop: 0, fontSize: "1.1rem", textAlign: "center" }}>Dein Bierkonto</h2>
<p style={{ fontSize: "2.4rem", margin: "4px 0 8px", textAlign: "center" }}>
{wallet.balance.toLocaleString("de-DE")}{" "}
<span style={{ fontSize: "1.1rem", color: "#ccc" }}>Bierkästen</span>
</p>
<p style={{ fontSize: "0.85rem", color: "#aaa", textAlign: "center" }}>
Letzter Claim:{" "}
{wallet.last_claim_at ? new Date(wallet.last_claim_at).toLocaleString("de-DE") : "noch nie"}
</p>
</div>
<div style={{ flex: "1 1 260px", maxWidth: 320, padding: "16px", borderRadius: 12, background: "linear-gradient(145deg, #191926, #131320)", border: "1px solid rgba(255,255,255,0.04)", display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
<div style={{ textAlign: "center" }}>
<h3 style={{ marginTop: 0, fontSize: "1rem" }}>Stündlicher Claim</h3>
<p style={{ fontSize: "0.9rem", color: "#ccc", marginBottom: 8 }}>Alle volle Stunde: <b>+25 Bierkästen</b>.</p>
<p style={{ fontSize: "0.85rem", color: "#aaa" }}>Nächster Claim: <b>{formatMs(wallet.next_claim_in_ms)}</b></p>
</div>
<button onClick={handleClaim} disabled={claiming || wallet.next_claim_in_ms > 0} style={{ marginTop: 12, padding: "10px 14px", borderRadius: 999, border: "none", background: claiming || wallet.next_claim_in_ms > 0 ? "#444" : "linear-gradient(135deg, #ffb347, #ffcc33)", color: claiming || wallet.next_claim_in_ms > 0 ? "#999" : "#222", fontWeight: 600, cursor: claiming || wallet.next_claim_in_ms > 0 ? "default" : "pointer", fontSize: "0.95rem" }}>
{claiming ? "Claim läuft..." : wallet.next_claim_in_ms > 0 ? "Noch nicht bereit" : "Bierkästen claimen 🍺"}
</button>
</div>
</div>
{/* Book of Bier Section */}
<div style={{ marginTop: 10, padding: "18px 16px 20px", borderRadius: 12, background: "linear-gradient(145deg, #191926, #131320)", border: "1px solid rgba(255,255,255,0.04)", textAlign: "center" }}>
<h2 style={{ marginTop: 0, fontSize: "1.2rem" }}>🎰 Book of Bier</h2>
<p style={{ fontSize: "0.9rem", color: "#ccc", marginBottom: 14 }}>5 Walzen, 3 Reihen, 10 Gewinnlinien. <b>BOOK</b> ({renderSymbol("BOOK")}) ist Scatter: 3+ Bücher geben Bonus-Gewinne.</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 12, alignItems: "center", justifyContent: "center", marginBottom: 16 }}>
<div style={{ fontSize: "0.9rem" }}>
Einsatz:&nbsp;
<input type="number" min={1} max={1000} value={slotBet} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { const v = parseInt(e.target.value || "0", 10); setSlotBet(Number.isFinite(v) ? v : 0); }} style={{ width: 90, padding: "4px 6px", borderRadius: 6, border: "1px solid #555", background: "#090910", color: "#f5f5f5", textAlign: "center" }} /> Bierkästen
</div>
<button onClick={handleSpin} disabled={slotSpinning || wallet.balance <= 0} style={{ padding: "9px 18px", borderRadius: 999, border: "none", background: slotSpinning ? "#444" : "linear-gradient(135deg, #ff6b6b, #f9d976)", color: slotSpinning ? "#aaa" : "#222", fontWeight: 600, cursor: slotSpinning ? "default" : "pointer", fontSize: "1rem", transform: slotSpinning ? "scale(1.05)" : "scale(1)", boxShadow: slotSpinning ? "0 0 18px rgba(255,255,255,0.6)" : "none", transition: "transform 0.15s ease-out, box-shadow 0.15s ease-out" }}>
{slotSpinning ? "Rollen..." : "Spin starten 🎰"}
</button>
<div style={{ fontSize: "0.85rem", color: "#aaa" }}>Kontostand: <b>{wallet.balance.toLocaleString("de-DE")}</b> Bierkästen</div>
</div>
{gridToShow ? (
<div style={{ marginTop: 4 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "flex-start", justifyContent: "center" }}>
<div>
<div style={{ fontSize: "0.95rem", marginBottom: 6, textAlign: "center" }}>Letzter Spin:</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 56px)", gridTemplateRows: "repeat(3, 56px)", gap: 6, padding: 8, background: "#0a0a12", borderRadius: 10, border: "1px solid rgba(255,255,255,0.06)", transform: slotSpinning ? "translateY(2px)" : "translateY(0)", transition: "transform 0.1s linear" }}>
{[0, 1, 2].map((row) => [0, 1, 2, 3, 4].map((col) => {
const key = `${col}-${row}`;
const isWinningCell = !slotSpinning && winningPositions.has(key);
const isBookCell = !slotSpinning && lastSpin && lastSpin.grid[col][row] === "BOOK";
return (
<div key={key} style={{ display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.6rem", borderRadius: 8, border: isWinningCell ? "1px solid rgba(255,215,0,0.9)" : "1px solid rgba(255,255,255,0.08)", boxShadow: isWinningCell ? "0 0 18px rgba(255,215,0,0.9)" : "none", background: isWinningCell ? "radial-gradient(circle, rgba(255,215,0,0.22) 0, transparent 60%)" : isBookCell ? "radial-gradient(circle, rgba(173,216,230,0.25) 0, transparent 60%)" : "transparent", transform: isWinningCell ? "scale(1.22)" : isBookCell ? "scale(1.1)" : "scale(1)", transition: "transform 0.18s ease-out, box-shadow 0.18s ease-out, background 0.18s ease-out, border-color 0.18s ease-out" }}>
{renderSymbol(gridToShow[col][row])}
</div>
);
}))}
</div>
</div>
{lastSpin && (
<div style={{ fontSize: "0.9rem", minWidth: 230, textAlign: "left" }}>
<p style={{ margin: "4px 0" }}>Einsatz: <b>{lastSpin.bet_amount}</b></p>
<p style={{ margin: "4px 0" }}>Gewinn: <b style={{ color: lastSpin.win_amount > 0 ? "#7CFC00" : "#ff9d9d", fontSize: lastSpin.win_amount >= lastSpin.bet_amount * 10 ? "1.2rem" : "1rem" }}>{lastSpin.win_amount}</b></p>
<p style={{ margin: "4px 0" }}>Bücher im Feld: <b>{lastSpin.book_count}</b></p>
{lastSpin.line_wins.length > 0 ? (
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: "0.85rem", marginBottom: 4 }}>Liniengewinne:</div>
<ul style={{ margin: 0, paddingLeft: 18, fontSize: "0.8rem" }}>
{lastSpin.line_wins.map((lw, idx) => <li key={idx}>Linie {lw.lineIndex + 1}: {lw.count}x {lw.symbol} {lw.win}</li>)}
</ul>
</div>
) : <p style={{ fontSize: "0.8rem", color: "#999", marginTop: 6 }}>Keine Liniengewinne in diesem Spin.</p>}
</div>
)}
</div>
</div>
) : <p style={{ fontSize: "0.85rem", color: "#aaa", marginTop: 8 }}>Noch kein Spin leg los und teste das Buch des Biers. 🍻</p>}
</div>
{/* Leaderboard Section */}
<div style={{ marginTop: 24, padding: "16px", borderRadius: 12, background: "linear-gradient(145deg, #141424, #10101b)", border: "1px solid rgba(255,255,255,0.04)" }}>
<h2 style={{ marginTop: 0, marginBottom: 10, fontSize: "1.1rem", textAlign: "center" }}>🏆 Bierbaron Leaderboards</h2>
{lbLoading && <p style={{ fontSize: "0.85rem", color: "#aaa", textAlign: "center" }}>Lade Bestenlisten...</p>}
{lbError && <p style={{ fontSize: "0.85rem", color: "#ff9d9d", textAlign: "center", marginBottom: 8 }}>{lbError}</p>}
{!lbLoading && !lbError && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, justifyContent: "center", alignItems: "flex-start" }}>
<div style={{ flex: "1 1 260px", maxWidth: 380, background: "rgba(0,0,0,0.35)", borderRadius: 10, padding: "10px 12px", border: "1px solid rgba(255,255,255,0.05)" }}>
<h3 style={{ margin: 0, marginBottom: 8, fontSize: "0.95rem", textAlign: "center" }}>💰 Meiste Bierkästen (Top 20)</h3>
{balanceLb && balanceLb.length > 0 ? (
<ol style={{ margin: 0, paddingLeft: 18, fontSize: "0.85rem", maxHeight: 260, overflowY: "auto" }}>
{balanceLb.map((entry) => (
<li key={entry.user_id} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, padding: "2px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{entry.avatar_url && <img src={entry.avatar_url} alt="" style={{ width: 20, height: 20, borderRadius: "50%" }} />}
<span style={{ maxWidth: 140, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{entry.discord_name}</span>
</div>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{entry.balance.toLocaleString("de-DE")} 🍺</span>
</li>
))}
</ol>
) : <p style={{ fontSize: "0.8rem", color: "#888", textAlign: "center", marginTop: 6 }}>Noch keine Daten.</p>}
</div>
<div style={{ flex: "1 1 260px", maxWidth: 380, background: "rgba(0,0,0,0.35)", borderRadius: 10, padding: "10px 12px", border: "1px solid rgba(255,255,255,0.05)" }}>
<h3 style={{ margin: 0, marginBottom: 8, fontSize: "0.95rem", textAlign: "center" }}>💥 Größter Einzelgewinn (Top 20)</h3>
{bigWinLb && bigWinLb.length > 0 ? (
<ol style={{ margin: 0, paddingLeft: 18, fontSize: "0.85rem", maxHeight: 260, overflowY: "auto" }}>
{bigWinLb.map((entry) => (
<li key={entry.user_id} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, padding: "2px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{entry.avatar_url && <img src={entry.avatar_url} alt="" style={{ width: 20, height: 20, borderRadius: "50%" }} />}
<span style={{ maxWidth: 140, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{entry.discord_name}</span>
</div>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{entry.biggest_win.toLocaleString("de-DE")} 🍺</span>
</li>
))}
</ol>
) : <p style={{ fontSize: "0.8rem", color: "#888", textAlign: "center", marginTop: 6 }}>Noch keine Gewinne geloggt.</p>}
</div>
</div>
)}
</div>
</>
);
};