mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
731 lines
34 KiB
TypeScript
731 lines
34 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { Tabs, TabList, TabPanels, Tab, TabPanel, useColorModeValue } from '@chakra-ui/react';
|
|
import MainLayout from '../components/layout/MainLayout';
|
|
import { getPublicSettings } from '../services/settings';
|
|
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
|
import { sortCategoriesWithOrder } from '../utils/categorySort';
|
|
import { assetUrl, sanitizeClubName } from '../utils/url';
|
|
import MatchModal from '../components/home/MatchModal';
|
|
import { useCountdown, useMultipleCountdowns } from '../hooks/useCountdown';
|
|
import '../styles/theme.css';
|
|
|
|
type MatchItem = {
|
|
id: number | string;
|
|
date: string;
|
|
time: string;
|
|
home: string;
|
|
away: string;
|
|
home_id?: string;
|
|
away_id?: string;
|
|
home_logo_url?: string;
|
|
away_logo_url?: string;
|
|
score?: string | null;
|
|
facr_link?: string | null;
|
|
report_url?: string | null;
|
|
venue?: string;
|
|
competition?: string;
|
|
competitionName?: string;
|
|
}
|
|
|
|
const MatchesPage: React.FC = () => {
|
|
const [clubName, setClubName] = useState<string>('');
|
|
const [clubId, setClubId] = useState<string>('');
|
|
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
|
|
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name: string; matches: Array<any>; matches_link?: string }>>([]);
|
|
const [activeTab, setActiveTab] = useState<number>(0);
|
|
const [isMatchModalOpen, setIsMatchModalOpen] = useState<boolean>(false);
|
|
const [selectedMatch, setSelectedMatch] = useState<any | null>(null);
|
|
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
|
|
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
|
|
const [sortAscending, setSortAscending] = useState<boolean>(true); // true = oldest first, false = newest first
|
|
const [displayedMatchesCount, setDisplayedMatchesCount] = useState<Record<number, number>>({}); // Track displayed matches per competition index
|
|
const MATCHES_PER_PAGE = 12; // Number of matches to load initially and per load more click
|
|
|
|
// Dark mode colors
|
|
const bgColor = useColorModeValue('#f8f9fb', '#0f1115');
|
|
const cardBg = useColorModeValue('#fff', '#1a1d29');
|
|
const borderColor = useColorModeValue('#e5e7eb', '#2a2e3a');
|
|
const textPrimary = useColorModeValue('#1a1a1a', '#e8eaf0');
|
|
const textSecondary = useColorModeValue('#666', '#9ca3af');
|
|
const headingColor = useColorModeValue('#000', '#fff');
|
|
|
|
const openMatch = (m: any) => {
|
|
try {
|
|
setSelectedMatch(m);
|
|
setIsMatchModalOpen(true);
|
|
} catch {
|
|
// noop
|
|
}
|
|
};
|
|
|
|
// Helper function to sanitize and truncate long club names
|
|
const truncateClubName = (name: string, maxLength: number = 35) => {
|
|
if (!name) return name;
|
|
// First sanitize the club name
|
|
const sanitized = sanitizeClubName(name);
|
|
if (sanitized.length <= maxLength) return sanitized;
|
|
return sanitized.substring(0, maxLength).trim() + '…';
|
|
};
|
|
|
|
// Format date to Czech format
|
|
const formatCzechDate = (dateStr: string, timeStr: string) => {
|
|
try {
|
|
const date = new Date(`${dateStr}T${timeStr}:00`);
|
|
return date.toLocaleDateString('cs-CZ', {
|
|
day: 'numeric',
|
|
month: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
} catch {
|
|
return dateStr;
|
|
}
|
|
};
|
|
|
|
// Sentiment helpers for win/draw/loss detection
|
|
const normalize = (s: string) => String(s || '')
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
const stripPrefixes = (s: string) => {
|
|
let x = normalize(s);
|
|
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
|
|
return x.replace(/\s+/g, ' ').trim();
|
|
};
|
|
|
|
const isClubTeam = (team: string) => {
|
|
try {
|
|
const a = stripPrefixes(team);
|
|
const b = stripPrefixes(clubName || '');
|
|
if (!a || !b) return false;
|
|
// Allow equality or suffix match (handles prefixes like TJ, SK, etc.)
|
|
return a === b || a.endsWith(b) || b.endsWith(a);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const parseScore = (score?: string | null): { h: number; a: number } | null => {
|
|
if (!score) return null;
|
|
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
|
if (!m) return null;
|
|
return { h: parseInt(m[1], 10), a: parseInt(m[2], 10) };
|
|
};
|
|
|
|
const getSentiment = (m: MatchItem): { label: 'Výhra'|'Remíza'|'Prohra'; colorScheme: 'green'|'blue'|'red' } | null => {
|
|
const s = parseScore(m.score);
|
|
if (!s) return null;
|
|
|
|
// First try ID-based matching (most reliable)
|
|
let ourIsHome = false;
|
|
let ourIsAway = false;
|
|
if (clubId) {
|
|
// 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;
|
|
if (s.h === s.a) return { label: 'Remíza', colorScheme: 'blue' };
|
|
const ourGoals = ourIsHome ? s.h : s.a;
|
|
const oppGoals = ourIsHome ? s.a : s.h;
|
|
if (ourGoals > oppGoals) return { label: 'Výhra', colorScheme: 'green' };
|
|
return { label: 'Prohra', colorScheme: 'red' };
|
|
};
|
|
|
|
// Get current date for accurate comparisons
|
|
const now = useMemo(() => Date.now(), []);
|
|
|
|
// Get sorted matches based on sort order
|
|
const sortedCompetitions = useMemo(() => {
|
|
const sorted = facrCompetitions.map(comp => ({
|
|
...comp,
|
|
matches: [...comp.matches].sort((a: any, b: any) => {
|
|
const aTime = new Date(`${a.date}T${(a.time || '00:00')}:00`).getTime();
|
|
const bTime = new Date(`${b.date}T${(b.time || '00:00')}:00`).getTime();
|
|
return sortAscending ? aTime - bTime : bTime - aTime;
|
|
})
|
|
}));
|
|
|
|
// Add "Všechny kategorie" as first tab with all matches combined
|
|
if (sorted.length > 0) {
|
|
const allMatches = sorted.flatMap(comp =>
|
|
comp.matches.map(m => ({ ...m, competitionName: comp.name }))
|
|
);
|
|
allMatches.sort((a: any, b: any) => {
|
|
const aTime = new Date(`${a.date}T${(a.time || '00:00')}:00`).getTime();
|
|
const bTime = new Date(`${b.date}T${(b.time || '00:00')}:00`).getTime();
|
|
return sortAscending ? aTime - bTime : bTime - aTime;
|
|
});
|
|
|
|
return [
|
|
{
|
|
name: 'Všechny kategorie',
|
|
matches: allMatches,
|
|
matches_link: undefined
|
|
},
|
|
...sorted
|
|
];
|
|
}
|
|
|
|
return sorted;
|
|
}, [facrCompetitions, sortAscending]);
|
|
|
|
// Initialize displayed matches count when competitions change
|
|
useEffect(() => {
|
|
const initialCounts: Record<number, number> = {};
|
|
sortedCompetitions.forEach((_, index) => {
|
|
if (displayedMatchesCount[index] === undefined) {
|
|
initialCounts[index] = MATCHES_PER_PAGE;
|
|
}
|
|
});
|
|
if (Object.keys(initialCounts).length > 0) {
|
|
setDisplayedMatchesCount(prev => ({ ...prev, ...initialCounts }));
|
|
}
|
|
}, [sortedCompetitions.length]);
|
|
|
|
// Handle load more for a specific competition
|
|
const handleLoadMore = (competitionIndex: number) => {
|
|
setDisplayedMatchesCount(prev => ({
|
|
...prev,
|
|
[competitionIndex]: (prev[competitionIndex] || MATCHES_PER_PAGE) + MATCHES_PER_PAGE
|
|
}));
|
|
};
|
|
|
|
// Get all upcoming matches for countdown tracking
|
|
const upcomingMatches = useMemo(() => {
|
|
if (activeTab >= sortedCompetitions.length) return [];
|
|
const comp = sortedCompetitions[activeTab];
|
|
const currentTime = Date.now();
|
|
return (comp?.matches || []).filter((m: any) => {
|
|
const matchTime = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
|
|
return matchTime > currentTime;
|
|
});
|
|
}, [sortedCompetitions, activeTab]);
|
|
|
|
// Track countdowns for all upcoming matches in current tab
|
|
const liveCountdowns = useMultipleCountdowns(upcomingMatches, 30000);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const resolveBackendUrl = (path: string) => {
|
|
try {
|
|
if (/^https?:\/\//i.test(path)) return path;
|
|
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
|
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
|
const b = new URL(base);
|
|
const abs = new URL(path, `${b.protocol}//${b.host}`);
|
|
return abs.toString();
|
|
}
|
|
return path;
|
|
} catch {
|
|
return path;
|
|
}
|
|
};
|
|
|
|
const fetchJSON = async (url: string) => {
|
|
try {
|
|
const res = await fetch(resolveBackendUrl(url), { cache: 'no-cache' });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return await res.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
(async () => {
|
|
const [
|
|
cachedSettingsJSON,
|
|
facrClubJSON,
|
|
teamLogoOverridesAPI,
|
|
teamLogoOverridesFile,
|
|
] = await Promise.all([
|
|
fetchJSON('/cache/prefetch/settings.json'),
|
|
fetchJSON('/cache/prefetch/facr_club_info.json'),
|
|
fetchJSON(`/api/v1/public/team-logo-overrides?t=${Date.now()}`),
|
|
fetchJSON('/cache/prefetch/team_logo_overrides.json'),
|
|
]);
|
|
|
|
// Load aliases
|
|
let aliasesList: CompetitionAlias[] = [];
|
|
try {
|
|
aliasesList = await getCompetitionAliasesPublic();
|
|
} catch {}
|
|
const amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
|
|
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
|
|
|
|
let liveSettings: any = null;
|
|
try {
|
|
liveSettings = await getPublicSettings();
|
|
} catch {}
|
|
|
|
if (!cancelled) {
|
|
setAliases(aliasesList || []);
|
|
setAliasMap(amap);
|
|
|
|
// Build override helpers
|
|
const teamLogoOverridesJSON = (teamLogoOverridesAPI && teamLogoOverridesAPI.by_name) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
|
|
const byName: Record<string, string> = (teamLogoOverridesJSON?.by_name || {}) as any;
|
|
const normalize = (s: string) => String(s)
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
const stripPrefixes = (s: string) => {
|
|
let x = normalize(s);
|
|
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '').replace(/\s+/g, ' ').trim();
|
|
return x;
|
|
};
|
|
const byNameNormalized: Record<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 getFallbackLogo = (teamName?: string, original?: string) => {
|
|
if (teamName) {
|
|
const exact = (byName || {})[teamName];
|
|
const normName = normalize(teamName);
|
|
let candidate = exact || byNameNormalized[normName];
|
|
if (!candidate) {
|
|
const stripped = stripPrefixes(teamName);
|
|
for (const { keyNorm, url } of byNameStrippedPairs) {
|
|
if (!keyNorm) continue;
|
|
if (stripped.endsWith(keyNorm) || keyNorm.endsWith(stripped)) { candidate = url; break; }
|
|
}
|
|
}
|
|
if (candidate) {
|
|
if (typeof candidate === 'string' && candidate.startsWith('/')) return resolveBackendUrl(candidate);
|
|
return candidate;
|
|
}
|
|
}
|
|
if (original) {
|
|
if (typeof original === 'string' && original.startsWith('/')) return resolveBackendUrl(original);
|
|
return original;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const getOverrideLogo = (teamName?: string, teamId?: string, original?: string) => {
|
|
if (teamId) {
|
|
return `http://logoapi.sportcreative.eu/logos/${teamId}`;
|
|
}
|
|
return getFallbackLogo(teamName, original);
|
|
};
|
|
|
|
const settingsJSON = liveSettings || cachedSettingsJSON;
|
|
|
|
if (settingsJSON) {
|
|
const name = settingsJSON?.club_name || settingsJSON?.clubName || '';
|
|
const id = settingsJSON?.club_id || settingsJSON?.clubId || '';
|
|
const type = settingsJSON?.club_type || 'football';
|
|
if (name) setClubName(name);
|
|
if (id) setClubId(id);
|
|
if (type === 'football' || type === 'futsal') setClubType(type);
|
|
}
|
|
|
|
// Load ALL matches from FACR (no time filtering)
|
|
if (facrClubJSON?.competitions?.length) {
|
|
const comps = (facrClubJSON.competitions || []).map((c: any) => ({
|
|
name: (amap?.[c?.code]?.alias) || (amap?.[c?.id]?.alias) || c.name || c.code || 'Soutěž',
|
|
alias: (amap?.[c?.code]?.alias) || (amap?.[c?.id]?.alias),
|
|
display_order: (amap?.[c?.code]?.display_order) ?? (amap?.[c?.id]?.display_order),
|
|
matches_link: c.matches_link,
|
|
matches: (Array.isArray(c.matches) ? c.matches : []).map((m: any, idx: number) => {
|
|
const dt: string = String(m.date_time || '');
|
|
const [d, t] = dt.includes(' ') ? dt.split(' ') : [dt, ''];
|
|
const [day, month, year] = (d || '').split('.');
|
|
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
|
const time = (t || '18:00').slice(0,5);
|
|
|
|
// Check if match is in the future - if so, ignore score
|
|
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
|
|
const isFutureMatch = matchTime > Date.now();
|
|
const actualScore = isFutureMatch ? null : m.score;
|
|
|
|
return {
|
|
id: m.match_id || idx + 1,
|
|
date: isoDate,
|
|
time,
|
|
home: m.home,
|
|
away: m.away,
|
|
home_id: m.home_id,
|
|
away_id: m.away_id,
|
|
home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url),
|
|
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
|
|
score: actualScore,
|
|
facr_link: m.facr_link,
|
|
report_url: m.report_url,
|
|
venue: m.venue || '',
|
|
competition: c.name || c.code || '',
|
|
};
|
|
})
|
|
}));
|
|
const sortedComps = sortCategoriesWithOrder(comps) as typeof comps;
|
|
setFacrCompetitions(sortedComps);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<MainLayout>
|
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px 16px', background: bgColor, minHeight: '100vh' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, flexWrap: 'wrap', gap: 16 }}>
|
|
<div>
|
|
<h1 style={{ fontSize: '2rem', fontWeight: 800, marginBottom: 8, color: headingColor }}>Všechny zápasy</h1>
|
|
<p style={{ color: textSecondary }}>{clubName || 'Klub'}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setSortAscending(!sortAscending)}
|
|
style={{
|
|
padding: '10px 20px',
|
|
background: 'var(--primary-color, #3b82f6)',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: 8,
|
|
fontWeight: 600,
|
|
fontSize: '0.9rem',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
e.currentTarget.style.boxShadow = '0 4px 8px rgba(0,0,0,0.15)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.transform = 'translateY(0)';
|
|
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
|
|
}}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
{sortAscending ? (
|
|
<path d="M11 5h4M11 9h7M11 13h10M3 17l3 3m0 0l3-3m-3 3V4" />
|
|
) : (
|
|
<path d="M11 5h10M11 9h7M11 13h4M3 17l3-3m0 0l3 3m-3-3v16" />
|
|
)}
|
|
</svg>
|
|
{sortAscending ? 'Nejstarší první' : 'Nejnovější první'}
|
|
</button>
|
|
</div>
|
|
|
|
{sortedCompetitions.length > 0 ? (
|
|
<Tabs
|
|
variant="soft-rounded"
|
|
colorScheme="blue"
|
|
index={activeTab}
|
|
onChange={setActiveTab}
|
|
>
|
|
<TabList
|
|
mb={4}
|
|
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'
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{sortedCompetitions.map((c, i) => (
|
|
<Tab key={`${c.name}-${i}`}>
|
|
{c.name}
|
|
</Tab>
|
|
))}
|
|
</TabList>
|
|
<TabPanels>
|
|
{sortedCompetitions.map((c, compIdx) => (
|
|
<TabPanel key={`panel-${c.name}-${compIdx}`} px={0}>
|
|
{c.matches.length === 0 ? (
|
|
<div style={{ textAlign: 'center', padding: '40px 0', color: textSecondary }}>
|
|
Žádné zápasy k zobrazení
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
|
gap: 16,
|
|
}}
|
|
>
|
|
{c.matches.slice(0, displayedMatchesCount[compIdx] || MATCHES_PER_PAGE).map((m: MatchItem, idx: number) => {
|
|
const matchTime = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
|
|
const currentTime = Date.now();
|
|
const isFuture = matchTime > currentTime;
|
|
const isPast = matchTime < currentTime;
|
|
const hasScore = m.score && m.score.trim() !== '';
|
|
const countdown = liveCountdowns[String(m.id)];
|
|
|
|
return (
|
|
<div
|
|
key={m.id || idx}
|
|
onClick={() => openMatch({ ...m, competitionName: c.name })}
|
|
style={{
|
|
background: cardBg,
|
|
border: `2px solid ${borderColor}`,
|
|
borderRadius: 16,
|
|
padding: 20,
|
|
cursor: 'pointer',
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
|
color: textPrimary,
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.transform = 'translateY(-8px)';
|
|
e.currentTarget.style.boxShadow = '0 16px 40px rgba(0,0,0,0.12), 0 6px 16px rgba(0,0,0,0.08)';
|
|
e.currentTarget.style.borderColor = 'var(--chakra-colors-brand-primary, #3b82f6)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.transform = 'translateY(0)';
|
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)';
|
|
e.currentTarget.style.borderColor = borderColor;
|
|
}}
|
|
>
|
|
<div style={{ fontSize: '0.85rem', color: textSecondary, marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 600 }}>
|
|
<span>
|
|
{formatCzechDate(m.date, m.time || '00:00')}
|
|
</span>
|
|
<span style={{ background: 'var(--chakra-colors-brand-primary, #3b82f6)', color: 'white', padding: '4px 10px', borderRadius: 8, fontSize: '0.8rem', fontWeight: 700 }}>{m.time}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
|
|
<div style={{ width: 48, height: 48, borderRadius: '50%', overflow: 'hidden', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<img
|
|
src={assetUrl(m.home_logo_url) || '/images/club-logo.png'}
|
|
alt={m.home}
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
objectFit: 'contain',
|
|
padding: assetUrl(m.home_logo_url)?.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
|
|
boxSizing: 'border-box'
|
|
}}
|
|
onError={(e) => { (e.target as HTMLImageElement).src = '/images/club-logo.png'; }}
|
|
/>
|
|
</div>
|
|
<span style={{ fontWeight: 700, fontSize: '1rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={m.home}>
|
|
{truncateClubName(m.home)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div style={{ textAlign: 'center', margin: '20px 0', fontSize: '1.8rem', fontWeight: 'bold' }}>
|
|
{isFuture ? (
|
|
countdown ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
|
<span style={{ fontSize: '0.7rem', color: '#f97316', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.5px' }}>Začátek za</span>
|
|
<span style={{ fontSize: '1.4rem', color: '#f97316', fontWeight: 800, fontFamily: 'monospace' }}>{countdown}</span>
|
|
</div>
|
|
) : (
|
|
<span style={{ fontSize: '1.2rem', color: '#3b82f6', fontWeight: 600 }}>vs</span>
|
|
)
|
|
) : hasScore ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
|
<span style={{ fontSize: '2rem', fontWeight: 800, background: 'linear-gradient(135deg, #3b82f6, #2563eb)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>{m.score}</span>
|
|
</div>
|
|
) : (
|
|
<span style={{ fontSize: '1.2rem', color: '#9ca3af', fontWeight: 600 }}>—:—</span>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
|
|
<div style={{ width: 48, height: 48, borderRadius: '50%', overflow: 'hidden', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<img
|
|
src={assetUrl(m.away_logo_url) || '/images/club-opponent.png'}
|
|
alt={m.away}
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
objectFit: 'contain',
|
|
padding: assetUrl(m.away_logo_url)?.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
|
|
boxSizing: 'border-box'
|
|
}}
|
|
onError={(e) => { (e.target as HTMLImageElement).src = '/images/club-opponent.png'; }}
|
|
/>
|
|
</div>
|
|
<span style={{ fontWeight: 700, fontSize: '1rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={m.away}>
|
|
{truncateClubName(m.away)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{m.venue && (
|
|
<div style={{ fontSize: '0.85rem', color: textSecondary, marginTop: 12, textAlign: 'center' }}>
|
|
{m.venue}
|
|
</div>
|
|
)}
|
|
{(m as any).competitionName && activeTab === 0 && (
|
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
|
<span style={{ fontSize: '0.75rem', color: 'white', background: 'var(--chakra-colors-brand-primary, #3b82f6)', padding: '4px 12px', borderRadius: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
|
{(m as any).competitionName}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{(() => {
|
|
const sentiment = hasScore && isPast ? getSentiment(m) : null;
|
|
if (sentiment) {
|
|
const colors = {
|
|
green: { bg: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', shadow: 'rgba(16, 185, 129, 0.3)' },
|
|
blue: { bg: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', shadow: 'rgba(59, 130, 246, 0.3)' },
|
|
red: { bg: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', shadow: 'rgba(239, 68, 68, 0.3)' }
|
|
};
|
|
const color = colors[sentiment.colorScheme];
|
|
return (
|
|
<div style={{
|
|
fontSize: '0.75rem',
|
|
background: color.bg,
|
|
color: 'white',
|
|
marginTop: 12,
|
|
padding: '6px 12px',
|
|
borderRadius: 8,
|
|
textAlign: 'center',
|
|
fontWeight: 700,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.5px',
|
|
boxShadow: `0 2px 8px ${color.shadow}`
|
|
}}>
|
|
{sentiment.label}
|
|
</div>
|
|
);
|
|
}
|
|
if (!hasScore && isPast) {
|
|
return (
|
|
<div style={{
|
|
fontSize: '0.75rem',
|
|
background: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
|
|
color: 'white',
|
|
marginTop: 12,
|
|
padding: '6px 12px',
|
|
borderRadius: 8,
|
|
textAlign: 'center',
|
|
fontWeight: 700,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.5px',
|
|
boxShadow: '0 2px 8px rgba(107, 114, 128, 0.3)'
|
|
}}>
|
|
Odehráno
|
|
</div>
|
|
);
|
|
}
|
|
if (!hasScore && isFuture && !countdown) {
|
|
return (
|
|
<div style={{
|
|
fontSize: '0.75rem',
|
|
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
|
color: 'white',
|
|
marginTop: 12,
|
|
padding: '6px 12px',
|
|
borderRadius: 8,
|
|
textAlign: 'center',
|
|
fontWeight: 700,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.5px',
|
|
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)'
|
|
}}>
|
|
Nadcházející
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{c.matches.length > (displayedMatchesCount[compIdx] || MATCHES_PER_PAGE) && (
|
|
<div style={{ textAlign: 'center', marginTop: 32 }}>
|
|
<button
|
|
onClick={() => handleLoadMore(compIdx)}
|
|
style={{
|
|
padding: '14px 32px',
|
|
background: 'var(--primary-color, #3b82f6)',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: 12,
|
|
fontWeight: 700,
|
|
fontSize: '1rem',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 10
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
e.currentTarget.style.boxShadow = '0 8px 20px rgba(59, 130, 246, 0.4)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.transform = 'translateY(0)';
|
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)';
|
|
}}
|
|
>
|
|
<span>Načíst další zápasy</span>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="6 9 12 15 18 9"></polyline>
|
|
</svg>
|
|
</button>
|
|
<div style={{ marginTop: 12, fontSize: '0.875rem', color: textSecondary, fontWeight: 600 }}>
|
|
Zobrazeno {Math.min(displayedMatchesCount[compIdx] || MATCHES_PER_PAGE, c.matches.length)} z {c.matches.length} zápasů
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</TabPanel>
|
|
))}
|
|
</TabPanels>
|
|
</Tabs>
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: '60px 0', color: textSecondary }}>
|
|
<p style={{ fontSize: '1.2rem', marginBottom: 8, color: headingColor }}>Žádné zápasy k zobrazení</p>
|
|
<p style={{ fontSize: '0.9rem', color: textSecondary }}>Zkontrolujte nastavení klubu v administraci</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<MatchModal isOpen={isMatchModalOpen} match={selectedMatch} onClose={() => setIsMatchModalOpen(false)} />
|
|
</MainLayout>
|
|
);
|
|
};
|
|
|
|
export default MatchesPage;
|