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(null); const [competitions, setCompetitions] = useState([]); const [aliasMap, setAliasMap] = useState>({}); const [selectedClub, setSelectedClub] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [overrides, setOverrides] = useState<{ by_id?: Record; by_name?: Record } | 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>({}); 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 = {}; 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 = {}; 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, [overrides]); const overridesNameIndex = useMemo(() => { const idx: Record = {}; 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 ( Tabulky Oficiální tabulky FACR podle soutěží. {loading && ( Načítám tabulky… )} {error && ( {error} )} {!!competitions.length && ( {competitions.map((c) => ( {c.name} ))} {competitions.map((c) => ( {c.name} {c.matches_link && ( Rozpis a detail soutěže )} {(() => { 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 ( handleClubClick(r)} > );});})()}
toggleSort(c.id, 'rank')}># {arrow(c.id, 'rank')} toggleSort(c.id, 'team')}>Tým {arrow(c.id, 'team')} toggleSort(c.id, 'played')}>Z {arrow(c.id, 'played')} toggleSort(c.id, 'wins')}>V {arrow(c.id, 'wins')} toggleSort(c.id, 'draws')}>R {arrow(c.id, 'draws')} toggleSort(c.id, 'losses')}>P {arrow(c.id, 'losses')} toggleSort(c.id, 'score')}>Skóre {arrow(c.id, 'score')} toggleSort(c.id, 'points')}>Body {arrow(c.id, 'points')}
{r.rank} {displayTeam} {r.played} {r.wins} {r.draws} {r.losses} {r.score} {r.points}
))}
)} {!loading && !error && competitions.length === 0 && ( Pro tento klub nejsou dostupné tabulky. )}
{/* Newsletter CTA */} setIsModalOpen(false)} club={selectedClub} clubType={(settings?.club_type as 'football' | 'futsal') || 'football'} />
); }; export default TablesPage;