Files
bridzik/frontend/src/App.tsx
T
timandClaude Fable 5 53274607b1 Donate stranka: PAY by square QR s vyberom sumy
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>
2026-07-04 16:54:49 +02:00

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>
);
}