Linka Na kavu v hlavicke lobby vedie na /donate. QR sa generuje dynamicky (balicek bysquare) pre presety 1/2/5 EUR alebo vlastnu sumu; IBAN sa da skopirovat do schranky. Navstevy loguje existujuci pageview beacon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
132 lines
5.2 KiB
TypeScript
132 lines
5.2 KiB
TypeScript
import { lazy, Suspense, useEffect } from 'react';
|
|
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
|
|
import { useGameStore } from './store/gameStore';
|
|
import { socket, emit } from './lib/socket';
|
|
import { trackEvent } from './lib/track';
|
|
import type { MyPlayer } from './types';
|
|
import GameList from './pages/GameList';
|
|
import Lobby from './pages/Lobby';
|
|
import GameTable from './pages/GameTable';
|
|
import Auth from './pages/Auth';
|
|
import History from './pages/History';
|
|
import Donate from './pages/Donate';
|
|
|
|
// Admin pulls in recharts — lazy-load it so players never download that chunk.
|
|
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
|
|
const AdminStats = lazy(() => import('./pages/admin/AdminStats'));
|
|
|
|
function AppInner() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const navigationType = useNavigationType();
|
|
const account = useGameStore((s) => s.account);
|
|
const myPlayer = useGameStore((s) => s.myPlayer);
|
|
const gameStatus = useGameStore((s) => s.gameStatus);
|
|
const error = useGameStore((s) => s.error);
|
|
const clearError = useGameStore((s) => s.clearError);
|
|
|
|
// Reconnect into an in-progress game on every socket connect (after network drops).
|
|
useEffect(() => {
|
|
const doReconnect = () => {
|
|
const saved = localStorage.getItem('bridzik_player');
|
|
if (!saved) return;
|
|
const player = JSON.parse(saved) as MyPlayer;
|
|
emit.reconnectToGame(player.gid, player.token);
|
|
};
|
|
socket.on('connect', doReconnect);
|
|
if (socket.connected) doReconnect();
|
|
return () => { socket.off('connect', doReconnect); };
|
|
}, []);
|
|
|
|
// Single authoritative navigation:
|
|
// - not logged in → /auth
|
|
// - logged in & in a game → lobby/game
|
|
// - logged in, no game, on /auth (just logged in) → leave for the game list
|
|
// - logged in, no game, elsewhere → null (let the user navigate: list/history)
|
|
const onGameRoute =
|
|
location.pathname === '/auth' ||
|
|
location.pathname.startsWith('/game') ||
|
|
location.pathname.startsWith('/lobby');
|
|
// /admin is a separate concern gated by its own token, not the player login.
|
|
const onAdminRoute = location.pathname.startsWith('/admin');
|
|
const targetRoute = onAdminRoute
|
|
? null
|
|
: !account
|
|
? '/auth'
|
|
: myPlayer
|
|
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
|
|
: onGameRoute ? '/' : null;
|
|
|
|
useEffect(() => {
|
|
if (!targetRoute) return;
|
|
navigate(targetRoute, { replace: true });
|
|
}, [targetRoute, navigate]);
|
|
|
|
// Auto-dismiss errors after 4 s
|
|
useEffect(() => {
|
|
if (!error) return;
|
|
const t = setTimeout(clearError, 4000);
|
|
return () => clearTimeout(t);
|
|
}, [error, clearError]);
|
|
|
|
// Fire-and-forget pageview beacon for self-hosted analytics; must never
|
|
// affect the app (network errors are swallowed). Skip REPLACE navigations --
|
|
// those are app-internal gate/index redirects (login gate, /admin index ->
|
|
// stats), not a page the user actually navigated to, so they'd otherwise
|
|
// inflate the count with one extra row per redirect hop. Skip /admin itself
|
|
// too -- that's the dashboard viewing its own traffic, not player usage.
|
|
// Skip /, /lobby, /game -- high-frequency in-game navigation with no
|
|
// analytical value; the backend drops these anyway (api/stats.py _SKIPPED_PATHS).
|
|
// The visitor's first touch (incl. landings on "/", which both skips would
|
|
// otherwise swallow) is captured by trackLanding() in main.tsx instead.
|
|
// Skip /auth too: it's only ever reached by a full page load (every in-app
|
|
// redirect to it is REPLACE), so each /auth row would be a 1:1 duplicate of
|
|
// the landing event fired by that same load.
|
|
useEffect(() => {
|
|
if (navigationType === 'REPLACE') return;
|
|
if (location.pathname.startsWith('/admin')) return;
|
|
if (
|
|
location.pathname === '/' ||
|
|
location.pathname === '/auth' ||
|
|
location.pathname.startsWith('/lobby') ||
|
|
location.pathname.startsWith('/game')
|
|
) {
|
|
return;
|
|
}
|
|
trackEvent(location.pathname, document.referrer);
|
|
}, [location.pathname, navigationType]);
|
|
|
|
return (
|
|
<>
|
|
{error && (
|
|
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-red-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm max-w-xs text-center">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<Suspense fallback={<p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>}>
|
|
<Routes>
|
|
<Route path="/auth" element={<Auth />} />
|
|
<Route path="/" element={<GameList />} />
|
|
<Route path="/history" element={<History />} />
|
|
<Route path="/donate" element={<Donate />} />
|
|
<Route path="/lobby/:gid" element={<Lobby />} />
|
|
<Route path="/game/:gid" element={<GameTable />} />
|
|
<Route path="/admin" element={<AdminLayout />}>
|
|
<Route index element={<Navigate to="stats" replace />} />
|
|
<Route path="stats" element={<AdminStats />} />
|
|
</Route>
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<AppInner />
|
|
</BrowserRouter>
|
|
);
|
|
}
|