mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
302 lines
13 KiB
TypeScript
302 lines
13 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import MainLayout from '../components/layout/MainLayout';
|
|
import { Box, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Button, Container, Flex, Heading, Image, Link, Spinner, Stack, Text, useToast } from '@chakra-ui/react';
|
|
import { Link as RouterLink, useParams } from 'react-router-dom';
|
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
|
|
import SponsorsSection from '../components/common/SponsorsSection';
|
|
import NewsletterCTA from '../components/common/NewsletterCTA';
|
|
import { API_URL } from '../services/api';
|
|
|
|
interface MatchItem {
|
|
id: string | number;
|
|
date: string;
|
|
time: string;
|
|
home: string;
|
|
away: string;
|
|
venue?: string;
|
|
home_logo_url?: string;
|
|
away_logo_url?: string;
|
|
report_url?: string;
|
|
facr_link?: string;
|
|
score?: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
const MatchDetailPage: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const toast = useToast();
|
|
const { data: settings } = usePublicSettings();
|
|
const [loading, setLoading] = useState(true);
|
|
const [match, setMatch] = useState<MatchItem | null>(null);
|
|
const [competitionName, setCompetitionName] = useState<string>("");
|
|
const [competitionId, setCompetitionId] = useState<string>("");
|
|
const [competitionCode, setCompetitionCode] = useState<string>("");
|
|
const [prevMatch, setPrevMatch] = useState<MatchItem | null>(null);
|
|
const [nextMatch, setNextMatch] = useState<MatchItem | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
if (!id) return;
|
|
setLoading(true);
|
|
try {
|
|
// Try main prefetch file first
|
|
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_club_info.json'), { cache: 'no-cache' });
|
|
let found: MatchItem | null = null;
|
|
let neighborSource: MatchItem[] = [];
|
|
if (res.ok) {
|
|
const json = await res.json();
|
|
for (const c of (json?.competitions || [])) {
|
|
const listForNeighbors: MatchItem[] = [];
|
|
for (const m of (c?.matches || [])) {
|
|
const mid = String(m.match_id || '');
|
|
if (mid && mid === String(id)) {
|
|
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();
|
|
found = {
|
|
id: mid,
|
|
date: isoDate,
|
|
time,
|
|
home: m.home,
|
|
away: m.away,
|
|
venue: m.venue,
|
|
home_logo_url: m.home_logo_url,
|
|
away_logo_url: 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,
|
|
};
|
|
setCompetitionName(String(c?.name || c?.code || ''));
|
|
if (c?.id != null) setCompetitionId(String(c.id));
|
|
if (c?.code) setCompetitionCode(String(c.code));
|
|
break;
|
|
}
|
|
// also push into neighbor list when iterating
|
|
const dt2: string = String(m.date_time || '');
|
|
const [d2, t2] = dt2.includes(' ') ? dt2.split(' ') : [dt2, ''];
|
|
const [day2, month2, year2] = d2.split('.');
|
|
const iso2 = (day2 && month2 && year2) ? `${year2}-${month2.padStart(2,'0')}-${day2.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
|
const tshort = (t2 || '00:00').slice(0,5);
|
|
listForNeighbors.push({ id: m.match_id || '', date: iso2, time: tshort, home: m.home, away: m.away } as MatchItem);
|
|
}
|
|
if (found) break;
|
|
neighborSource = listForNeighbors;
|
|
}
|
|
}
|
|
// Fallback to club-specific info file
|
|
if (!found && settings?.club_id) {
|
|
const clubType = settings.club_type || 'football';
|
|
const infoPath = `/cache/facr/${clubType}_${settings.club_id}_info.json`;
|
|
const res2 = await fetch(resolveBackendUrl(infoPath), { cache: 'no-cache' });
|
|
if (res2.ok) {
|
|
const j2 = await res2.json();
|
|
for (const c of (j2?.competitions || [])) {
|
|
const listForNeighbors: MatchItem[] = [];
|
|
for (const m of (c?.matches || [])) {
|
|
const mid = String(m.match_id || '');
|
|
if (mid && mid === String(id)) {
|
|
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();
|
|
found = {
|
|
id: mid,
|
|
date: isoDate,
|
|
time,
|
|
home: m.home,
|
|
away: m.away,
|
|
venue: m.venue,
|
|
home_logo_url: m.home_logo_url,
|
|
away_logo_url: 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,
|
|
};
|
|
setCompetitionName(String(c?.name || c?.code || ''));
|
|
if (c?.id != null) setCompetitionId(String(c.id));
|
|
if (c?.code) setCompetitionCode(String(c.code));
|
|
break;
|
|
}
|
|
// collect neighbor list
|
|
const dt2: string = String(m.date_time || '');
|
|
const [d2, t2] = dt2.includes(' ') ? dt2.split(' ') : [dt2, ''];
|
|
const [day2, month2, year2] = d2.split('.');
|
|
const iso2 = (day2 && month2 && year2) ? `${year2}-${month2.padStart(2,'0')}-${day2.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
|
const tshort = (t2 || '00:00').slice(0,5);
|
|
listForNeighbors.push({ id: m.match_id || '', date: iso2, time: tshort, home: m.home, away: m.away } as MatchItem);
|
|
}
|
|
if (found) break;
|
|
neighborSource = listForNeighbors;
|
|
}
|
|
}
|
|
}
|
|
// compute neighbors
|
|
if (found && neighborSource.length) {
|
|
const sorted = neighborSource.sort((a, b) => new Date(`${a.date}T${a.time}:00`).getTime() - new Date(`${b.date}T${b.time}:00`).getTime());
|
|
const idx = sorted.findIndex(x => String(x.id) === String(found!.id));
|
|
setPrevMatch(idx > 0 ? sorted[idx - 1] : null);
|
|
setNextMatch(idx >= 0 && idx < sorted.length - 1 ? sorted[idx + 1] : null);
|
|
} else {
|
|
setPrevMatch(null); setNextMatch(null);
|
|
}
|
|
if (!cancelled) setMatch(found);
|
|
} catch (e: any) {
|
|
if (!cancelled) toast({ title: 'Chyba', description: e?.message || 'Nelze načíst zápas', status: 'error' });
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [id, settings, toast]);
|
|
|
|
const isPast = useMemo(() => {
|
|
if (!match) return false;
|
|
return new Date(`${match.date}T${(match.time||'00:00')}:00`).getTime() < Date.now();
|
|
}, [match]);
|
|
|
|
// Load aliases and resolve display name for competition
|
|
const { data: aliases } = useQuery({
|
|
queryKey: ['public-competition-aliases'],
|
|
queryFn: getCompetitionAliasesPublic,
|
|
});
|
|
const compDisplay = useMemo(() => {
|
|
if (!competitionCode) return competitionName;
|
|
const hit = (aliases || []).find(a => a.code === competitionCode);
|
|
return hit?.alias || competitionName;
|
|
}, [aliases, competitionCode, competitionName]);
|
|
|
|
return (
|
|
<MainLayout>
|
|
<Container maxW="3xl" py={{ base: 6, md: 10 }}>
|
|
<Breadcrumb fontSize="sm" mb={4} separator="/">
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink as={RouterLink} to="/">Domů</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink as={RouterLink} to={competitionId ? `/kalendar?comp=${competitionId}` : "/kalendar"}>Kalendář</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
{compDisplay && (
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink as={RouterLink} to={competitionId ? `/kalendar?comp=${competitionId}` : "/kalendar"}>{compDisplay}</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
)}
|
|
{match && (
|
|
<BreadcrumbItem isCurrentPage>
|
|
<BreadcrumbLink>{match.home} vs {match.away}</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
)}
|
|
</Breadcrumb>
|
|
<Box mb={4}>
|
|
<Button as={RouterLink} to={competitionId ? `/kalendar?comp=${competitionId}` : "/kalendar"} size="sm" variant="outline">Zpět na kalendář</Button>
|
|
</Box>
|
|
{loading && (
|
|
<Flex align="center" gap={3} color="gray.600"><Spinner size="sm" /> Načítám…</Flex>
|
|
)}
|
|
{!loading && !match && (
|
|
<Box color="gray.600">Zápas nebyl nalezen.</Box>
|
|
)}
|
|
{match && (
|
|
<Stack spacing={5}>
|
|
<Heading size="lg">{match.home} vs {match.away}</Heading>
|
|
<Flex align="center" justify="center" gap={4}>
|
|
{match.home_logo_url && (
|
|
<Image src={match.home_logo_url} alt={match.home} boxSize="56px" borderRadius="full" />
|
|
)}
|
|
<Text fontSize="2xl" fontWeight="bold">{isPast && match.score ? match.score : 'vs'}</Text>
|
|
{match.away_logo_url && (
|
|
<Image src={match.away_logo_url} alt={match.away} boxSize="56px" borderRadius="full" />
|
|
)}
|
|
</Flex>
|
|
<Text textAlign="center" color="gray.700">{match.date} {match.time} {match.venue ? `• ${match.venue}` : ''}</Text>
|
|
{(prevMatch || nextMatch) && (
|
|
<Flex justify="space-between" mt={2}>
|
|
<Box>
|
|
{prevMatch && (
|
|
<Button as={RouterLink} to={`/zapas/${prevMatch.id}`} size="sm" variant="ghost">← {prevMatch.home} vs {prevMatch.away}</Button>
|
|
)}
|
|
</Box>
|
|
<Box>
|
|
{nextMatch && (
|
|
<Button as={RouterLink} to={`/zapas/${nextMatch.id}`} size="sm" variant="ghost">{nextMatch.home} vs {nextMatch.away} →</Button>
|
|
)}
|
|
</Box>
|
|
</Flex>
|
|
)}
|
|
{(match.facr_link || match.report_url) && (
|
|
<Flex justify="center" gap={3}>
|
|
{match.facr_link && (
|
|
<Button
|
|
colorScheme="red"
|
|
variant="outline"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
// Open in background tab without switching focus
|
|
const link = document.createElement('a');
|
|
link.href = match.facr_link || '';
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
link.style.display = 'none';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}}
|
|
>
|
|
FAČR detail
|
|
</Button>
|
|
)}
|
|
{match.report_url && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
// Open in background tab without switching focus
|
|
const link = document.createElement('a');
|
|
link.href = match.report_url || '';
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
link.style.display = 'none';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}}
|
|
>
|
|
Zápis
|
|
</Button>
|
|
)}
|
|
</Flex>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
</Container>
|
|
|
|
{/* Newsletter CTA */}
|
|
<NewsletterCTA />
|
|
|
|
{/* Sponsors Section */}
|
|
<SponsorsSection />
|
|
</MainLayout>
|
|
);
|
|
};
|
|
|
|
export default MatchDetailPage;
|