Files
MyClub/frontend/src/pages/CalendarPage.tsx
T
2025-10-24 18:15:36 +02:00

1214 lines
61 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, Flex, Badge, Stack, Spinner, Grid, IconButton, ButtonGroup, Button, Link, Image, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, useToast, Input, useColorModeValue } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek } from 'date-fns';
import { cs } from 'date-fns/locale';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { useSearchParams } from 'react-router-dom';
import { useCountdown, useMultipleCountdowns } from '../hooks/useCountdown';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { sortCategoriesWithOrder } from '../utils/categorySort';
import ClubModal from '../components/home/ClubModal';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
// Weekday headers (Czech, starting Monday)
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
type MatchItem = {
id: number | string;
date: string; // yyyy-mm-dd
time: string; // HH:MM
home: string;
away: string;
home_id?: string;
away_id?: string;
venue?: string;
home_logo_url?: string;
away_logo_url?: string;
report_url?: string;
facr_link?: string;
score?: string; // optional: e.g., "2:1" for finished matches
// enriched when building the "Všechny soutěže" tab
__compId?: string;
__compName?: string;
__compDisplayOrder?: number;
};
type Competition = {
id: string;
name: string;
code?: string;
matches: MatchItem[];
};
const CalendarPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [competitions, setCompetitions] = useState<Competition[]>([]);
const [error, setError] = useState<string | null>(null);
const [compLinks, setCompLinks] = useState<Record<string, string>>({});
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
const [tabIndex, setTabIndex] = useState<number>(0);
const [searchParams] = useSearchParams();
const toast = useToast();
const [clubName, setClubName] = useState<string>('');
const [clubId, setClubId] = useState<string>('');
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
const [standings, setStandings] = useState<any[]>([]);
// Color mode values for dark/light theme
const calendarDayBg = useColorModeValue('white', 'gray.800');
const calendarDayBorder = useColorModeValue('gray.200', 'gray.700');
const calendarMatchBg = useColorModeValue('gray.50', 'gray.700');
const calendarMatchHoverBg = useColorModeValue('blue.50', 'blue.900');
const listGroupBg = useColorModeValue('white', 'gray.800');
const listGroupBorder = useColorModeValue('gray.200', 'gray.700');
const listGroupHeaderBg = useColorModeValue('gray.50', 'gray.700');
const listGroupHeaderBgHighlight = useColorModeValue('blue.50', 'blue.900');
const listGroupHeaderBorderLeft = useColorModeValue('gray.300', 'gray.600');
const listGroupHeaderText = useColorModeValue('gray.800', 'gray.100');
const listMatchBg = useColorModeValue('gray.50', 'gray.700');
const listMatchBorder = useColorModeValue('gray.200', 'gray.600');
const listMatchHoverBg = useColorModeValue('blue.50', 'blue.900');
const listDateText = useColorModeValue('gray.800', 'gray.100');
const listVenueText = useColorModeValue('gray.600', 'gray.400');
const listTimeText = useColorModeValue('gray.700', 'gray.300');
// Modal state
const { isOpen, onOpen, onClose } = useDisclosure();
const [selected, setSelected] = useState<{ match: MatchItem; comp?: Competition } | null>(null);
// Removed admin notification form state (moved to admin page)
// Fan subscribe state
const [fanEmail, setFanEmail] = useState<string>("");
const [fanSubscribing, setFanSubscribing] = useState<boolean>(false);
// ClubModal state
const [isClubModalOpen, setIsClubModalOpen] = useState<boolean>(false);
const [selectedClub, setSelectedClub] = useState<any | null>(null);
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
return new URL(path, origin).toString();
}
return path;
} catch {
return path;
}
};
// Helper functions shared across effects and render helpers
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 subscribeFan = async () => {
if (!fanEmail) {
toast({ title: 'Zadejte email', status: 'warning' });
return;
}
setFanSubscribing(true);
try {
const res = await fetch(resolveBackendUrl('/api/v1/newsletter/subscribe'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: fanEmail })
});
if (!res.ok) {
const jt = await res.json().catch(() => ({} as any));
throw new Error(jt?.error || `HTTP ${res.status}`);
}
toast({ title: 'Přihlášeno k odběru', status: 'success' });
setFanEmail('');
} catch (e: any) {
toast({ title: 'Chyba přihlášení', description: e?.message || String(e), status: 'error' });
} finally {
setFanSubscribing(false);
}
};
// Handle team click to show team stats in ClubModal
const handleTeamClick = (teamName: string, teamLogoUrl?: string) => {
try {
const normalizedTeamName = normalize(teamName);
// Search through all standings to find the team
let foundTeam: any = null;
for (const standing of standings) {
const table = standing.table || standing.rows || [];
for (const row of table) {
const rowTeamName = normalize(row.team_name || row.team || '');
if (rowTeamName === normalizedTeamName || rowTeamName.includes(normalizedTeamName) || normalizedTeamName.includes(rowTeamName)) {
foundTeam = row;
break;
}
}
if (foundTeam) break;
}
// If team found in standings, show detailed modal
if (foundTeam) {
const clubData = {
team: foundTeam.team_name || foundTeam.team || teamName,
team_id: foundTeam.team_id || String(foundTeam.position || 0),
team_logo_url: foundTeam.logo_url ? (assetUrl(foundTeam.logo_url) || foundTeam.logo_url) : teamLogoUrl,
rank: foundTeam.position || foundTeam.rank || foundTeam.pos,
played: foundTeam.played || foundTeam.matches,
wins: foundTeam.wins || foundTeam.win,
draws: foundTeam.draws || foundTeam.draw,
losses: foundTeam.losses || foundTeam.loss,
score: foundTeam.score || ((foundTeam.goals_for || foundTeam.gf) && (foundTeam.goals_against || foundTeam.ga)
? `${foundTeam.goals_for || foundTeam.gf}:${foundTeam.goals_against || foundTeam.ga}`
: undefined),
points: foundTeam.points || foundTeam.pts,
};
setSelectedClub(clubData);
setIsClubModalOpen(true);
} else {
// If not found in standings, show minimal modal with just the name and logo
const minimalClubData = {
team: teamName,
team_id: teamName,
team_logo_url: teamLogoUrl,
rank: '-',
played: '-',
wins: '-',
draws: '-',
losses: '-',
score: '-',
points: '-',
};
setSelectedClub(minimalClubData);
setIsClubModalOpen(true);
}
} catch {
// noop
}
};
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
// Load public competition aliases for display
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 {}
// Load team-logo overrides (API + fallback to cached file)
const now = Date.now();
let overrides: any = null;
try {
const res = await fetch(resolveBackendUrl(`/api/v1/public/team-logo-overrides?t=${now}`), { cache: 'no-cache' });
if (res.ok) overrides = await res.json();
} catch {}
if (!overrides) {
try {
const res2 = await fetch(resolveBackendUrl('/cache/prefetch/team_logo_overrides.json'), { cache: 'no-cache' });
if (res2.ok) overrides = await res2.json();
} catch {}
}
const byName: Record<string, string> = (overrides?.by_name || {}) as any;
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, 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 getOverrideLogo = (teamName?: string, original?: string) => {
if (!teamName) return original;
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; }
}
}
const chosen = candidate || original;
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
return chosen;
};
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_club_info.json'), { cache: 'no-cache' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const compLinkMap: Record<string,string> = {};
const baseComps: Competition[] = Array.isArray(json?.competitions)
? json.competitions.map((c: any, cIdx: number) => {
const compId = String(c.id || cIdx);
if (c.matches_link) compLinkMap[compId] = String(c.matches_link);
const matches: MatchItem[] = (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 || '00:00').slice(0,5);
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
return {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
time,
home: m.home,
away: m.away,
home_id: m.home_id,
away_id: m.away_id,
venue: m.venue,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
report_url: m.report_url,
facr_link: m.facr_link,
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
};
})
.sort((a: MatchItem, b: MatchItem) => new Date(`${a.date}T${a.time}:00`).getTime() - new Date(`${b.date}T${b.time}:00`).getTime());
return {
id: compId,
code: c.code,
name: (amap?.[c?.code]?.alias) || (amap?.[compId]?.alias) || c.name || c.code || `Soutěž ${cIdx+1}`,
matches,
};
})
: [];
// Try to enrich competitions missing matches using facr detailed info file
let enriched: Competition[] = baseComps;
try {
const clubId = json?.club_id;
const clubType = json?.club_type || 'football';
if (clubId) {
const infoPath = `/cache/facr/${clubType}_${clubId}_info.json`;
const res2 = await fetch(resolveBackendUrl(infoPath), { cache: 'no-cache' });
if (res2.ok) {
const j2 = await res2.json();
if (Array.isArray(j2?.competitions)) {
const mapCompMatches = (c: any, cIdx: number) => {
const list: MatchItem[] = (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 || '00:00').slice(0,5);
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
return {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
time,
home: m.home,
away: m.away,
home_id: m.home_id,
away_id: m.away_id,
venue: m.venue,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
report_url: m.report_url,
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
} as MatchItem;
}).sort((a: MatchItem, b: MatchItem) => new Date(`${a.date}T${a.time}:00`).getTime() - new Date(`${b.date}T${b.time}:00`).getTime());
return list;
};
enriched = baseComps.map((bc) => {
if (bc.matches.length) return bc;
const found = j2.competitions.find((c: any) => String(c.id) === String(bc.id) || c.name === bc.name || c.code === bc.name || c.code === bc.id);
if (!found) return bc;
const mm = mapCompMatches(found, 0);
return { ...bc, matches: mm };
});
}
}
}
} catch {}
// Sort competitions by age (Muži first, then U19, U17, etc.) and respect custom order
const sortedEnriched = sortCategoriesWithOrder(
enriched.map(c => ({
...c,
alias: (amap?.[c?.code || '']?.alias) || (amap?.[c?.id]?.alias),
display_order: (amap?.[c?.code || '']?.display_order) ?? (amap?.[c?.id]?.display_order),
}))
);
// Build unified "All competitions" entry as the default tab
const allMatches: MatchItem[] = (sortedEnriched || [])
.flatMap((c, idx) => (c.matches || []).map(m => ({
...m,
__compId: c.id,
__compName: c.name,
__compDisplayOrder: c.display_order ?? (1000 + idx) // Use display_order or fallback to sorted index
} as MatchItem)))
.sort((a, b) => new Date(`${a.date}T${a.time}:00`).getTime() - new Date(`${b.date}T${b.time}:00`).getTime());
const allComp: Competition = { id: 'all', name: 'Všechny soutěže', matches: allMatches };
// Load standings data
let standingsData: any[] = [];
try {
const tablesRes = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' });
if (tablesRes.ok) {
const facrTablesJSON = await tablesRes.json();
if (facrTablesJSON?.competitions?.length) {
standingsData = (facrTablesJSON.competitions || []).map((c: any) => ({
name: (amap?.[c?.code]?.alias) || (amap?.[c?.id]?.alias) || c.name || c.code,
table: (c.table?.overall || []).map((r: any, idx: number) => {
const teamName = ((): string => {
const t = r.team;
if (typeof t === 'string') return t;
if (t && typeof t === 'object') {
return t.name || t.Name || t.team_name || t.TeamName || '-';
}
return r.team_name || r.TeamName || '-';
})();
const teamId = ((): string | undefined => {
const t = r.team;
if (t && typeof t === 'object') {
return t.id || t.team_id || t.teamId || r.team_id;
}
return r.team_id;
})();
return {
position: Number(r.rank || idx + 1),
team_name: teamName,
team_id: teamId,
points: Number(r.points || r.pts || 0),
played: Number(r.played || r.matches || 0),
wins: Number(r.wins || r.win || 0),
draws: Number(r.draws || r.draw || 0),
losses: Number(r.losses || r.loss || 0),
goals_for: Number(r.goals_for ?? r.gf ?? r.goalsFor ?? r.scored ?? r.goals ?? 0),
goals_against: Number(r.goals_against ?? r.ga ?? r.goalsAgainst ?? r.conceded ?? 0),
logo_url: r.team_logo_url || undefined,
};
}),
}));
}
}
} catch {}
if (!cancelled) {
setAliasMap(amap);
const comps = [allComp, ...sortedEnriched];
setCompetitions(comps);
setCompLinks(compLinkMap);
setStandings(standingsData);
if (json?.name) setClubName(String(json.name));
if (json?.club_internal_id) setClubId(String(json.club_internal_id));
if (json?.club_type) setClubType(json.club_type);
// Set active tab from query ?comp=<id>
const compQ = searchParams.get('comp');
if (compQ) {
const idx = comps.findIndex(cmp => String(cmp.id) === String(compQ));
setTabIndex(idx >= 0 ? idx : 0);
} else {
setTabIndex(0);
}
}
} catch (e: any) {
if (!cancelled) setError(e?.message || 'Nepodařilo se načíst kalendář.');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
// Optimized countdown hooks
const modalCountdown = useCountdown(
selected ? `${selected.match.date}T${selected.match.time || '00:00'}:00` : null,
1000 // Update every second for modal
);
// Get upcoming matches for live countdowns (only future matches)
const upcomingMatches = useMemo(() => {
return competitions.flatMap(comp =>
comp.matches.filter(match => {
const matchTime = new Date(`${match.date}T${match.time || '00:00'}:00`).getTime();
return matchTime > Date.now();
})
);
}, [competitions]);
const liveCountdowns = useMultipleCountdowns(upcomingMatches, 30000); // Update every 30 seconds for better performance
const openMatchModal = (match: MatchItem, comp?: Competition) => {
setSelected({ match, comp });
onOpen();
};
// Handle middle click to open in new tab
const handleMatchMouseDown: React.MouseEventHandler<HTMLAnchorElement | HTMLDivElement> = (e) => {
// Middle click or Ctrl/Cmd+Click should open link in new tab naturally when using <a>.
// This handler is a safety net when match is rendered as a div.
if (e.button === 1) {
const target = e.currentTarget as HTMLElement & { dataset?: any };
const href = (target.getAttribute && target.getAttribute('data-href')) || (target as any).dataset?.href;
if (href) {
window.open(href as string, '_blank', 'noopener');
}
}
};
const hasData = useMemo(() => competitions.some(c => c.matches.length), [competitions]);
// Month-view state and helpers
const [monthRef, setMonthRef] = useState<Date>(startOfMonth(new Date()));
const [viewMode, setViewMode] = useState<'calendar'|'list'>('calendar');
const [expandedDates, setExpandedDates] = useState<Record<string, boolean>>({});
const [showPast, setShowPast] = useState<boolean>(false);
const weeks = useMemo(() => {
const start = startOfWeek(startOfMonth(monthRef), { weekStartsOn: 1 });
// Build 6 weeks x 7 days
const days: Date[] = [];
for (let i = 0; i < 42; i++) {
days.push(new Date(start.getTime() + i * 86400000));
}
return days;
}, [monthRef]);
const groupByDate = (matches: MatchItem[]) => {
const map = new Map<string, MatchItem[]>();
matches.forEach((m) => {
const key = m.date;
const arr = map.get(key) || [];
arr.push(m);
map.set(key, arr);
});
// Sort matches within each day by competition order, then by time
map.forEach((matchList, dateKey) => {
matchList.sort((a, b) => {
// First sort by competition display order (if available)
const aOrder = a.__compDisplayOrder ?? 9999;
const bOrder = b.__compDisplayOrder ?? 9999;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
// Then sort by time
const aTime = a.time || '00:00';
const bTime = b.time || '00:00';
return aTime.localeCompare(bTime);
});
});
return map;
};
// Sentiment helpers
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): { 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'; color: string } | null => {
// Don't show sentiment for future matches
const dt = new Date(`${m.date}T${(m.time || '00:00')}:00`);
const isPast = Date.now() >= dt.getTime();
if (!isPast) return 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) {
// Check each team ID individually - even if one is missing, we can still match the other
if (m.home_id) {
ourIsHome = m.home_id === clubId;
}
if (m.away_id) {
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; // unknown perspective
if (s.h === s.a) return { label: 'Remíza', color: 'blue' };
const ourGoals = ourIsHome ? s.h : s.a;
const oppGoals = ourIsHome ? s.a : s.h;
if (ourGoals > oppGoals) return { label: 'Výhra', color: 'green' };
return { label: 'Prohra', color: 'red' };
};
return (
<MainLayout>
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
<Heading as="h1" size="xl" mb={2}>Kalendář</Heading>
<Text color="gray.600" mb={6}>Přehled zápasů podle soutěží (FACR).</Text>
{loading && (
<Flex align="center" gap={3} color="gray.600" mb={6}>
<Spinner size="sm" />
<span>Načítám rozpis</span>
</Flex>
)}
{error && (
<Box color="red.600" mb={4}>{error}</Box>
)}
{!loading && !hasData && !error && (
<Box color="gray.600">Zatím nemáme žádné zápasy k zobrazení.</Box>
)}
{!!competitions.length && (
<Tabs variant="soft-rounded" colorScheme="blue" index={tabIndex} onChange={(i) => setTabIndex(i)}>
{/* Compact, wrapped TabList with better spacing (no overlap) */}
<Box mb={3} position="relative" zIndex={1}>
<TabList
flexWrap="wrap"
gap={2}
justifyContent="flex-start"
sx={{
'& > 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'
}
}
}}
>
{competitions.map((c) => (
<Tab key={c.id}>
{c.name}
</Tab>
))}
</TabList>
</Box>
<TabPanels>
{competitions.map((c) => {
const byDate = groupByDate(c.matches);
const mkHref = (m: MatchItem) => (m.facr_link || m.report_url || undefined) ?? (`/zapas/${m.id}`);
// Build latest results (only matches with score)
const nowTs = Date.now();
const compareByDateDesc = (a: MatchItem, b: MatchItem) => new Date(`${b.date}T${(b.time||'00:00')}:00`).getTime() - new Date(`${a.date}T${(a.time||'00:00')}:00`).getTime();
let latestResults: MatchItem[] = [];
if (c.id === 'all') {
// For 'all', pick most recent scored match per competition
const grouped: Record<string, MatchItem[]> = {};
(c.matches || []).forEach((m) => {
if (!m.score) return;
const ts = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime();
if (isNaN(ts) || ts > nowTs) return; // future results not allowed
const key = m.__compId || 'na';
grouped[key] = grouped[key] || [];
grouped[key].push(m);
});
latestResults = Object.values(grouped)
.map(list => list.sort(compareByDateDesc)[0])
.filter(Boolean)
.sort(compareByDateDesc);
} else {
// Single competition: pick the most recent scored match
latestResults = (c.matches || [])
.filter(m => !!m.score && new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() <= nowTs)
.sort(compareByDateDesc)
.slice(0, 1);
}
return (
<TabPanel key={c.id} px={0}>
{/* Latest results header list rendered above both calendar and list modes */}
{latestResults.length > 0 && (
<Box mb={4}>
<Heading as="h3" size="md" mb={2}>Nejnovější výsledky</Heading>
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={3}>
{latestResults.map((m) => {
const href = mkHref(m);
return (
<Box key={`latest-${c.id}-${m.id}`} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
<Flex align="center" justify="space-between" mb={2}>
<Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text>
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
</Flex>
<Flex align="center" gap={2} justify="center">
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<Text fontSize="sm">{m.home}</Text>
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
<Text fontSize="sm">{m.away}</Text>
</Flex>
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
</Box>
);
})}
</Grid>
</Box>
)}
<Flex align="center" justify="flex-end" mb={3}>
<ButtonGroup size="sm" isAttached>
<Button
variant={viewMode==='calendar' ? 'solid' : 'outline'}
bg={viewMode==='calendar' ? 'brand.primary' : undefined}
color={viewMode==='calendar' ? 'text.onPrimary' : undefined}
_hover={{ filter: viewMode==='calendar' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='calendar' ? 'text.onPrimary' : undefined }}
onClick={() => setViewMode('calendar')}
>Kalendář</Button>
<Button
variant={viewMode==='list' ? 'solid' : 'outline'}
bg={viewMode==='list' ? 'brand.primary' : undefined}
color={viewMode==='list' ? 'text.onPrimary' : undefined}
_hover={{ filter: viewMode==='list' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='list' ? 'text.onPrimary' : undefined }}
onClick={() => setViewMode('list')}
>Seznam</Button>
</ButtonGroup>
</Flex>
{viewMode === 'calendar' ? (
<>
<Flex align="center" justify="space-between" mb={3} gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
<IconButton
aria-label="Předchozí měsíc"
size="sm"
onClick={() => setMonthRef(addMonths(monthRef, -1))}
icon={<ChevronLeftIcon />}
variant="outline"
_hover={{ bg: 'rgba(0,0,0,0.04)', borderColor: 'brand.primary' }}
/>
<Button
size="sm"
onClick={() => setMonthRef(new Date())}
variant="solid"
bg={'brand.primary'}
color={'text.onPrimary'}
_hover={{ filter: 'brightness(0.95)' }}
>Dnes</Button>
<IconButton
aria-label="Další měsíc"
size="sm"
onClick={() => setMonthRef(addMonths(monthRef, 1))}
icon={<ChevronRightIcon />}
variant="outline"
_hover={{ bg: 'rgba(0,0,0,0.04)', borderColor: 'brand.primary' }}
/>
</Flex>
<Box overflowX="auto">
<Grid templateColumns="repeat(7, 1fr)" gap={3} minW="980px">
{WEEKDAYS_SHORT.map((w) => (
<Box key={w} textAlign="center" fontWeight="semibold" color="gray.600" fontSize={{ base: 'xs', md: 'sm' }}>{w}</Box>
))}
</Grid>
</Box>
<Box overflowX="auto">
<Grid templateColumns="repeat(7, 1fr)" gap={{ base: 1, md: 2 }} minW="980px">
{weeks.map((day, idx) => {
const key = format(day, 'yyyy-MM-dd');
const list = byDate.get(key) || [];
const faded = !isSameMonth(day, monthRef);
const today = isSameDay(day, new Date());
return (
<Box
key={idx}
borderWidth="1px"
borderRadius="md"
p={2}
minH="120px"
minW="130px"
bg={today ? 'rgba(59,130,246,0.06)' : calendarDayBg}
borderColor={today ? 'brand.primary' : calendarDayBorder}
opacity={faded ? 0.6 : 1}
>
<Flex align="center" justify="space-between" mb={2}>
<Text fontWeight="bold">{format(day, 'd')}</Text>
{!!list.length && (
<Badge bg="brand.primary" color="text.onPrimary" borderRadius="full">{list.length}</Badge>
)}
</Flex>
<Stack spacing={2}>
{(expandedDates[key] ? list : list.slice(0,3)).map((m: MatchItem) => {
const href = mkHref(m);
const isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
const countdown = liveCountdowns[String(m.id)];
return (
<Box key={m.id} _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
<Box p={2} borderWidth="1px" borderRadius="md" bg={calendarMatchBg} _hover={{ bg: calendarMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer' }} textAlign="center">
{!isPast && countdown ? (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<Badge colorScheme="orange">za {countdown}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
) : (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'}>{isPast && m.score ? m.score : 'vs'}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
)}
</Box>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
);
})}
{list.length > 3 && !expandedDates[key] && (
<Button size="xs" variant="link" colorScheme="gray" onClick={() => setExpandedDates((s) => ({ ...s, [key]: true }))}>
+{list.length - 3} další
</Button>
)}
{expandedDates[key] && list.length > 3 && (
<Button size="xs" variant="link" colorScheme="gray" onClick={() => setExpandedDates((s) => ({ ...s, [key]: false }))}>
Zobrazit méně
</Button>
)}
</Stack>
</Box>
);
})}
</Grid>
</Box>
</>
) : (
<Stack spacing={4}>
{(() => {
const keys = Array.from(byDate.keys());
const todayStr = format(new Date(), 'yyyy-MM-dd');
const pastKeys = keys.filter(k => k < todayStr).sort().reverse();
const futureKeys = keys.filter(k => k >= todayStr).sort();
const renderGroup = (dKey: string, highlight: boolean) => {
const dayMatches = byDate.get(dKey) || [];
return (
<Box key={dKey} borderWidth="1px" borderRadius="md" overflow="hidden" bg={listGroupBg} borderColor={listGroupBorder}>
<Box
px={3}
py={2}
bg={highlight ? listGroupHeaderBgHighlight : listGroupHeaderBg}
borderLeftWidth="4px"
borderLeftColor={highlight ? 'brand.primary' : listGroupHeaderBorderLeft}
>
<Flex align="center" gap={2}>
<Text fontWeight="semibold" color={highlight ? 'brand.primary' : listGroupHeaderText}>
{format(new Date(dKey), 'EEEE d. M. yyyy', { locale: cs })}
</Text>
{highlight && (
<Badge colorScheme="blue" variant="subtle" borderRadius="full">Dnes</Badge>
)}
</Flex>
</Box>
<Stack spacing={3}>
{dayMatches.map((m: MatchItem) => {
const href = mkHref(m);
const isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
const sentiment = isPast ? getSentiment(m) : null;
const countdown = liveCountdowns[String(m.id)];
return (
<Box key={m.id} _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
<Flex
align="center"
justify="space-between"
p={3}
borderWidth="1px"
borderRadius="md"
bg={listMatchBg}
borderColor={listMatchBorder}
_hover={{ bg: listMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer', transform: 'translateY(-2px)', boxShadow: 'md' }}
transition="all 0.2s"
gap={3}
>
<Flex direction="column" minW="100px">
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
</Flex>
<Flex align="center" gap={3} flex="1">
{/* Home Team */}
<Flex align="center" gap={2} flex="1" justify="flex-end">
<Text fontSize="sm" fontWeight="medium" textAlign="right" color={listDateText}>
{m.home}
</Text>
{m.home_logo_url && (
<Image
src={m.home_logo_url}
alt={m.home}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
/>
)}
</Flex>
{/* Score or Countdown */}
<Flex direction="column" align="center" gap={1} minW="80px">
{!isPast && countdown ? (
<Badge colorScheme="orange" fontSize="sm" px={2}>za {countdown}</Badge>
) : (
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
{isPast && m.score ? m.score : 'vs'}
</Badge>
)}
{sentiment && (
<Text fontSize="xs" color={`${sentiment.color}.600`} fontWeight="semibold">
{sentiment.label}
</Text>
)}
</Flex>
{/* Away Team */}
<Flex align="center" gap={2} flex="1" justify="flex-start">
{m.away_logo_url && (
<Image
src={m.away_logo_url}
alt={m.away}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
/>
)}
<Text fontSize="sm" fontWeight="medium" textAlign="left" color={listDateText}>
{m.away}
</Text>
</Flex>
</Flex>
</Flex>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
);
})}
</Stack>
</Box>
);
};
return (
<>
{!!pastKeys.length && (
<Box>
{!showPast ? (
<Button size="sm" variant="link" onClick={() => setShowPast(true)}>Zobrazit předchozí zápasy ({pastKeys.reduce((acc,k)=>acc+(byDate.get(k)?.length||0),0)})</Button>
) : (
<Button size="sm" variant="link" onClick={() => setShowPast(false)}>Skrýt předchozí zápasy</Button>
)}
{showPast && (
<Stack spacing={4} mt={2}>
{pastKeys.map((k) => renderGroup(k, false))}
</Stack>
)}
</Box>
)}
<Stack spacing={4}>
{futureKeys.map((k) => renderGroup(k, k === todayStr))}
</Stack>
</>
);
})()}
</Stack>
)}
</TabPanel>
);
})}
</TabPanels>
</Tabs>
)}
{/* Match details modal */}
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered returnFocusOnClose={false}>
<ModalContent>
<ModalHeader>
{selected ? (selected.match.__compName || selected.comp?.name || 'Detail zápasu') : 'Detail zápasu'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{selected && (
<Stack spacing={4}>
{/* Show match status (Výhra/Remíza/Prohra) if determinable; otherwise fall back to competition name */}
{(() => {
const s = getSentiment(selected.match);
if (s) {
return (
<Flex justify="center">
<Badge colorScheme={s.color as any} variant="subtle">{s.label}</Badge>
</Flex>
);
}
const compName = selected.comp?.name || selected.match.__compName;
// Don't show "Všechny soutěže" badge - only show specific competition names
if (compName && compName !== 'Všechny soutěže') {
return (
<Flex justify="center">
<Badge colorScheme="purple">{compName}</Badge>
</Flex>
);
}
return null;
})()}
<Flex align="center" justify="center" gap={3}>
{selected.match.home_logo_url && (
<Image
src={selected.match.home_logo_url}
alt={selected.match.home}
boxSize="40px"
borderRadius="full"
cursor="pointer"
onClick={() => handleTeamClick(selected.match.home, selected.match.home_logo_url)}
_hover={{ opacity: 0.8, transform: 'scale(1.1)' }}
transition="all 0.2s"
title={`Klikněte pro zobrazení statistik: ${selected.match.home}`}
/>
)}
{(() => {
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
const isPast = Date.now() >= dt.getTime();
const hasScore = Boolean(selected.match.score);
// For future matches, always show countdown or "vs" - never the score
if (!isPast) {
if (modalCountdown.countdownString) {
return (
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>za {modalCountdown.countdownString}</Badge>
);
}
return (
<Badge colorScheme="gray" fontSize="md" px={3} py={1}>vs</Badge>
);
}
// For past matches, show score or "vs"
return (
<Badge colorScheme={hasScore ? (getSentiment(selected.match)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
{hasScore ? selected.match.score : 'vs'}
</Badge>
);
})()}
{selected.match.away_logo_url && (
<Image
src={selected.match.away_logo_url}
alt={selected.match.away}
boxSize="40px"
borderRadius="full"
cursor="pointer"
onClick={() => handleTeamClick(selected.match.away, selected.match.away_logo_url)}
_hover={{ opacity: 0.8, transform: 'scale(1.1)' }}
transition="all 0.2s"
title={`Klikněte pro zobrazení statistik: ${selected.match.away}`}
/>
)}
</Flex>
{/* Date and Time Display with Countdown */}
<Box textAlign="center">
<Text fontSize="lg" fontWeight="semibold" color="gray.800" mb={1}>
{(() => {
try {
return format(new Date(selected.match.date), 'EEEE d. MMMM yyyy', { locale: cs });
} catch {
return selected.match.date;
}
})()}
</Text>
<Text fontSize="md" color="gray.700">
{selected.match.time || '—'}
</Text>
</Box>
{/* Enhanced Countdown Display for Upcoming Matches */}
{(() => {
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
const isPast = Date.now() >= dt.getTime();
const hasScore = Boolean(selected.match.score);
if (!hasScore && !isPast && modalCountdown.isActive && modalCountdown.timeRemaining > 0) {
const days = Math.floor(modalCountdown.timeRemaining / (24 * 60 * 60 * 1000));
const hours = Math.floor((modalCountdown.timeRemaining % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
const minutes = Math.floor((modalCountdown.timeRemaining % (60 * 60 * 1000)) / (60 * 1000));
const seconds = Math.floor((modalCountdown.timeRemaining % (60 * 1000)) / 1000);
return (
<Box
mt={4}
p={4}
bg="orange.50"
borderRadius="lg"
borderWidth="2px"
borderColor="orange.200"
>
<Text fontSize="sm" fontWeight="semibold" color="orange.800" mb={3} textAlign="center">
Zápas začíná za
</Text>
<Grid
templateColumns={days > 0 ? "repeat(4, 1fr)" : "repeat(3, 1fr)"}
gap={3}
>
{days > 0 && (
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{days}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{days === 1 ? 'den' : days < 5 ? 'dny' : 'dní'}
</Text>
</Box>
)}
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(hours).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{hours === 1 ? 'hodina' : hours < 5 ? 'hodiny' : 'hodin'}
</Text>
</Box>
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(minutes).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{minutes === 1 ? 'minuta' : minutes < 5 ? 'minuty' : 'minut'}
</Text>
</Box>
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(seconds).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{seconds === 1 ? 'sekunda' : seconds < 5 ? 'sekundy' : 'sekund'}
</Text>
</Box>
</Grid>
</Box>
);
}
return null;
})()}
<Box h="1px" bg="gray.200" />
<Heading as="h3" size="sm">Odběr notifikací pro fanoušky</Heading>
<Text fontSize="sm" color="gray.600">Zadejte svůj email a budeme vás informovat o novinkách a zápasech.</Text>
<Flex gap={2} align="center">
<Input type="email" placeholder="váš@email.cz" value={fanEmail} onChange={(e) => setFanEmail(e.target.value)} />
<Button colorScheme="red" onClick={subscribeFan} isLoading={fanSubscribing}>Odebírat</Button>
</Flex>
</Stack>
)}
</ModalBody>
<ModalFooter>
{selected && (selected.match.facr_link || selected.match.report_url) && (
<Button
colorScheme="blue"
mr={3}
onClick={(e) => {
e.preventDefault();
// Open in background tab without switching focus
const facrLink = selected.match.facr_link || selected.match.report_url;
if (facrLink) {
const link = document.createElement('a');
link.href = facrLink;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}}
>
Detail na FAČR
</Button>
)}
<Button onClick={onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Container>
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
{/* Club Modal for team statistics */}
<ClubModal
isOpen={isClubModalOpen}
onClose={() => setIsClubModalOpen(false)}
club={selectedClub}
clubType={clubType}
/>
</MainLayout>
);
};
export default CalendarPage;