Files
MyClub/frontend/src/pages/TablesPage.tsx
T
Tomas Dvorak 8762bde4bf dev day #89
2025-11-11 10:29:30 +01:00

411 lines
19 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import MainLayout from '../components/layout/MainLayout';
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Flex, Spinner, Badge, Link, useColorModeValue } from '@chakra-ui/react';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import NewsletterCTA from '../components/common/NewsletterCTA';
import ClubModal from '../components/home/ClubModal';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { sortCategoriesWithOrder } from '../utils/categorySort';
import { TeamLogo } from '../components/common/TeamLogo';
import { API_URL } from '../services/api';
type TableRow = {
rank: string;
team: string;
team_id?: string;
team_logo_url?: string;
played: string;
wins: string;
draws: string;
losses: string;
score: string;
points: string;
};
function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return undefined;
}
}
type CompetitionTable = {
id: string;
name: string;
code?: string;
matches_link?: string;
rows: TableRow[];
};
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
const u = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
const abs = new URL(path, `${u.protocol}//${u.host}`);
return abs.toString();
}
return path;
} catch {
return path;
}
};
const TablesPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [competitions, setCompetitions] = useState<CompetitionTable[]>([]);
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [overrides, setOverrides] = useState<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } | null>(null);
const { data: settings } = usePublicSettings();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const tableBg = useColorModeValue('white', 'gray.800');
const tableHeaderBg = useColorModeValue('var(--primary, #C53030)', 'var(--primary, #9b2c2c)');
const tableTextColor = useColorModeValue('gray.800', 'gray.200');
const rowOddBg = useColorModeValue('white', 'gray.800');
const rowEvenBg = useColorModeValue('gray.50', 'gray.700');
type SortKey = 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points';
type SortOrder = 'desc' | 'asc';
const [sortState, setSortState] = useState<Record<string, { key: SortKey; order: SortOrder } | null>>({});
const toNumber = (v: any): number => {
if (typeof v === 'number') return v;
const n = parseFloat(String(v ?? '').replace(/[^0-9\-\.]/g, ''));
return isNaN(n) ? 0 : n;
};
const scoreDiff = (s: any): number => {
const str = String(s ?? '').trim();
const m = str.match(/(-?\d+)\s*[:\-]\s*(-?\d+)/);
if (m) return Number(m[1]) - Number(m[2]);
return toNumber(str);
};
const toggleSort = (compId: string, key: SortKey) => {
const cur = sortState[compId];
if (!cur || cur.key !== key) { setSortState({ ...sortState, [compId]: { key, order: 'desc' } }); return; }
if (cur.order === 'desc') { setSortState({ ...sortState, [compId]: { key, order: 'asc' } }); return; }
const next = { ...sortState }; next[compId] = null; setSortState(next);
};
const arrow = (compId: string, key: SortKey) => {
const cur = sortState[compId];
if (!cur || cur.key !== key) return '';
return cur.order === 'desc' ? '▼' : '▲';
};
const handleClubClick = (club: any) => {
setSelectedClub(club);
setIsModalOpen(true);
};
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
// Load overrides (API + cached file)
try {
const now = Date.now();
let ovr: any = null;
try {
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
if (res.ok) ovr = await res.json();
} catch {}
if (!ovr) {
try {
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
if (res2.ok) ovr = await res2.json();
} catch {}
}
if (!cancelled) setOverrides(ovr || { by_id: {}, by_name: {} });
} catch {}
// Load aliases first
let amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
try {
const aliases: CompetitionAlias[] = await getCompetitionAliasesPublic();
(aliases || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
} catch {}
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const comps: CompetitionTable[] = Array.isArray(json?.competitions)
? json.competitions.map((c: any, idx: number) => {
const overall = c?.table?.overall || [];
const rows: TableRow[] = Array.isArray(overall) ? overall.map((r: any) => ({
rank: String(r.rank ?? ''),
team: String(r.team ?? ''),
team_id: r.team_id,
team_logo_url: r.team_logo_url ? resolveBackendUrl(r.team_logo_url) : undefined,
played: String(r.played ?? ''),
wins: String(r.wins ?? ''),
draws: String(r.draws ?? ''),
losses: String(r.losses ?? ''),
score: String(r.score ?? ''),
points: String(r.points ?? ''),
})) : [];
return {
id: String(c.id || idx),
name: (amap?.[c?.code]?.alias) || (amap?.[String(c.id || idx)]?.alias) || c.name || c.code || `Soutěž ${idx+1}`,
alias: (amap?.[c?.code]?.alias) || (amap?.[String(c.id || idx)]?.alias),
display_order: (amap?.[c?.code]?.display_order) ?? (amap?.[String(c.id || idx)]?.display_order),
code: c.code,
matches_link: c.matches_link,
rows,
};
}) : [];
if (!cancelled) {
setAliasMap(amap);
// Only keep competitions that have at least one row in the table
const filtered = (comps || []).filter((c) => Array.isArray(c.rows) && c.rows.length > 0);
// Sort competitions by age (Muži first, then U19, U17, etc.) and respect custom order
const sorted = sortCategoriesWithOrder(filtered) as typeof filtered;
setCompetitions(sorted);
}
} catch (e: any) {
if (!cancelled) setError(e?.message || 'Nepodařilo se načíst tabulky.');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
// Normalization helpers (same as CalendarPage/TableSection)
const normalize = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = normalize(s);
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const byNameMap = useMemo(() => {
const m: Record<string, string> = {};
const src = overrides?.by_name || {};
for (const k of Object.keys(src)) m[normalize(k)] = src[k];
return m;
}, [overrides]);
const byIdMap = useMemo(() => (overrides?.by_id || {}) as Record<string, { name?: string; logo_url?: string }>, [overrides]);
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string }> = {};
try {
for (const [id, v] of Object.entries(byIdMap)) {
const name = String((v as any)?.name || '').trim();
if (!name) continue;
const key = normalize(name);
if (!key) continue;
idx[key] = { id, name };
}
} catch {}
return idx;
}, [byIdMap]);
const pickName = (teamId?: string, original?: string, logoUrl?: string) => {
const id = String(teamId || '') || deriveTeamIdFromLogoUrl(logoUrl) || '';
const v = id ? byIdMap?.[id]?.name : undefined;
if (v && String(v).trim().length > 0) return String(v);
const orig = String(original || '');
if (orig) {
const n = normalize(orig);
let hit = overridesNameIndex[n];
if (!hit) {
for (const [k, val] of Object.entries(overridesNameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = val as any; break; }
}
}
if (!hit) {
const t1 = n.split(' ')[0];
if (t1 && t1.length >= 5) {
for (const [k, val] of Object.entries(overridesNameIndex)) {
const k1 = String(k).split(' ')[0];
if (k1 === t1) { hit = val as any; break; }
}
}
}
if (hit?.name) return hit.name;
}
return orig;
};
const pickLogo = (teamId?: string, teamName?: string, original?: string): string | undefined => {
if (teamId && byIdMap?.[teamId]?.logo_url) return byIdMap[teamId]!.logo_url as string;
if (teamName) {
const exact = (overrides?.by_name || {})[teamName];
if (exact) return exact;
const n = normalize(teamName);
const cand = byNameMap[n];
if (cand) return cand;
const stripped = stripPrefixes(teamName);
for (const k of Object.keys(overrides?.by_name || {})) {
const kn = stripPrefixes(k);
if (!kn) continue;
if (stripped.endsWith(kn) || kn.endsWith(stripped)) return (overrides!.by_name as any)[k];
}
}
return original;
};
return (
<MainLayout>
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
<Heading as="h1" size="xl" mb={2}>Tabulky</Heading>
<Text color={textSecondary} mb={6}>Oficiální tabulky FACR podle soutěží.</Text>
{loading && (
<Flex align="center" gap={3} color={textSecondary} mb={6}>
<Spinner size="sm" />
<span>Načítám tabulky</span>
</Flex>
)}
{error && (
<Box color="red.600" mb={6}>{error}</Box>
)}
{!!competitions.length && (
<Tabs variant="enclosed" size="sm">
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
}}>
{competitions.map((c) => (
<Tab
key={c.id}
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
_hover={{ bg: 'rgba(0,0,0,0.04)' }}
flex="0 0 auto"
px={3}
py={2}
>
<Text as="span" noOfLines={1} maxW="300px" title={c.name}>{c.name}</Text>
</Tab>
))}
</TabList>
<TabPanels>
{competitions.map((c) => (
<TabPanel key={c.id} px={0}>
<Flex align="center" justify="space-between" mb={3}>
<Heading as="h2" size="md">{c.name}</Heading>
{c.matches_link && (
<Link href={c.matches_link} isExternal color="brand.primary" _hover={{ filter: 'brightness(0.9)' }}>Rozpis a detail soutěže</Link>
)}
</Flex>
<Box borderWidth="1px" borderColor={borderColor} borderRadius="lg" overflowX="auto" boxShadow="sm" bg={tableBg}>
<Table size="sm" variant="unstyled" color={tableTextColor}>
<Thead position="sticky" top={0} zIndex={2}>
<Tr bg={tableHeaderBg} color="white">
<Th w="56px" color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'rank')}># {arrow(c.id, 'rank')}</Th>
<Th color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'team')}>Tým {arrow(c.id, 'team')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'played')}>Z {arrow(c.id, 'played')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'wins')}>V {arrow(c.id, 'wins')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'draws')}>R {arrow(c.id, 'draws')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'losses')}>P {arrow(c.id, 'losses')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'score')}>Skóre {arrow(c.id, 'score')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'points')}>Body {arrow(c.id, 'points')}</Th>
</Tr>
</Thead>
<Tbody>
{(() => {
const cur = sortState[c.id];
const arr = [...(c.rows || [])];
if (cur) {
arr.sort((a: any, b: any) => {
let va: any; let vb: any; let isText = false;
switch (cur.key) {
case 'team': va = pickName(a.team_id, a.team, a.team_logo_url); vb = pickName(b.team_id, b.team, b.team_logo_url); isText = true; break;
case 'rank': va = toNumber(a.rank); vb = toNumber(b.rank); break;
case 'played': va = toNumber(a.played); vb = toNumber(b.played); break;
case 'wins': va = toNumber(a.wins); vb = toNumber(b.wins); break;
case 'draws': va = toNumber(a.draws); vb = toNumber(b.draws); break;
case 'losses': va = toNumber(a.losses); vb = toNumber(b.losses); break;
case 'score': va = scoreDiff(a.score); vb = scoreDiff(b.score); break;
case 'points': va = toNumber(a.points); vb = toNumber(b.points); break;
default: va = 0; vb = 0;
}
let res = isText ? String(va).localeCompare(String(vb)) : (va as number) - (vb as number);
if (cur.order === 'desc') res = -res;
if (res === 0) {
const ra = toNumber(a.rank); const rb = toNumber(b.rank);
res = ra - rb;
}
return res;
});
}
return arr.map((r, idx) => {
const displayTeam = pickName(r.team_id, r.team, r.team_logo_url);
const displayLogo = pickLogo(r.team_id, displayTeam, r.team_logo_url);
return (
<Tr
key={`${c.id}-${r.rank}-${r.team}`}
transition="all 0.15s"
_hover={{ transform: 'translateY(-3px)', boxShadow: 'lg', zIndex: 3, cursor: 'pointer' }}
position="relative"
bg={idx % 2 === 0 ? rowOddBg : rowEvenBg}
onClick={() => handleClubClick(r)}
>
<Td>
<Badge variant="subtle" bg={useColorModeValue('gray.100', 'gray.700')} color={tableTextColor} borderWidth="1px" borderColor={borderColor}>{r.rank}</Badge>
</Td>
<Td>
<Flex align="center" gap={3}>
<TeamLogo
teamId={r.team_id || deriveTeamIdFromLogoUrl(r.team_logo_url)}
teamName={displayTeam}
facrLogo={displayLogo}
size="small"
alt={displayTeam}
objectFit="contain"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
/>
<Text fontWeight="medium" color={tableTextColor}>{displayTeam}</Text>
</Flex>
</Td>
<Td isNumeric color={tableTextColor}>{r.played}</Td>
<Td isNumeric color={tableTextColor}>{r.wins}</Td>
<Td isNumeric color={tableTextColor}>{r.draws}</Td>
<Td isNumeric color={tableTextColor}>{r.losses}</Td>
<Td isNumeric color={tableTextColor}>{r.score}</Td>
<Td isNumeric>
<Badge variant="solid" bg="blue.600" color="white">{r.points}</Badge>
</Td>
</Tr>
);});})()}
</Tbody>
</Table>
</Box>
</TabPanel>
))}
</TabPanels>
</Tabs>
)}
{!loading && !error && competitions.length === 0 && (
<Text color={textSecondary}>Pro tento klub nejsou dostupné tabulky.</Text>
)}
</Container>
{/* Newsletter CTA */}
<NewsletterCTA />
<ClubModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
club={selectedClub}
clubType={(settings?.club_type as 'football' | 'futsal') || 'football'}
/>
</MainLayout>
);
};
export default TablesPage;