import React, { useEffect, useState, useMemo } from 'react'; import { Tabs, TabList, TabPanels, Tab, TabPanel, useColorModeValue } from '@chakra-ui/react'; import MainLayout from '../components/layout/MainLayout'; import { getPublicSettings } from '../services/settings'; import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases'; import { sortCategoriesWithOrder } from '../utils/categorySort'; import { assetUrl, sanitizeClubName } from '../utils/url'; import MatchModal from '../components/home/MatchModal'; import { useCountdown, useMultipleCountdowns } from '../hooks/useCountdown'; import '../styles/theme.css'; type MatchItem = { id: number | string; date: string; time: string; home: string; away: string; home_id?: string; away_id?: string; home_logo_url?: string; away_logo_url?: string; score?: string | null; facr_link?: string | null; report_url?: string | null; venue?: string; competition?: string; competitionName?: string; } const MatchesPage: React.FC = () => { const [clubName, setClubName] = useState(''); const [clubId, setClubId] = useState(''); const [clubType, setClubType] = useState<'football' | 'futsal'>('football'); const [facrCompetitions, setFacrCompetitions] = useState; matches_link?: string }>>([]); const [activeTab, setActiveTab] = useState(0); const [isMatchModalOpen, setIsMatchModalOpen] = useState(false); const [selectedMatch, setSelectedMatch] = useState(null); const [aliases, setAliases] = useState([]); const [aliasMap, setAliasMap] = useState>({}); const [sortAscending, setSortAscending] = useState(true); // true = oldest first, false = newest first const [displayedMatchesCount, setDisplayedMatchesCount] = useState>({}); // Track displayed matches per competition index const MATCHES_PER_PAGE = 12; // Number of matches to load initially and per load more click // Dark mode colors const bgColor = useColorModeValue('#f8f9fb', '#0f1115'); const cardBg = useColorModeValue('#fff', '#1a1d29'); const borderColor = useColorModeValue('#e5e7eb', '#2a2e3a'); const textPrimary = useColorModeValue('#1a1a1a', '#e8eaf0'); const textSecondary = useColorModeValue('#666', '#9ca3af'); const headingColor = useColorModeValue('#000', '#fff'); const openMatch = (m: any) => { try { setSelectedMatch(m); setIsMatchModalOpen(true); } catch { // noop } }; // Helper function to sanitize and truncate long club names const truncateClubName = (name: string, maxLength: number = 35) => { if (!name) return name; // First sanitize the club name const sanitized = sanitizeClubName(name); if (sanitized.length <= maxLength) return sanitized; return sanitized.substring(0, maxLength).trim() + '…'; }; // Format date to Czech format const formatCzechDate = (dateStr: string, timeStr: string) => { try { const date = new Date(`${dateStr}T${timeStr}:00`); return date.toLocaleDateString('cs-CZ', { day: 'numeric', month: 'numeric', year: 'numeric' }); } catch { return dateStr; } }; // Sentiment helpers for win/draw/loss detection 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 isClubTeam = (team: string) => { try { const a = stripPrefixes(team); const b = stripPrefixes(clubName || ''); if (!a || !b) return false; // Allow equality or suffix match (handles prefixes like TJ, SK, etc.) return a === b || a.endsWith(b) || b.endsWith(a); } catch { return false; } }; const parseScore = (score?: string | null): { h: number; a: number } | null => { if (!score) return null; const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/); if (!m) return null; return { h: parseInt(m[1], 10), a: parseInt(m[2], 10) }; }; const getSentiment = (m: MatchItem): { label: 'Výhra'|'Remíza'|'Prohra'; colorScheme: 'green'|'blue'|'red' } | null => { const s = parseScore(m.score); if (!s) return null; // First try ID-based matching (most reliable) let ourIsHome = false; let ourIsAway = false; if (clubId && m.home_id && m.away_id) { ourIsHome = m.home_id === clubId; ourIsAway = m.away_id === clubId; } // Fallback to name matching if IDs not available or no match if (!ourIsHome && !ourIsAway) { ourIsHome = isClubTeam(m.home); ourIsAway = isClubTeam(m.away); } if (!ourIsHome && !ourIsAway) return null; if (s.h === s.a) return { label: 'Remíza', colorScheme: 'blue' }; const ourGoals = ourIsHome ? s.h : s.a; const oppGoals = ourIsHome ? s.a : s.h; if (ourGoals > oppGoals) return { label: 'Výhra', colorScheme: 'green' }; return { label: 'Prohra', colorScheme: 'red' }; }; // Get current date for accurate comparisons const now = useMemo(() => Date.now(), []); // Get sorted matches based on sort order const sortedCompetitions = useMemo(() => { const sorted = facrCompetitions.map(comp => ({ ...comp, matches: [...comp.matches].sort((a: any, b: any) => { const aTime = new Date(`${a.date}T${(a.time || '00:00')}:00`).getTime(); const bTime = new Date(`${b.date}T${(b.time || '00:00')}:00`).getTime(); return sortAscending ? aTime - bTime : bTime - aTime; }) })); // Add "Všechny kategorie" as first tab with all matches combined if (sorted.length > 0) { const allMatches = sorted.flatMap(comp => comp.matches.map(m => ({ ...m, competitionName: comp.name })) ); allMatches.sort((a: any, b: any) => { const aTime = new Date(`${a.date}T${(a.time || '00:00')}:00`).getTime(); const bTime = new Date(`${b.date}T${(b.time || '00:00')}:00`).getTime(); return sortAscending ? aTime - bTime : bTime - aTime; }); return [ { name: 'Všechny kategorie', matches: allMatches, matches_link: undefined }, ...sorted ]; } return sorted; }, [facrCompetitions, sortAscending]); // Initialize displayed matches count when competitions change useEffect(() => { const initialCounts: Record = {}; sortedCompetitions.forEach((_, index) => { if (displayedMatchesCount[index] === undefined) { initialCounts[index] = MATCHES_PER_PAGE; } }); if (Object.keys(initialCounts).length > 0) { setDisplayedMatchesCount(prev => ({ ...prev, ...initialCounts })); } }, [sortedCompetitions.length]); // Handle load more for a specific competition const handleLoadMore = (competitionIndex: number) => { setDisplayedMatchesCount(prev => ({ ...prev, [competitionIndex]: (prev[competitionIndex] || MATCHES_PER_PAGE) + MATCHES_PER_PAGE })); }; // Get all upcoming matches for countdown tracking const upcomingMatches = useMemo(() => { if (activeTab >= sortedCompetitions.length) return []; const comp = sortedCompetitions[activeTab]; const currentTime = Date.now(); return (comp?.matches || []).filter((m: any) => { const matchTime = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime(); return matchTime > currentTime; }); }, [sortedCompetitions, activeTab]); // Track countdowns for all upcoming matches in current tab const liveCountdowns = useMultipleCountdowns(upcomingMatches, 30000); useEffect(() => { let cancelled = false; const resolveBackendUrl = (path: string) => { try { if (/^https?:\/\//i.test(path)) return path; if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) { const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1'; const b = new URL(base); const abs = new URL(path, `${b.protocol}//${b.host}`); return abs.toString(); } return path; } catch { return path; } }; const fetchJSON = async (url: string) => { try { const res = await fetch(resolveBackendUrl(url), { cache: 'no-cache' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch { return null; } }; (async () => { const [ cachedSettingsJSON, facrClubJSON, teamLogoOverridesAPI, teamLogoOverridesFile, ] = await Promise.all([ fetchJSON('/cache/prefetch/settings.json'), fetchJSON('/cache/prefetch/facr_club_info.json'), fetchJSON(`/api/v1/public/team-logo-overrides?t=${Date.now()}`), fetchJSON('/cache/prefetch/team_logo_overrides.json'), ]); // Load aliases let aliasesList: CompetitionAlias[] = []; try { aliasesList = await getCompetitionAliasesPublic(); } catch {} const amap: Record = {}; (aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; }); let liveSettings: any = null; try { liveSettings = await getPublicSettings(); } catch {} if (!cancelled) { setAliases(aliasesList || []); setAliasMap(amap); // Build override helpers const teamLogoOverridesJSON = (teamLogoOverridesAPI && teamLogoOverridesAPI.by_name) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {}); const byName: Record = (teamLogoOverridesJSON?.by_name || {}) as any; 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, '').replace(/\s+/g, ' ').trim(); return x; }; const byNameNormalized: Record = Object.keys(byName || {}).reduce((acc: Record, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {}); const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] })); const getFallbackLogo = (teamName?: string, original?: string) => { if (teamName) { const exact = (byName || {})[teamName]; const normName = normalize(teamName); let candidate = exact || byNameNormalized[normName]; if (!candidate) { const stripped = stripPrefixes(teamName); for (const { keyNorm, url } of byNameStrippedPairs) { if (!keyNorm) continue; if (stripped.endsWith(keyNorm) || keyNorm.endsWith(stripped)) { candidate = url; break; } } } if (candidate) { if (typeof candidate === 'string' && candidate.startsWith('/')) return resolveBackendUrl(candidate); return candidate; } } if (original) { if (typeof original === 'string' && original.startsWith('/')) return resolveBackendUrl(original); return original; } return undefined; }; const getOverrideLogo = (teamName?: string, teamId?: string, original?: string) => { if (teamId) { return `http://logoapi.sportcreative.eu/logos/${teamId}`; } return getFallbackLogo(teamName, original); }; const settingsJSON = liveSettings || cachedSettingsJSON; if (settingsJSON) { const name = settingsJSON?.club_name || settingsJSON?.clubName || ''; const id = settingsJSON?.club_id || settingsJSON?.clubId || ''; const type = settingsJSON?.club_type || 'football'; if (name) setClubName(name); if (id) setClubId(id); if (type === 'football' || type === 'futsal') setClubType(type); } // Load ALL matches from FACR (no time filtering) if (facrClubJSON?.competitions?.length) { const comps = (facrClubJSON.competitions || []).map((c: any) => ({ name: (amap?.[c?.code]?.alias) || (amap?.[c?.id]?.alias) || c.name || c.code || 'Soutěž', alias: (amap?.[c?.code]?.alias) || (amap?.[c?.id]?.alias), display_order: (amap?.[c?.code]?.display_order) ?? (amap?.[c?.id]?.display_order), matches_link: c.matches_link, matches: (Array.isArray(c.matches) ? c.matches : []).map((m: any, idx: number) => { const dt: string = String(m.date_time || ''); const [d, t] = dt.includes(' ') ? dt.split(' ') : [dt, '']; const [day, month, year] = (d || '').split('.'); const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10); const time = (t || '18:00').slice(0,5); // Check if match is in the future - if so, ignore score const matchTime = new Date(`${isoDate}T${time}:00`).getTime(); const isFutureMatch = matchTime > Date.now(); const actualScore = isFutureMatch ? null : m.score; return { id: m.match_id || idx + 1, date: isoDate, time, home: m.home, away: m.away, home_id: m.home_id, away_id: m.away_id, home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url), away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url), score: actualScore, facr_link: m.facr_link, report_url: m.report_url, venue: m.venue || '', competition: c.name || c.code || '', }; }) })); const sortedComps = sortCategoriesWithOrder(comps) as typeof comps; setFacrCompetitions(sortedComps); } } })(); return () => { cancelled = true; }; }, []); return (

Všechny zápasy

{clubName || 'Klub'}

{sortedCompetitions.length > 0 ? ( button': { minW: 'auto', px: { base: 3, md: 4 }, py: { base: 1.5, md: 2 }, fontSize: { base: 'xs', md: 'sm' }, fontWeight: 600, borderRadius: 'md', transition: 'all 0.2s', _selected: { bg: 'brand.primary', color: 'white', transform: 'translateY(-2px)', boxShadow: 'md' }, _hover: { transform: 'translateY(-1px)', boxShadow: 'sm' } } }} > {sortedCompetitions.map((c, i) => ( {c.name} ))} {sortedCompetitions.map((c, compIdx) => ( {c.matches.length === 0 ? (
Žádné zápasy k zobrazení
) : ( <>
{c.matches.slice(0, displayedMatchesCount[compIdx] || MATCHES_PER_PAGE).map((m: MatchItem, idx: number) => { const matchTime = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime(); const currentTime = Date.now(); const isFuture = matchTime > currentTime; const isPast = matchTime < currentTime; const hasScore = m.score && m.score.trim() !== ''; const countdown = liveCountdowns[String(m.id)]; return (
openMatch({ ...m, competitionName: c.name })} style={{ background: cardBg, border: `2px solid ${borderColor}`, borderRadius: 16, padding: 20, cursor: 'pointer', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', color: textPrimary, position: 'relative', overflow: 'hidden', }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-8px)'; e.currentTarget.style.boxShadow = '0 16px 40px rgba(0,0,0,0.12), 0 6px 16px rgba(0,0,0,0.08)'; e.currentTarget.style.borderColor = 'var(--chakra-colors-brand-primary, #3b82f6)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)'; e.currentTarget.style.borderColor = borderColor; }} >
{formatCzechDate(m.date, m.time || '00:00')} {m.time}
{m.home} { (e.target as HTMLImageElement).src = '/images/club-logo.png'; }} />
{truncateClubName(m.home)}
{isFuture ? ( countdown ? (
Začátek za {countdown}
) : ( vs ) ) : hasScore ? (
{m.score}
) : ( —:— )}
{m.away} { (e.target as HTMLImageElement).src = '/images/club-opponent.png'; }} />
{truncateClubName(m.away)}
{m.venue && (
{m.venue}
)} {(m as any).competitionName && activeTab === 0 && (
{(m as any).competitionName}
)} {(() => { const sentiment = hasScore && isPast ? getSentiment(m) : null; if (sentiment) { const colors = { green: { bg: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', shadow: 'rgba(16, 185, 129, 0.3)' }, blue: { bg: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', shadow: 'rgba(59, 130, 246, 0.3)' }, red: { bg: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', shadow: 'rgba(239, 68, 68, 0.3)' } }; const color = colors[sentiment.colorScheme]; return (
{sentiment.label}
); } if (!hasScore && isPast) { return (
Odehráno
); } if (!hasScore && isFuture && !countdown) { return (
Nadcházející
); } return null; })()}
); })}
{c.matches.length > (displayedMatchesCount[compIdx] || MATCHES_PER_PAGE) && (
Zobrazeno {Math.min(displayedMatchesCount[compIdx] || MATCHES_PER_PAGE, c.matches.length)} z {c.matches.length} zápasů
)} )}
))}
) : (

Žádné zápasy k zobrazení

Zkontrolujte nastavení klubu v administraci

)}
setIsMatchModalOpen(false)} />
); }; export default MatchesPage;