mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
1240 lines
62 KiB
TypeScript
1240 lines
62 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';
|
|
import { TeamLogo } from '../components/common/TeamLogo';
|
|
|
|
// 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 byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) 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, teamId?: string) => {
|
|
// Prefer admin override by ID
|
|
if (teamId && byId?.[teamId]?.logo_url) {
|
|
const v = byId[teamId]!.logo_url as string;
|
|
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
|
return v;
|
|
}
|
|
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();
|
|
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
|
|
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
|
|
return {
|
|
id: m.match_id || `${cIdx}-${idx}`,
|
|
date: isoDate,
|
|
time,
|
|
home: homeName,
|
|
away: awayName,
|
|
home_id: m.home_id,
|
|
away_id: m.away_id,
|
|
venue: m.venue,
|
|
home_logo_url: getOverrideLogo(homeName, m.home_logo_url, m.home_id),
|
|
away_logo_url: getOverrideLogo(awayName, m.away_logo_url, m.away_id),
|
|
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: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
|
|
away: (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : 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, m.home_id),
|
|
away_logo_url: getOverrideLogo(m.away, m.away_logo_url, m.away_id),
|
|
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: (teamId && byId?.[teamId]?.name && String(byId[teamId].name).trim()) ? String(byId[teamId].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: (teamId && byId?.[teamId]?.logo_url) ? String(byId[teamId].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">
|
|
<TeamLogo
|
|
teamId={m.home_id}
|
|
teamName={m.home}
|
|
facrLogo={m.home_logo_url}
|
|
size="custom"
|
|
boxSize="18px"
|
|
alt={m.home}
|
|
borderRadius="full"
|
|
/>
|
|
<Text fontSize="sm">{m.home}</Text>
|
|
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
|
|
<TeamLogo
|
|
teamId={m.away_id}
|
|
teamName={m.away}
|
|
facrLogo={m.away_logo_url}
|
|
size="custom"
|
|
boxSize="18px"
|
|
alt={m.away}
|
|
borderRadius="full"
|
|
/>
|
|
<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;
|