This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
@@ -0,0 +1,123 @@
import React from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import HorizontalScroller from '../ui/HorizontalScroller';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url';
import { Eye, Clock } from 'lucide-react';
const Card: React.FC<{ a: Article }> = ({ a }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
const theme = useClubTheme();
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
const categoryBadgeColor = useColorModeValue('gray.100', 'whiteAlpha.200');
const categoryName = (a as any)?.category?.name || '';
return (
<Box
as={RouterLink}
to={link}
minW={{ base: '85%', md: '60%', lg: '33%' }}
scrollSnapAlign="start"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="lg"
borderWidth="1px"
borderColor={border}
_hover={{ transform: 'translateY(-4px)', boxShadow: '2xl' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
position="relative"
>
<Box position="relative" overflow="hidden">
<Image
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
alt={a.title}
w="100%"
h={{ base: '200px', md: '240px' }}
objectFit="cover"
transition="transform 0.3s ease"
_groupHover={{ transform: 'scale(1.05)' }}
/>
{categoryName && (
<Badge
position="absolute"
top={3}
left={3}
colorScheme="blue"
fontSize="xs"
px={3}
py={1}
borderRadius="full"
textTransform="uppercase"
fontWeight="bold"
>
{categoryName}
</Badge>
)}
</Box>
<VStack align="stretch" spacing={3} p={5}>
<Heading size="md" noOfLines={2} lineHeight="1.3">{a.title}</Heading>
{a.content && (
<Text fontSize="sm" color="gray.600" noOfLines={3} lineHeight="1.5">
{a.content.replace(/<[^>]*>/g, '').trim()}
</Text>
)}
<HStack spacing={3} pt={2} borderTopWidth="1px" borderColor={border} flexWrap="wrap">
{(a.read_time || a.estimated_read_minutes) && (
<HStack spacing={1}>
<Clock size={14} color="gray" />
<Text fontSize="xs" color="gray.500">
{a.read_time || a.estimated_read_minutes} min
</Text>
</HStack>
)}
{a.view_count !== undefined && a.view_count > 0 && (
<HStack spacing={1}>
<Eye size={14} color="gray" />
<Text fontSize="xs" color="gray.500">
{a.view_count}
</Text>
</HStack>
)}
{a.published_at && (
<Text fontSize="xs" color="gray.500">
{new Date(a.published_at).toLocaleDateString('cs-CZ')}
</Text>
)}
</HStack>
</VStack>
</Box>
);
};
const BlogCardsScroller: React.FC = () => {
const theme = useClubTheme();
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
const list: Article[] = data?.data || [];
return (
<Box>
<HorizontalScroller
title="Novinky"
rightAction={<Button as={RouterLink} to="/blog" variant="link" color="brand.primary">Více</Button>}
>
{isLoading && Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} minW={{ base: '85%', md: '60%', lg: '33%' }} h={{ base: '260px', md: '300px' }} borderRadius="xl" />
))}
{!isLoading && list.map((a) => (
<Card key={a.id} a={a} />
))}
</HorizontalScroller>
</Box>
);
};
export default BlogCardsScroller;
+103
View File
@@ -0,0 +1,103 @@
import { Box, Heading, SimpleGrid, Image, Text, VStack, HStack, Button, Skeleton, Badge, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
const categoryName = (article as any)?.category?.name || '';
return (
<VStack
as={RouterLink}
to={link}
align="stretch"
spacing={0}
borderWidth="1px"
borderRadius="xl"
bg={cardBg}
overflow="hidden"
boxShadow="lg"
borderColor={border}
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
>
<Box position="relative" overflow="hidden">
<Image
src={assetUrl(article.image_url) || '/logo192.png'}
alt={article.title}
objectFit="cover"
w="100%"
h="200px"
transition="transform 0.3s ease"
_groupHover={{ transform: 'scale(1.05)' }}
/>
{categoryName && (
<Badge
position="absolute"
top={3}
left={3}
colorScheme="blue"
fontSize="xs"
px={3}
py={1}
borderRadius="full"
textTransform="uppercase"
fontWeight="bold"
>
{categoryName}
</Badge>
)}
</Box>
<VStack align="stretch" spacing={3} p={5}>
<Heading size="md" noOfLines={2} lineHeight="1.3">{article.title}</Heading>
<Text noOfLines={3} color="gray.600" fontSize="sm" lineHeight="1.5">
{article.content?.replace(/<[^>]*>/g, '').slice(0, 160)}
</Text>
<HStack spacing={2} pt={2} borderTopWidth="1px" borderColor={border}>
{article.estimated_read_minutes && (
<Text fontSize="xs" color="gray.500">
{article.estimated_read_minutes} min čtení
</Text>
)}
{article.published_at && (
<Text fontSize="xs" color="gray.500">
{new Date(article.published_at).toLocaleDateString('cs-CZ')}
</Text>
)}
</HStack>
</VStack>
</VStack>
);
};
const BlogGrid: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 10, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 10, published: true }),
});
const articles = data?.data || [];
return (
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Aktuality</Heading>
<Button as={RouterLink} to="/blog" size="sm" variant="link">Zobrazit všechny</Button>
</HStack>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={6}>
{isLoading && Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height="240px" />
))}
{!isLoading && articles.map((a) => (
<BlogCard key={a.id} article={a} />
))}
</SimpleGrid>
</Box>
);
};
export default BlogGrid;
+302
View File
@@ -0,0 +1,302 @@
import React, { useRef, useState, useCallback } from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Skeleton, Button, IconButton, Flex, useBreakpointValue, Container } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { motion, AnimatePresence } from 'framer-motion';
import { wrap } from 'popmotion';
const MotionBox = motion(Box);
const MotionImage = motion(Image);
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 1000 : -1000,
opacity: 0
}),
center: {
zIndex: 1,
x: 0,
opacity: 1
},
exit: (direction: number) => ({
zIndex: 0,
x: direction < 0 ? 1000 : -1000,
opacity: 0
})
};
const swipeConfidenceThreshold = 10000;
const swipePower = (offset: number, velocity: number) => {
return Math.abs(offset) * velocity;
};
const HeroSlide: React.FC<{ article: Article }> = ({ article }) => {
const theme = useClubTheme();
const excerpt = (article.content || '').replace(/<[^>]*>/g, '').slice(0, 200) + '...';
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
return (
<Box
position="relative"
w="100%"
h={{ base: '500px', md: '600px' }}
overflow="hidden"
borderRadius={{ base: 'none', md: 'xl' }}
boxShadow="lg"
>
<MotionImage
src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'}
alt={article.title}
w="100%"
h="100%"
objectFit="cover"
initial={{ opacity: 0.7 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
/>
<Box
position="absolute"
bottom={0}
left={0}
right={0}
p={{ base: 6, md: 10 }}
bgGradient="linear(to-t, blackAlpha.900, blackAlpha.700, transparent)"
color="white"
>
<Container maxW="7xl" px={{ base: 4, md: 6 }}>
{/* Top-left BLOG link badge */}
<HStack spacing={3} mb={4}>
<Button
as={RouterLink}
to="/blog"
size="sm"
px={3}
height="28px"
borderRadius="full"
bg={theme.primary}
color="white"
_hover={{ bg: theme.accent }}
>
BLOG
</Button>
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}></Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}>Klubové aktuality</Text>
</HStack>
<Box maxW={{ base: '100%', md: '70%', lg: '55%' }}>
<Text
fontSize={{ base: 'sm', md: 'md' }}
fontWeight="bold"
color={theme.accent}
textTransform="uppercase"
letterSpacing="0.1em"
mb={2}
>
Nejnovější aktualita
</Text>
<Heading
as="h2"
size={{ base: 'xl', md: '2xl', lg: '3xl' }}
mb={4}
lineHeight="1.2"
textShadow="0 2px 4px rgba(0,0,0,0.5)"
>
{article.title}
</Heading>
<Text
fontSize={{ base: 'sm', md: 'md' }}
noOfLines={3}
mb={6}
textShadow="0 1px 2px rgba(0,0,0,0.5)"
>
{excerpt}
</Text>
<HStack spacing={4}>
<Button
as={RouterLink}
to={link}
size="lg"
bg={theme.primary}
color="white"
rightIcon={<ChevronRightIcon />}
_hover={{
bg: theme.accent,
transform: 'translateY(-2px)',
boxShadow: 'lg',
}}
>
Číst více
</Button>
<Button
as={RouterLink}
to="/blog"
size="lg"
variant="outline"
borderColor="whiteAlpha.700"
color="white"
_hover={{ bg: 'whiteAlpha.200' }}
>
Všechny články
</Button>
</HStack>
</Box>
</Container>
</Box>
</Box>
);
};
const BlogSwiper: React.FC = () => {
const [page, setPage] = useState(0);
const [[slideIndex, direction], setSlideIndex] = useState([0, 0]);
const { data: featuredData, isLoading: loadingFeatured } = useQuery({
queryKey: ['featured-articles', { page: 1, page_size: 5 }],
queryFn: () => getFeaturedArticles({ page: 1, page_size: 5 }),
});
// Fallback to latest published if no featured are available
const { data: latestData } = useQuery({
queryKey: ['latest-articles', { page: 1, page_size: 5, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 5, published: true }),
enabled: Boolean(!loadingFeatured && !(featuredData?.data?.length)),
});
const articles = (featuredData?.data?.length ? featuredData.data : (latestData?.data || []));
const articleIndex = wrap(0, articles.length, slideIndex);
const paginate = useCallback(
(newDirection: number) => {
setSlideIndex([slideIndex + newDirection, newDirection]);
},
[slideIndex]
);
// Auto-advance slides
React.useEffect(() => {
if (articles.length <= 1) return;
const timer = setInterval(() => {
paginate(1);
}, 8000);
return () => clearInterval(timer);
}, [articles.length, paginate]);
if (loadingFeatured) {
return (
<Skeleton
w="100%"
h={{ base: '500px', md: '600px' }}
borderRadius={{ base: 'none', md: 'xl' }}
/>
);
}
if (!articles.length) return null;
const currentArticle = articles[articleIndex];
if (!currentArticle) return null;
return (
<Box position="relative" w="100%" overflow="hidden">
<AnimatePresence initial={false} custom={direction}>
<MotionBox
key={slideIndex}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 }
}}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={1}
onDragEnd={(e, { offset, velocity }) => {
const swipe = swipePower(offset.x, velocity.x);
if (swipe < -swipeConfidenceThreshold) {
paginate(1);
} else if (swipe > swipeConfidenceThreshold) {
paginate(-1);
}
}}
position="relative"
w="100%"
h="100%"
>
<HeroSlide article={currentArticle} />
</MotionBox>
</AnimatePresence>
{articles.length > 1 && (
<>
<IconButton
aria-label="Předchozí slide"
icon={<ChevronLeftIcon />}
position="absolute"
left={4}
top="50%"
transform="translateY(-50%)"
zIndex={2}
borderRadius="full"
colorScheme="blackAlpha"
onClick={() => paginate(-1)}
size="lg"
/>
<IconButton
aria-label="Další slide"
icon={<ChevronRightIcon />}
position="absolute"
right={4}
top="50%"
transform="translateY(-50%)"
zIndex={2}
borderRadius="full"
colorScheme="blackAlpha"
onClick={() => paginate(1)}
size="lg"
/>
<Flex
position="absolute"
bottom={8}
left="50%"
transform="translateX(-50%)"
zIndex={2}
gap={2}
>
{articles.map((_, index) => (
<Box
key={index}
as="button"
px={2}
h="20px"
display="flex"
alignItems="center"
justifyContent="center"
fontSize="xs"
fontWeight="700"
color={index === articleIndex ? 'black' : 'white'}
bg={index === articleIndex ? 'white' : 'whiteAlpha.500'}
borderRadius="sm"
onClick={() => setSlideIndex([index, index > articleIndex ? 1 : -1])}
transition="all 0.3s"
_hover={{
bg: 'white',
color: 'black',
}}
>
{String(index + 1).padStart(2, '0')}
</Box>
))}
</Flex>
</>
)}
</Box>
);
};
export default BlogSwiper;
@@ -0,0 +1,85 @@
import React from 'react';
import { Box, HStack, Image, Skeleton, useBreakpointValue, Tooltip } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
const modulo = (n: number, m: number) => ((n % m) + m) % m;
const BlogThumbStrip: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['thumb-articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
const articles = data?.data?.filter(a => !!a.image_url) || [];
const visible = useBreakpointValue({ base: 2, md: 3, lg: 5 }) || 5;
const [index, setIndex] = React.useState(0);
const [paused, setPaused] = React.useState(false);
React.useEffect(() => {
if (articles.length <= visible) return;
const id = setInterval(() => {
if (!paused) setIndex((i) => i + 1);
}, 3000);
return () => clearInterval(id);
}, [articles.length, visible, paused]);
if (isLoading) {
return (
<HStack spacing={3}>
{Array.from({ length: visible }).map((_, i) => (
<Skeleton key={i} w={{ base: '50%', md: `${100/visible}%` }} h={{ base: '90px', md: '120px', lg: '140px' }} borderRadius="md" />
))}
</HStack>
);
}
if (!articles.length) return null;
const items = Array.from({ length: visible }).map((_, i) => {
const idx = modulo(index + i, articles.length);
return articles[idx];
});
return (
<Box
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<HStack spacing={3}>
{items.map((a: Article) => (
<Box
key={a.id}
as={RouterLink}
to={a.slug ? `/news/${a.slug}` : `/articles/${a.id}`}
flex={`0 0 ${100/visible}%`}
position="relative"
borderRadius="md"
overflow="hidden"
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
transition="all 0.25s ease"
>
<Image
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
alt={a.title}
w="100%"
h={{ base: '90px', md: '120px', lg: '140px' }}
objectFit="cover"
/>
<Box position="absolute" bottom={0} left={0} right={0} h="36px" bgGradient="linear(to-t, blackAlpha.700, transparent)" />
<Tooltip label={a.title} openDelay={300}>
<Box position="absolute" bottom={1} left={2} right={2} color="white" fontSize="xs" noOfLines={1}>
{a.title}
</Box>
</Tooltip>
</Box>
))}
</HStack>
</Box>
);
};
export default BlogThumbStrip;
@@ -0,0 +1,41 @@
import { Box, Flex, Heading, Image, HStack, Button, Text } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
const ClubHeader: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
const { data, isLoading, isError } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId, clubType),
enabled: Boolean(clubId),
});
return (
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
<HStack spacing={4}>
<Image src={data?.logo_url || '/logo192.png'} alt={data?.name || 'Club'} boxSize="64px" objectFit="contain" />
<Box>
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
<Text color="gray.600" fontSize="sm">
{data?.address || (!clubId ? 'Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID' : '')}
</Text>
</Box>
</HStack>
<HStack spacing={2}>
<Button as="a" href="https://facebook.com" target="_blank" size="sm" variant="ghost">FB</Button>
<Button as="a" href="https://instagram.com" target="_blank" size="sm" variant="ghost">IG</Button>
<Button as="a" href="https://youtube.com" target="_blank" size="sm" variant="ghost">YT</Button>
{data?.url && (
<Button as="a" href={data.url} target="_blank" size="sm" colorScheme="blue">FAČR profil</Button>
)}
</HStack>
</Flex>
);
};
export default ClubHeader;
+211
View File
@@ -0,0 +1,211 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
Box,
Text,
VStack,
HStack,
Badge,
Flex,
useColorModeValue,
} from '@chakra-ui/react';
import { TeamLogo } from '../common/TeamLogo';
interface ClubModalProps {
isOpen: boolean;
onClose: () => void;
club: {
team: string;
team_id?: string;
team_logo_url?: string;
rank?: string | number;
played?: string | number;
wins?: string | number;
draws?: string | number;
losses?: string | number;
score?: string;
points?: string | number;
// Additional fields from FACR
goals_scored?: string | number;
goals_conceded?: string | number;
goal_difference?: string | number;
form?: string; // Last 5 matches form (e.g., "WWDWL")
position_change?: number; // +/- change in position
} | null;
clubType?: 'football' | 'futsal';
}
const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType = 'football' }) => {
if (!club) return null;
// Theme-aware colors
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'whiteAlpha.300');
const fallbackBg = useColorModeValue('gray.100', 'gray.700');
const fallbackText = useColorModeValue('gray.600', 'gray.300');
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
<ModalOverlay bg="blackAlpha.600" backdropFilter="blur(4px)" />
<ModalContent>
<ModalHeader>
<Flex align="center" gap={3}>
<TeamLogo
teamId={club.team_id}
teamName={club.team}
facrLogo={club.team_logo_url}
size="large"
alt={club.team}
borderRadius="full"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
fallbackIcon={
<Box
w="48px"
h="48px"
bg={fallbackBg}
borderRadius="full"
display="flex"
alignItems="center"
justifyContent="center"
color={fallbackText}
fontSize="lg"
fontWeight="bold"
borderWidth="1px"
borderColor={borderColor}
>
{club.team.substring(0, 2).toUpperCase()}
</Box>
}
/>
<Box>
<Text fontSize="xl" fontWeight="bold">
{club.team}
</Text>
{club.rank && (
<Badge colorScheme="blue" fontSize="sm">
{club.rank}. místo
</Badge>
)}
</Box>
</Flex>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
{/* Statistics */}
<Box
borderWidth="1px"
borderRadius="lg"
p={4}
bg={useColorModeValue('gray.50', 'gray.700')}
borderColor={borderColor}
>
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
Statistiky
</Text>
<VStack spacing={2} align="stretch">
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Odehráno zápasů:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.played || 0}</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Výhry:</Text>
<Text fontWeight="bold" color="green.600">
{club.wins || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Remízy:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.600', 'gray.400')}>
{club.draws || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Prohry:</Text>
<Text fontWeight="bold" color="red.600">
{club.losses || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.score || '0:0'}</Text>
</HStack>
{(club.goals_scored !== undefined || club.goals_conceded !== undefined) && (
<>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Vstřelené góly:</Text>
<Text fontWeight="bold" color="green.500">{club.goals_scored || 0}</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Obdržené góly:</Text>
<Text fontWeight="bold" color="red.500">{club.goals_conceded || 0}</Text>
</HStack>
</>
)}
{club.goal_difference !== undefined && (
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre rozdíl:</Text>
<Text fontWeight="bold" color={Number(club.goal_difference) >= 0 ? 'green.600' : 'red.600'}>
{Number(club.goal_difference) > 0 ? '+' : ''}{club.goal_difference}
</Text>
</HStack>
)}
<HStack justify="space-between" pt={2} borderTopWidth="1px" borderColor={borderColor}>
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">Body:</Text>
<Badge colorScheme="blue" fontSize="lg" px={3} py={1}>
{club.points || 0}
</Badge>
</HStack>
</VStack>
</Box>
{/* Form (Last 5 matches) */}
{club.form && (
<Box
borderWidth="1px"
borderRadius="lg"
p={4}
bg={useColorModeValue('gray.50', 'gray.700')}
borderColor={borderColor}
>
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
Forma (posledních 5 zápasů)
</Text>
<HStack spacing={2} justify="center">
{club.form.split('').map((result, idx) => (
<Badge
key={idx}
colorScheme={result === 'W' ? 'green' : result === 'D' ? 'yellow' : 'red'}
fontSize="md"
px={3}
py={1}
borderRadius="md"
>
{result === 'W' ? 'V' : result === 'D' ? 'R' : 'P'}
</Badge>
))}
</HStack>
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button colorScheme="gray" onClick={onClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default ClubModal;
@@ -0,0 +1,151 @@
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import { facrApi } from '../../services/facr/facrApi';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../../services/competitionAliases';
import { TeamLogo } from '../common/TeamLogo';
import { sortCategoriesWithOrder } from '../../utils/categorySort';
import '../../styles/logos.css';
const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string; aid?: string; al?: string; s?: string; clubName?: string }> = ({ d, h, hid, hl, a, aid, al, s, clubName }) => (
<HStack justify="space-between" borderRadius="lg" p={3} bg="white" boxShadow="sm">
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
<HStack flex={1} justify="flex-end" spacing={4}>
<HStack minW="40%" justify="flex-end" spacing={2}>
<Text noOfLines={1} textAlign="right" flex={1}>{h}</Text>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={hid}
teamName={h}
facrLogo={hl}
size="custom"
boxSize="28px"
/>
</Box>
</HStack>
<HStack minW="60px" justify="center" spacing={2}>
<Text fontWeight="bold" textAlign="center">{s || '-:-'}</Text>
{(() => {
if (!s || !clubName) return null;
const m = s.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
if (!m) return null;
const hG = parseInt(m[1], 10), aG = parseInt(m[2], 10);
const norm = (x: string) => String(x||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,' ').trim().toLowerCase();
const strip = (x: string) => norm(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();
const ourHome = (() => { const A = strip(h); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
const ourAway = (() => { const A = strip(a); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
if (!ourHome && !ourAway) return null;
if (hG === aG) return <Badge colorScheme="blue" variant="subtle">Remíza</Badge>;
const our = ourHome ? hG : aG; const opp = ourHome ? aG : hG;
return our > opp ? <Badge colorScheme="green" variant="subtle">Výhra</Badge> : <Badge colorScheme="red" variant="subtle">Prohra</Badge>;
})()}
</HStack>
<HStack minW="40%" spacing={2}>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={aid}
teamName={a}
facrLogo={al}
size="custom"
boxSize="28px"
/>
</Box>
<Text noOfLines={1} flex={1}>{a}</Text>
</HStack>
</HStack>
</HStack>
);
const CompetitionMatches: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const { data, isLoading } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType as any),
enabled: !!clubId,
});
// Load competition aliases
const [aliases, setAliases] = React.useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
React.useEffect(() => {
let mounted = true;
(async () => {
try {
const list: CompetitionAlias[] = await getCompetitionAliasesPublic();
if (!mounted) return;
const map: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
(list || []).forEach((a) => { if (a?.code && a?.alias) map[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
setAliases(map);
} catch {}
})();
return () => { mounted = false; };
}, []);
// Precompute sorted competitions safely (must be before any early returns to keep hooks order stable)
const competitions = data?.competitions ?? [];
const sortedCompetitions = React.useMemo(() => {
const arr = Array.isArray(competitions) ? competitions : [];
return sortCategoriesWithOrder(
arr.map(c => ({
...c,
name: aliases[c.code]?.alias || aliases[c.id]?.alias || c.name,
alias: aliases[c.code]?.alias || aliases[c.id]?.alias,
display_order: (aliases[c.code]?.display_order) ?? (aliases[c.id]?.display_order),
}))
);
}, [competitions, aliases]);
if (isLoading) return <Skeleton height="200px" />;
if (!clubId) {
return (
<Box p={4} bg="yellow.50" borderRadius="md" borderWidth="1px" borderColor="yellow.200">
<Text color="gray.700">
Pro zobrazení zápasů je potřeba nastavit klub v administraci (Nastavení Základní údaje).
</Text>
</Box>
);
}
if (!data || !data.competitions || data.competitions.length === 0) {
return (
<Box p={4} bg="gray.50" borderRadius="md" borderWidth="1px" borderColor="gray.200">
<Text color="gray.600">
Žádné soutěže ani zápasy nejsou k dispozici pro vybraný klub.
</Text>
</Box>
);
}
// Sort competitions by age (Muži first, then U19, U17, etc.) and respect custom order (computed above)
return (
<Box>
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
<TabList>
{sortedCompetitions.map((c) => {
const label = c.alias || c.name;
return <Tab key={c.id}>{label}</Tab>;
})}
</TabList>
<TabPanels>
{sortedCompetitions.map((c) => (
<TabPanel key={c.id} px={0}>
<VStack align="stretch" spacing={3}>
{(c.matches || []).slice(0, 6).map((m, idx) => (
<Row key={m.match_id || idx} d={m.date_time} h={m.home} hid={m.home_id} hl={m.home_logo_url} a={m.away} aid={m.away_id} al={m.away_logo_url} s={m.score} clubName={data.name} />
))}
{(c.matches || []).length === 0 && (
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
)}
</VStack>
</TabPanel>
))}
</TabPanels>
</Tabs>
</Box>
);
};
export default CompetitionMatches;
+328
View File
@@ -0,0 +1,328 @@
import React, { useEffect, useRef } from 'react';
import { Box } from '@chakra-ui/react';
// Dynamically load Leaflet
let L: any = null;
interface ContactMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: string;
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
}
// Available map styles
export const MAP_STYLES = {
// Clean & Minimal
'positron': {
name: 'Positron (Light)',
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Clean light map, perfect for overlays'
},
'positron-no-labels': {
name: 'Positron No Labels',
url: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Minimal light map without labels'
},
// Dark Themes
'dark': {
name: 'Dark Matter',
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Dark theme, great for night mode'
},
'dark-no-labels': {
name: 'Dark No Labels',
url: 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Dark map without labels'
},
// Black & White
'toner': {
name: 'Toner (B&W)',
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png',
attribution: '© Stamen Design © OpenStreetMap',
description: 'High contrast black and white'
},
'toner-lite': {
name: 'Toner Lite (B&W)',
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Subtle black and white'
},
// Colorful Options
'voyager': {
name: 'Voyager',
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Balanced colors, good readability'
},
'terrain': {
name: 'Terrain',
url: 'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.jpg',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Natural terrain visualization'
},
'watercolor': {
name: 'Watercolor',
url: 'https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Artistic watercolor style'
},
// Default
'default': {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
description: 'Standard OpenStreetMap'
},
// Satellite
'satellite': {
name: 'Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri',
description: 'Satellite imagery'
},
};
const ContactMap: React.FC<ContactMapProps> = ({
latitude,
longitude,
zoom = 15,
address,
clubName,
mapStyle = 'default',
height = 400,
clubPrimaryColor,
clubSecondaryColor,
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const [isLoaded, setIsLoaded] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
useEffect(() => {
// Load Leaflet CSS and JS dynamically
const loadLeaflet = async () => {
try {
// Check if already loaded
if ((window as any).L) {
L = (window as any).L;
setIsLoaded(true);
return;
}
// Load CSS
if (!document.getElementById('leaflet-css')) {
const link = document.createElement('link');
link.id = 'leaflet-css';
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
link.crossOrigin = '';
document.head.appendChild(link);
}
// Load JS
if (!document.getElementById('leaflet-js')) {
const script = document.createElement('script');
script.id = 'leaflet-js';
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
script.crossOrigin = '';
script.onload = () => {
L = (window as any).L;
setIsLoaded(true);
};
script.onerror = () => {
setLoadError('Failed to load map library');
};
document.head.appendChild(script);
}
} catch (error) {
setLoadError('Error loading map');
}
};
loadLeaflet();
}, []);
useEffect(() => {
if (!isLoaded || !L || !mapRef.current || mapInstanceRef.current) return;
try {
// Initialize map
const map = L.map(mapRef.current, {
center: [latitude, longitude],
zoom: zoom,
scrollWheelZoom: false, // Disable scroll zoom for better UX
});
mapInstanceRef.current = map;
// Get tile layer URL based on style
let tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
let attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Use predefined styles or custom URL
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
tileUrl = style.url;
attribution = style.attribution;
} else if (mapStyle && mapStyle.startsWith('http')) {
// Custom tile URL
tileUrl = mapStyle;
}
// Add tile layer
const tileLayer = L.tileLayer(tileUrl, {
attribution: attribution,
maxZoom: 19,
}).addTo(map);
// Apply club color overlay if provided
if (clubPrimaryColor && clubPrimaryColor !== '') {
const colorFilter = createColorFilter(clubPrimaryColor);
if (colorFilter) {
const pane = map.createPane('colorOverlay');
pane.style.zIndex = '400';
pane.style.pointerEvents = 'none';
pane.style.mixBlendMode = 'multiply';
pane.style.backgroundColor = colorFilter;
pane.style.opacity = '0.15';
}
}
// Create custom marker icon with club colors
const markerColor = clubPrimaryColor || '#3388ff';
const customIcon = createCustomMarkerIcon(markerColor, L);
// Add marker
const marker = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
// Add popup if address is provided
if (clubName || address) {
let popupContent = '';
if (clubName) popupContent += `<b>${clubName}</b><br>`;
if (address) popupContent += address;
marker.bindPopup(popupContent);
}
// Enable scroll zoom on click
map.on('click', () => {
map.scrollWheelZoom.enable();
});
// Disable scroll zoom on mouseout
map.on('mouseout', () => {
map.scrollWheelZoom.disable();
});
} catch (error) {
console.error('Error initializing map:', error);
setLoadError('Failed to initialize map');
}
// Cleanup
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, [isLoaded, latitude, longitude, zoom, address, clubName, mapStyle, clubPrimaryColor, clubSecondaryColor]);
// Helper function to create color filter
function createColorFilter(color: string): string | null {
try {
// Validate and normalize color
const tempDiv = document.createElement('div');
tempDiv.style.color = color;
document.body.appendChild(tempDiv);
const computedColor = window.getComputedStyle(tempDiv).color;
document.body.removeChild(tempDiv);
return computedColor;
} catch {
return null;
}
}
// Helper function to create custom marker with club colors
function createCustomMarkerIcon(color: string, leaflet: any) {
// Create SVG marker with custom color
const svgIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<path fill="${color}" stroke="#fff" stroke-width="1.5" filter="url(#shadow)"
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
<circle cx="12" cy="9" r="3" fill="#fff"/>
</svg>
`;
const iconUrl = 'data:image/svg+xml;base64,' + btoa(svgIcon);
return leaflet.icon({
iconUrl: iconUrl,
iconSize: [36, 54],
iconAnchor: [18, 54],
popupAnchor: [0, -54],
});
}
if (loadError) {
return (
<Box
ref={mapRef}
w="100%"
h={`${height}px`}
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
>
{loadError}
</Box>
);
}
return (
<Box
ref={mapRef}
w="100%"
h={`${height}px`}
borderRadius="md"
overflow="hidden"
boxShadow="md"
/>
);
};
export default ContactMap;
@@ -0,0 +1,334 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Container,
Heading,
Text,
SimpleGrid,
VStack,
HStack,
Avatar,
Badge,
Link,
Icon,
useColorModeValue,
Divider,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
} from '@chakra-ui/react';
import { FiMail, FiPhone, FiMapPin } from 'react-icons/fi';
import { getPublicContacts, GroupedContacts } from '../../services/contactInfo';
import { getPublicSettings } from '../../services/settings';
import ContactMap from './ContactMap';
import { getImageUrl } from '../../utils/imageUtils';
const ContactsSection: React.FC = () => {
const [contactsData, setContactsData] = useState<GroupedContacts | null>(null);
const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [contacts, settingsData] = await Promise.all([
getPublicContacts(),
getPublicSettings(),
]);
setContactsData(contacts);
setSettings(settingsData);
} catch (error) {
console.error('Failed to load contacts:', error);
} finally {
setLoading(false);
}
};
if (loading || !contactsData) {
return null;
}
// Check if there's any data to display
const hasContacts = Object.keys(contactsData.categories).length > 0 || contactsData.uncategorized.length > 0;
const hasLocation = settings?.location_latitude && settings?.location_longitude;
const hasContactInfo = settings?.contact_address || settings?.contact_phone || settings?.contact_email;
if (!hasContacts && !hasLocation && !hasContactInfo) {
return null; // Don't render if no data
}
return (
<Box py={16} bg={useColorModeValue('gray.50', 'gray.900')}>
<Container maxW="container.xl">
<VStack spacing={8} align="stretch">
<Box textAlign="center">
<Heading size="xl" mb={4}>Kontakt</Heading>
<Text fontSize="lg" color="gray.600">
Spojte se s námi
</Text>
</Box>
{/* Map and Address Section */}
{(hasLocation || hasContactInfo) && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
{/* Map - always show if coordinates are set */}
{hasLocation && (
<Box>
<ContactMap
latitude={settings.location_latitude}
longitude={settings.location_longitude}
zoom={settings.map_zoom_level || 15}
address={settings.contact_address}
clubName={settings.club_name || settings.site_title}
mapStyle={settings.map_style || 'default'}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
/>
</Box>
)}
{/* Contact Information */}
{hasContactInfo && (
<Box
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
>
<VStack align="stretch" spacing={4}>
<Heading size="md">Naše adresa</Heading>
{settings.contact_address && (
<HStack align="start">
<Icon as={FiMapPin} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Adresa</Text>
<Text>{settings.contact_address}</Text>
{settings.contact_city && (
<Text>
{settings.contact_zip && `${settings.contact_zip} `}
{settings.contact_city}
</Text>
)}
{settings.contact_country && <Text>{settings.contact_country}</Text>}
</VStack>
</HStack>
)}
{settings.contact_phone && (
<HStack align="start">
<Icon as={FiPhone} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Telefon</Text>
<Link href={`tel:${settings.contact_phone}`} color="blue.500">
{settings.contact_phone}
</Link>
</VStack>
</HStack>
)}
{settings.contact_email && (
<HStack align="start">
<Icon as={FiMail} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Email</Text>
<Link href={`mailto:${settings.contact_email}`} color="blue.500">
{settings.contact_email}
</Link>
</VStack>
</HStack>
)}
</VStack>
</Box>
)}
</SimpleGrid>
)}
{/* Contacts by Category */}
{hasContacts && (
<Box>
<Divider my={8} />
<Accordion allowMultiple defaultIndex={[0]}>
{Object.entries(contactsData.categories).map(([categoryName, contacts]) => (
<AccordionItem key={categoryName} border="none" mb={4}>
<AccordionButton
bg={bgColor}
borderRadius="lg"
p={4}
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
boxShadow="sm"
>
<Box flex="1" textAlign="left">
<Heading size="md">{categoryName}</Heading>
<Text fontSize="sm" color="gray.600">
{contacts.length} {contacts.length === 1 ? 'kontakt' : 'kontaktů'}
</Text>
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} pt={4}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{contacts.map((contact) => (
<Box
key={contact.id}
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
transition="transform 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
>
<VStack spacing={4} align="start">
{contact.image_url && (
<Avatar
src={getImageUrl(contact.image_url)}
name={contact.name}
size="xl"
alignSelf="center"
/>
)}
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
<Heading size="sm" mb={1}>
{contact.name}
</Heading>
{contact.position && (
<Badge colorScheme="blue" mb={2}>
{contact.position}
</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">
{contact.description}
</Text>
)}
<VStack align="start" spacing={2} w="100%">
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
{contact.email}
</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
{contact.phone}
</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
))}
{/* Uncategorized contacts */}
{contactsData.uncategorized.length > 0 && (
<AccordionItem border="none" mb={4}>
<AccordionButton
bg={bgColor}
borderRadius="lg"
p={4}
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
boxShadow="sm"
>
<Box flex="1" textAlign="left">
<Heading size="md">Ostatní kontakty</Heading>
<Text fontSize="sm" color="gray.600">
{contactsData.uncategorized.length} {contactsData.uncategorized.length === 1 ? 'kontakt' : 'kontaktů'}
</Text>
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} pt={4}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{contactsData.uncategorized.map((contact) => (
<Box
key={contact.id}
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
transition="transform 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
>
<VStack spacing={4} align="start">
{contact.image_url && (
<Avatar
src={getImageUrl(contact.image_url)}
name={contact.name}
size="xl"
alignSelf="center"
/>
)}
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
<Heading size="sm" mb={1}>
{contact.name}
</Heading>
{contact.position && (
<Badge colorScheme="blue" mb={2}>
{contact.position}
</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">
{contact.description}
</Text>
)}
<VStack align="start" spacing={2} w="100%">
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
{contact.email}
</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
{contact.phone}
</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
</Box>
)}
</VStack>
</Container>
</Box>
);
};
export default ContactsSection;
@@ -0,0 +1,92 @@
import { Box, Grid, GridItem, Heading, Image, Text, VStack, HStack, Button, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { Eye, Clock } from 'lucide-react';
const FeaturedBlog: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 3, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 3, published: true }),
});
const theme = useClubTheme();
const articles = data?.data || [];
if (isLoading) return <Skeleton height="320px" />;
const [main, side1, side2] = [articles[0], articles[1], articles[2]];
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="lg">Aktuality</Heading>
<Button as={RouterLink} to="/blog" size="sm" variant="link">Všechny články</Button>
</HStack>
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={4}>
<GridItem>
{main && (
<Box as={RouterLink} to={main.slug ? `/news/${main.slug}` : `/articles/${main.id}`} position="relative" overflow="hidden" borderRadius="xl">
<Image src={assetUrl(main.image_url) || '/logo192.png'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.7), rgba(0,0,0,0.1))" />
{/* Stats badges */}
{((main.read_time || main.estimated_read_minutes) || (main.view_count && main.view_count > 0)) && (
<HStack position="absolute" top={3} right={3} spacing={2}>
{(main.read_time || main.estimated_read_minutes) && (
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
<Clock size={12} />
{main.read_time || main.estimated_read_minutes} min
</Badge>
)}
{main.view_count && main.view_count > 0 && (
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
<Eye size={12} />
{main.view_count}
</Badge>
)}
</HStack>
)}
<VStack align="stretch" spacing={2} position="absolute" bottom={0} p={4} color="white">
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
<Heading size="md">{main.title}</Heading>
</VStack>
</Box>
)}
</GridItem>
<GridItem>
<VStack spacing={4} align="stretch">
{[side1, side2].filter(Boolean).map((a) => (
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
<VStack align="stretch" spacing={2} flex={1}>
<HStack spacing={2} flexWrap="wrap">
{((a as Article).read_time || (a as Article).estimated_read_minutes) && (
<HStack spacing={1}>
<Clock size={12} color="gray" />
<Text fontSize="xs" color="gray.500">
{(a as Article).read_time || (a as Article).estimated_read_minutes} min
</Text>
</HStack>
)}
{(a as Article).view_count && (a as Article).view_count! > 0 && (
<HStack spacing={1}>
<Eye size={12} color="gray" />
<Text fontSize="xs" color="gray.500">
{(a as Article).view_count}
</Text>
</HStack>
)}
</HStack>
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
</VStack>
</HStack>
))}
</VStack>
</GridItem>
</Grid>
</Box>
);
};
export default FeaturedBlog;
@@ -0,0 +1,287 @@
import React, { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {
Box,
Heading,
SimpleGrid,
Image,
Text,
VStack,
HStack,
Button,
Skeleton,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
import { Calendar, Image as ImageIcon, ExternalLink, ArrowRight } from 'lucide-react';
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: Array<{
id: string;
page_url: string;
image_1500: string;
}>;
}
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 GallerySection: React.FC = () => {
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
// Dark mode colors
const cardBg = useColorModeValue('white', 'gray.800');
const headingColor = useColorModeValue('gray.800', 'gray.100');
const textColor = useColorModeValue('gray.600', 'gray.300');
const infoBg = useColorModeValue('blue.50', 'blue.900');
const infoBorder = useColorModeValue('blue.200', 'blue.700');
const infoText = useColorModeValue('blue.700', 'blue.200');
useEffect(() => {
const fetchAlbums = async () => {
setLoading(true);
try {
// Load from both sources and combine
const [profileRes, albumsRes] = await Promise.allSettled([
fetch(resolveBackendUrl('/cache/prefetch/zonerama_profile.json'), { cache: 'no-cache' }),
fetch(resolveBackendUrl('/cache/prefetch/zonerama_albums.json'), { cache: 'no-cache' })
]);
let combinedAlbums: Album[] = [];
// Get profile albums (newest/main source)
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
const profileData = await profileRes.value.json();
combinedAlbums = [...(profileData.albums || [])];
}
// Get blog-related albums (additional source)
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
const albumsData = await albumsRes.value.json();
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
// Filter out albums with empty/invalid data and avoid duplicates
const validBlogAlbums = blogAlbums.filter((album: any) =>
album.id &&
album.title &&
!combinedAlbums.some(existing => existing.id === album.id)
);
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
// Sort by date (newest first)
combinedAlbums.sort((a, b) => {
const parseDate = (dateStr: string) => {
if (!dateStr) return new Date(0);
const parts = dateStr.split(/[.\s]+/).filter(Boolean);
if (parts.length === 3) {
const [day, month, year] = parts;
return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
}
return new Date(dateStr);
};
return parseDate(b.date).getTime() - parseDate(a.date).getTime();
});
// Get the 3 most recent albums
const recentAlbums = combinedAlbums.slice(0, 3);
setAlbums(recentAlbums);
} catch (err) {
console.error('Error loading albums:', err);
} finally {
setLoading(false);
}
};
fetchAlbums();
}, []);
if (loading) {
return (
<Box py={12}>
<VStack spacing={6} align="stretch">
<Heading size="xl">Galerie</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{[1, 2, 3].map((i) => (
<Skeleton key={i} height="300px" borderRadius="lg" />
))}
</SimpleGrid>
</VStack>
</Box>
);
}
if (albums.length === 0) {
return null;
}
return (
<Box py={12}>
<VStack spacing={6} align="stretch">
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl" color={headingColor}>
Fotogalerie
</Heading>
<Text color={textColor} fontSize="sm">
Nejnovější alba z našich akcí
</Text>
</VStack>
<Button
as={RouterLink}
to="/galerie"
rightIcon={<ArrowRight size={18} />}
colorScheme="blue"
variant="outline"
size="md"
>
Zobrazit vše
</Button>
</HStack>
{/* Zonerama Attribution */}
<Box
bg={infoBg}
borderWidth="1px"
borderColor={infoBorder}
borderRadius="md"
px={4}
py={2}
>
<Text fontSize="xs" color={infoText}>
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
{/* Albums Grid */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0
? album.photos[0]
: null;
return (
<Box
key={album.id}
as={RouterLink}
to={`/galerie/album/${album.id}`}
bg={cardBg}
borderRadius="lg"
overflow="hidden"
boxShadow="md"
transition="all 0.3s"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
_hover={{
transform: 'translateY(-8px)',
boxShadow: '2xl',
borderColor: useColorModeValue('gray.300', 'gray.600'),
}}
cursor="pointer"
>
{/* Cover Image */}
{coverPhoto ? (
<Image
src={coverPhoto.image_1500}
alt={album.title}
w="100%"
h="200px"
objectFit="cover"
loading="lazy"
/>
) : (
<Box
w="100%"
h="200px"
bg="gray.200"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={48} color="gray" />
</Box>
)}
{/* Album Info */}
<VStack align="stretch" p={4} spacing={2}>
<Heading size="sm" color={headingColor} noOfLines={2} minH="40px">
{album.title}
</Heading>
<VStack spacing={2} fontSize="xs" color={textColor} align="stretch">
{album.date && (
<HStack spacing={1}>
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
)}
<HStack spacing={1}>
<ImageIcon size={14} />
<Text>{album.photos_count} foto</Text>
</HStack>
</VStack>
{album.views_count !== undefined && album.views_count > 0 && (
<Badge colorScheme="purple" fontSize="2xs" alignSelf="flex-start">
{album.views_count} zhlédnutí
</Badge>
)}
</VStack>
</Box>
);
})}
</SimpleGrid>
{/* Bottom CTA */}
<Box textAlign="center" pt={4}>
<Button
as={RouterLink}
to="/galerie"
rightIcon={<ArrowRight size={18} />}
colorScheme="blue"
size="lg"
>
Zobrazit všechna alba
</Button>
</Box>
</VStack>
</Box>
);
};
export default GallerySection;
@@ -0,0 +1,199 @@
import React from 'react';
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
interface HeaderVariantsProps {
variant: 'unified' | 'edge' | 'minimal' | 'modern';
clubName?: string;
clubLogo?: string;
clubId?: string;
}
const HeaderVariants: React.FC<HeaderVariantsProps> = ({
variant = 'unified',
clubName,
clubLogo,
clubId,
}) => {
const displayLogo = clubId
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
: clubLogo || '/images/club-logo.png';
// Unified variant - classic header
if (variant === 'unified') {
return (
<Box
bg={useColorModeValue('white', 'gray.800')}
borderBottom="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
py={4}
>
<Container maxW="7xl">
<Flex align="center" justify="space-between">
<HStack as={RouterLink} to="/" spacing={4}>
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="48px"
objectFit="contain"
borderRadius="full"
borderWidth="2px"
borderColor="brand.primary"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Box>
<Text fontSize="2xl" fontWeight="bold" color={useColorModeValue('gray.800', 'white')}>
{clubName || 'MyClub'}
</Text>
<Text fontSize="xs" color="gray.500">Official Website</Text>
</Box>
</HStack>
</Flex>
</Container>
</Box>
);
}
// Edge variant - modern with gradient
if (variant === 'edge') {
return (
<Box
bgGradient="linear(to-r, brand.primary, brand.secondary)"
position="relative"
overflow="hidden"
>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.300"
backdropFilter="blur(8px)"
/>
<Container maxW="7xl" position="relative" py={6}>
<Flex align="center" justify="center" direction="column">
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="80px"
objectFit="contain"
mb={3}
filter="drop-shadow(0 4px 6px rgba(0,0,0,0.3))"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '8px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Text fontSize="3xl" fontWeight="bold" color="white" textShadow="0 2px 4px rgba(0,0,0,0.3)">
{clubName || 'Football Club'}
</Text>
<Text fontSize="sm" color="whiteAlpha.900" mt={1}>
Official Website
</Text>
</Flex>
</Container>
</Box>
);
}
// Minimal variant - clean and simple
if (variant === 'minimal') {
return (
<Box bg={useColorModeValue('gray.50', 'gray.900')} py={3}>
<Container maxW="7xl">
<Flex align="center" justify="center">
<HStack as={RouterLink} to="/" spacing={3}>
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="36px"
objectFit="contain"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '3px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Text fontSize="lg" fontWeight="600" color={useColorModeValue('gray.700', 'gray.200')}>
{clubName || 'FC'}
</Text>
</HStack>
</Flex>
</Container>
</Box>
);
}
// Modern variant - bold with accent
if (variant === 'modern') {
return (
<Box
bg={useColorModeValue('white', 'gray.800')}
borderBottom="4px"
borderColor="brand.primary"
boxShadow="sm"
>
<Container maxW="7xl" py={5}>
<Flex align="center" justify="space-between">
<HStack as={RouterLink} to="/" spacing={4}>
{displayLogo && (
<Box
position="relative"
_before={{
content: '""',
position: 'absolute',
top: '-4px',
left: '-4px',
right: '-4px',
bottom: '-4px',
bg: 'brand.primary',
opacity: 0.1,
borderRadius: 'full',
}}
>
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="56px"
objectFit="contain"
borderRadius="full"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '6px' : '0px',
boxSizing: 'border-box'
}}
/>
</Box>
)}
<Box>
<Text
fontSize="2xl"
fontWeight="900"
color="brand.primary"
letterSpacing="tight"
>
{clubName || 'FOOTBALL CLUB'}
</Text>
<Text fontSize="xs" color="gray.500" fontWeight="600" letterSpacing="wider" textTransform="uppercase">
Official Website
</Text>
</Box>
</HStack>
</Flex>
</Container>
</Box>
);
}
return null;
};
export default HeaderVariants;
@@ -0,0 +1,106 @@
import React from 'react';
import { Box, Container, Grid, GridItem, VStack, HStack, Image, Heading, Text, Icon, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import BlogSwiper from './BlogSwiper';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import UpcomingSwitch from './UpcomingSwitch';
import { ChevronRightIcon } from '@chakra-ui/icons';
const RailItem: React.FC<{ a: Article }>=({ a })=>{
return (
<HStack
as={RouterLink}
to={`/articles/${a.id}`}
spacing={3}
align="center"
px={3}
py={2}
borderRadius="md"
_hover={{ bg: 'whiteAlpha.200' }}
transition="background 0.2s"
>
<Box position="relative" flexShrink={0}>
<Image src={a.image_url || '/stadium-placeholder.jpg'} alt={a.title} boxSize="64px" objectFit="cover" borderRadius="md" />
</Box>
<VStack spacing={0} align="start" minW={0}>
<Text fontSize="xs" color="whiteAlpha.700">{new Date(a.created_at || '').toLocaleDateString()}</Text>
<Text fontWeight={600} noOfLines={2} color="white">{a.title}</Text>
</VStack>
<ChevronRightIcon color="whiteAlpha.700" ml="auto" />
</HStack>
);
}
const HeroWithRail: React.FC = () => {
const theme = useClubTheme();
const { data, isLoading } = useQuery({
queryKey: ['hero-rail-articles', { page: 1, page_size: 8, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 8, published: true }),
});
const articles = data?.data || [];
return (
<Box position="relative">
{/* Hero */}
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
<GridItem>
<BlogSwiper />
</GridItem>
{/* Right rail */}
<GridItem display={{ base: 'none', lg: 'block' }}>
<Box
h={{ base: 'auto', lg: '600px' }}
bg="blackAlpha.600"
borderRadius="xl"
overflow="hidden"
backdropFilter="auto"
backdropBlur="8px"
border="1px solid"
borderColor="whiteAlpha.200"
>
<VStack align="stretch" spacing={0} h="100%">
<HStack justify="space-between" px={4} py={3} borderBottom="1px solid" borderColor="whiteAlpha.200">
<Text fontWeight="700" color="white">Novinky</Text>
<Button as={RouterLink} to="/blog" size="sm" variant="ghost" color="whiteAlpha.800" _hover={{ bg: 'whiteAlpha.200' }}>Vše</Button>
</HStack>
<VStack spacing={1} align="stretch" px={2} py={2} overflowY="auto">
{isLoading && Array.from({length:5}).map((_,i)=> (
<Box key={i} h="72px" borderRadius="md" bg="whiteAlpha.200" />
))}
{!isLoading && articles.map((a) => (
<RailItem key={a.id} a={a} />
))}
</VStack>
</VStack>
</Box>
</GridItem>
</Grid>
{/* Glasmorphic upcoming panel */}
<Box
position="absolute"
left="50%"
bottom={{ base: 2, md: 4 }}
transform="translateX(-50%)"
w={{ base: '95%', md: '80%' }}
bg="whiteAlpha.200"
backdropFilter="auto"
backdropBlur="10px"
border="1px solid"
borderColor="whiteAlpha.300"
borderRadius="xl"
px={{ base: 3, md: 6 }}
py={{ base: 3, md: 4 }}
boxShadow="lg"
>
<UpcomingSwitch />
</Box>
</Box>
);
};
export default HeroWithRail;
@@ -0,0 +1,227 @@
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, Heading, Flex, Tooltip } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { useState } from 'react';
import ClubModal from './ClubModal';
import { TeamLogo } from '../common/TeamLogo';
const LeagueTablePro: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-table', clubId, clubType],
queryFn: () => facrApi.getClubTable(clubId!, clubType as any),
enabled: !!clubId,
});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleClubClick = (row: any) => {
// Transform row data to match ClubModal interface
const clubData = {
team: row.team || row.team_name || '-',
team_id: row.team_id || '',
team_logo_url: row.team_logo_url,
rank: row.rank,
played: row.played,
wins: row.wins,
draws: row.draws,
losses: row.losses,
score: row.score,
points: row.points,
};
setSelectedClub(clubData);
setIsModalOpen(true);
};
if (!data) return null;
return (
<Box borderRadius="lg" overflow="hidden" bg="white" boxShadow="sm" borderWidth="1px" borderColor="gray.100">
<Box bg="primary.600" px={4} py={2} borderBottomWidth="1px" borderColor="primary.700">
<Heading size="md" color="white">Tabulka</Heading>
</Box>
<Tabs variant="enclosed" colorScheme="gray" size="sm">
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
}}>
{data.competitions?.map((c) => (
<Tab
key={c.id}
_selected={{
color: 'primary.600',
borderBottom: '2px solid',
borderColor: 'primary.600',
fontWeight: 'bold'
}}
_focus={{ boxShadow: 'none' }}
fontSize="sm"
px={3}
py={3}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<Box maxH="420px" overflowY="auto">
<Table size="sm" variant="simple">
<Thead position="sticky" top={0} zIndex={1} bg="gray.50">
<Tr borderBottomWidth="1px" borderColor="gray.200">
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">#</Th>
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Tým</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Z</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">V</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">R</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">P</Th>
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Skóre</Th>
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Body</Th>
</Tr>
</Thead>
<Tbody>
{c.table?.overall?.map((row, idx) => {
const isHighlighted = row.team_id === clubId;
return (
<Tr
key={`${row.team_id}-${idx}`}
bg={isHighlighted ? 'primary.50' : idx % 2 === 0 ? 'white' : 'gray.50'}
borderLeft={isHighlighted ? '3px solid' : '3px solid transparent'}
borderLeftColor={isHighlighted ? 'primary.500' : 'transparent'}
_hover={{ bg: isHighlighted ? 'primary.100' : 'gray.100', cursor: 'pointer' }}
onClick={() => handleClubClick(row)}
>
<Td px={2} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
<Flex align="center" justify="center">
{row.rank}
{idx < 3 && (
<Box as="span" ml={1} color={idx === 0 ? 'yellow.400' : idx === 1 ? 'gray.400' : 'yellow.700'}>
{idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉'}
</Box>
)}
</Flex>
</Td>
<Td px={3} py={2}>
<Flex align="center" minW="180px">
<TeamLogo
teamId={row.team_id}
teamName={row.team}
facrLogo={row.team_logo_url}
size="small"
alt={row.team}
mr={2}
fallbackIcon={
<Box
w="20px"
h="20px"
bg="gray.200"
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
color="gray.400"
fontSize="xs"
fontWeight="bold"
>
{row.team.substring(0, 2).toUpperCase()}
</Box>
}
/>
<Text
as="span"
fontWeight={isHighlighted ? 'bold' : 'normal'}
color={isHighlighted ? 'primary.700' : 'gray.800'}
isTruncated
>
{row.team}
</Text>
</Flex>
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.played}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.wins}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.draws}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.losses}
</Td>
<Td isNumeric px={1} textAlign="center" fontFamily="mono" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.score}
</Td>
<Td
isNumeric
px={1}
textAlign="center"
fontWeight="bold"
color={isHighlighted ? 'white' : 'gray.800'}
bg={isHighlighted ? 'primary.500' : 'gray.100'}
borderLeftWidth="1px"
borderLeftColor="white"
>
{row.points}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* League Info Footer */}
<Box px={4} py={3} borderTopWidth="1px" borderColor="gray.100" bg="gray.50">
<Flex justify="space-between" fontSize="xs" color="gray.600">
<Box>
<Text as="span" mr={4} display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="green.500" borderRadius="full" mr={1} />
Postup
</Text>
<Text as="span" mr={4} display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="blue.500" borderRadius="full" mr={1} />
Evropské poháry
</Text>
<Text as="span" display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="red.500" borderRadius="full" mr={1} />
Sestup
</Text>
</Box>
<Box>
<Text as="span" display="inline-flex" alignItems="center">
<Box as="span" fontWeight="bold" mr={1}>Aktualizováno:</Box>
{new Date().toLocaleDateString('cs-CZ', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}
</Text>
</Box>
</Flex>
</Box>
</TabPanel>
))}
</TabPanels>
</Tabs>
<ClubModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
club={selectedClub}
clubType={clubType as 'football' | 'futsal'}
/>
</Box>
);
};
export default LeagueTablePro;
+193
View File
@@ -0,0 +1,193 @@
import React, { useMemo } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
HStack,
VStack,
Image,
Text,
Badge,
Link,
Divider,
} from '@chakra-ui/react';
import { useCountdown } from '../../hooks/useCountdown';
import { assetUrl } from '../../utils/url';
export type FacrMatchLike = {
id?: string | number;
date?: string; // yyyy-mm-dd
time?: string; // HH:MM
date_time?: string; // alternative combined format (dd.MM.yyyy HH:mm)
home?: string;
away?: string;
home_logo_url?: string;
away_logo_url?: string;
competition?: string;
competitionName?: string;
venue?: string;
score?: string | null;
facr_link?: string | null;
report_url?: string | null;
};
interface MatchModalProps {
isOpen: boolean;
match: FacrMatchLike | null;
onClose: () => void;
onTeamClick?: (teamName: string, teamLogoUrl?: string) => void;
}
const formatWhen = (m: FacrMatchLike | null) => {
if (!m) return '';
try {
if (m.date && m.time) {
const d = new Date(`${m.date}T${(m.time || '00:00')}:00`);
if (!isNaN(d.getTime())) return d.toLocaleString();
}
if (m.date_time) {
// Try to parse dd.MM.yyyy HH:mm quickly by reordering
const dt = String(m.date_time);
const [dPart, tPart] = dt.split(' ');
const [dd, MM, yyyy] = (dPart || '').split('.');
if (dd && MM && yyyy) {
const iso = `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
const d = new Date(iso);
if (!isNaN(d.getTime())) return d.toLocaleString();
}
return m.date_time;
}
} catch {}
return '';
};
export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose, onTeamClick }) => {
const kickoffIso = useMemo(() => {
if (!match) return null;
if (match.date && match.time) return `${match.date}T${(match.time || '00:00')}:00`;
if (match.date_time) {
const dt = String(match.date_time);
const [dPart, tPart] = dt.split(' ');
const [dd, MM, yyyy] = (dPart || '').split('.');
if (dd && MM && yyyy) return `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
return match.date_time; // fallback
}
return null;
}, [match]);
const { countdownString, isActive, timeRemaining } = useCountdown(kickoffIso, 1000);
const facrLink = match?.facr_link || match?.report_url || null;
const when = formatWhen(match);
// Determine if match has started (countdown finished) but no score yet
const matchStarted = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
const hasScore = match?.score && match.score.trim() !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered size={{ base: 'md', md: 'lg' }}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{match?.home || 'Domácí'} vs {match?.away || 'Hosté'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{match && (
<VStack align="stretch" spacing={4}>
<HStack justify="space-between" align="center">
<VStack
align="center"
spacing={2}
flex={1}
minW={0}
cursor={onTeamClick ? 'pointer' : 'default'}
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
transition="all 0.2s"
role={onTeamClick ? 'button' : undefined}
tabIndex={onTeamClick ? 0 : undefined}
>
<Image src={assetUrl(match.home_logo_url) || '/logo192.png'} alt={match.home || 'Domácí'} boxSize="56px" objectFit="contain" />
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.home || 'Domácí'}</Text>
</VStack>
<VStack spacing={1} minW="120px">
{hasScore ? (
<>
<Text fontSize="2xl" fontWeight="bold">{match.score}</Text>
<Text fontSize="sm" color="gray.600">Skončeno</Text>
</>
) : matchStarted ? (
<>
<Text fontSize="2xl" fontWeight="bold">:</Text>
<Text fontSize="sm" color="green.600">Probíhá</Text>
</>
) : (
<>
<Text fontSize="lg" color="gray.600">Začátek za</Text>
<Text fontSize="2xl" fontWeight="bold">{countdownString || '—'}</Text>
</>
)}
{(match.competition || match.competitionName) && (
<Badge colorScheme="blue" variant="subtle">{match.competition || match.competitionName}</Badge>
)}
</VStack>
<VStack
align="center"
spacing={2}
flex={1}
minW={0}
cursor={onTeamClick ? 'pointer' : 'default'}
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
transition="all 0.2s"
role={onTeamClick ? 'button' : undefined}
tabIndex={onTeamClick ? 0 : undefined}
>
<Image src={assetUrl(match.away_logo_url) || '/logo192.png'} alt={match.away || 'Hosté'} boxSize="56px" objectFit="contain" />
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.away || 'Hosté'}</Text>
</VStack>
</HStack>
<Divider />
<VStack align="stretch" spacing={1} color="gray.700">
{when && <Text><strong>Kdy:</strong> {when}</Text>}
{match.venue && <Text><strong>Kde:</strong> {match.venue}</Text>}
</VStack>
</VStack>
)}
</ModalBody>
<ModalFooter>
{facrLink && (
<Button
colorScheme="blue"
mr={3}
onClick={(e) => {
e.preventDefault();
// Open in background tab without switching focus
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>
);
};
export default MatchModal;
@@ -0,0 +1,118 @@
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Link, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { TeamLogo } from '../common/TeamLogo';
import '../../styles/logos.css';
const MatchRow: React.FC<{
date: string;
home: { name: string; logo?: string; id?: string };
away: { name: string; logo?: string; id?: string };
score?: string;
clubName?: string;
}> = ({ date, home, away, score, clubName }) => (
<HStack justify="space-between" borderWidth="1px" borderRadius="md" p={3} bg="white">
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
<HStack flex={1} justify="flex-end">
<HStack minW="40%" justify="flex-end" spacing={2}>
<Text noOfLines={1} textAlign="right" flex={1}>{home.name}</Text>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={home.id}
teamName={home.name}
facrLogo={home.logo}
size="custom"
boxSize="28px"
/>
</Box>
</HStack>
<HStack w="auto" minW="60px" justify="center" spacing={2}>
<Text fontWeight="bold" textAlign="center">{score || '-:-'}</Text>
{(() => {
const sent = (() => {
if (!score || !clubName) return null;
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
if (!m) return null;
const h = parseInt(m[1], 10), a = parseInt(m[2], 10);
const norm = (s: string) => String(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g,' ').trim().toLowerCase();
const strip = (s: string) => norm(s).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
const ourIsHome = (() => { const aName = strip(home.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
const ourIsAway = (() => { const aName = strip(away.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
if (!ourIsHome && !ourIsAway) return null;
if (h === a) return { label: 'Remíza', color: 'blue' } as const;
const our = ourIsHome ? h : a; const opp = ourIsHome ? a : h;
return our > opp ? ({ label: 'Výhra', color: 'green' } as const) : ({ label: 'Prohra', color: 'red' } as const);
})();
return sent ? <Badge colorScheme={sent.color} variant="subtle">{sent.label}</Badge> : null;
})()}
</HStack>
<HStack minW="40%" spacing={2}>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={away.id}
teamName={away.name}
facrLogo={away.logo}
size="custom"
boxSize="28px"
/>
</Box>
<Text noOfLines={1} flex={1}>{away.name}</Text>
</HStack>
</HStack>
</HStack>
);
const MatchesSection: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
const { data, isLoading, isError } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId, clubType),
enabled: Boolean(clubId),
});
return (
<Box>
<Heading size="lg" mb={4}>Zápasy</Heading>
{!clubId && (
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení zápasů z FAČR.</Text>
)}
{isLoading && <Skeleton height="200px" />}
{!isLoading && data && (
<Tabs variant="enclosed-colored" isFitted>
<TabList>
{data.competitions?.map((c) => (
<Tab key={c.id}>{c.name}</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<VStack align="stretch" spacing={3}>
{(c.matches || []).slice(0, 5).map((m, idx) => (
<MatchRow
key={m.match_id || idx}
date={m.date_time}
home={{ name: m.home, logo: m.home_logo_url, id: m.home_id }}
away={{ name: m.away, logo: m.away_logo_url, id: m.away_id }}
score={m.score}
clubName={data.name}
/>
))}
{(c.matches || []).length === 0 && (
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
)}
</VStack>
</TabPanel>
))}
</TabPanels>
</Tabs>
)}
</Box>
);
};
export default MatchesSection;
@@ -0,0 +1,77 @@
import { Box, SimpleGrid, Heading, Text, useColorModeValue, HStack, Button, Link, Badge } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { getClothing, ClothingItem } from '../../services/clothing';
import { Link as RouterLink } from 'react-router-dom';
const MerchSection: React.FC = () => {
const [items, setItems] = useState<ClothingItem[]>([]);
const [loading, setLoading] = useState(true);
const cardBg = useColorModeValue('white', 'gray.800');
useEffect(() => {
const fetchItems = async () => {
try {
const data = await getClothing();
// Show only 5 newest items on homepage
setItems(data.slice(0, 5));
} catch (e) {
console.error('Failed to fetch clothing items:', e);
} finally {
setLoading(false);
}
};
fetchItems();
}, []);
if (loading || items.length === 0) return null;
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading as="h3" size="md">Oblečení týmu</Heading>
<Link as={RouterLink} to="/obleceni">
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
</Link>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 5 }} spacing={4}>
{items.map((it) => (
<a
key={it.id}
href={it.url || '/obleceni'}
target={it.url ? "_blank" : undefined}
rel={it.url ? "noreferrer noopener" : undefined}
>
<Box
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="1px"
transition="all 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
>
<Box
aria-hidden
height={{ base: 140, md: 180 }}
bgSize="cover"
bgPos="center"
style={{ backgroundImage: `url(${it.image_url})` }}
/>
<Box p={3} borderTopWidth="1px">
<Text noOfLines={1} fontWeight="semibold" fontSize="sm">{it.title}</Text>
{it.price && it.price > 0 && (
<Badge colorScheme="blue" mt={1} fontSize="xs">
{it.price} {it.currency || 'Kč'}
</Badge>
)}
</Box>
</Box>
</a>
))}
</SimpleGrid>
</Box>
);
};
export default MerchSection;
@@ -0,0 +1,168 @@
import { Box, Grid, GridItem, Heading, Image, Button, HStack, Text, VStack, Badge } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Calendar, Image as ImageIcon } from 'lucide-react';
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: Array<{
id: string;
page_url: string;
image_1500: string;
}>;
}
// Resolve backend-relative URLs against API origin
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 PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
const [albums, setAlbums] = useState<Album[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let active = true;
(async () => {
try {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${apiUrl}/gallery/albums`);
if (response.ok) {
const data = await response.json();
if (active) {
// Get 5 most recent albums
setAlbums((data.albums || []).slice(0, 5));
}
}
} catch {
if (active) setAlbums([]);
} finally {
if (active) setLoaded(true);
}
})();
return () => { active = false };
}, []);
const showSetupHint = loaded && albums.length === 0 && !zoneramaUrl;
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="lg">Fotogalerie</Heading>
<Button as={RouterLink} to="/galerie" size="sm" variant="outline">
Zobrazit vše
</Button>
</HStack>
{showSetupHint && (
<Box bg="yellow.50" borderWidth="1px" borderColor="yellow.200" color="yellow.800" p={3} borderRadius="md" mb={3}>
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě Fotogalerie) a my ji budeme automaticky načítat.</Text>
</Box>
)}
{/* Zonerama Attribution */}
{albums.length > 0 && (
<Box bg="blue.50" borderWidth="1px" borderColor="blue.200" color="blue.800" p={2} borderRadius="md" mb={3} fontSize="xs">
<Text>
📸 Fotografie z{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
)}
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={4}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0 ? album.photos[0] : null;
return (
<GridItem key={album.id}>
<Box
as={RouterLink}
to={`/galerie/album/${album.id}`}
bg="white"
borderRadius="md"
overflow="hidden"
boxShadow="sm"
transition="all 0.2s"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'md',
}}
cursor="pointer"
display="block"
>
{/* Cover Image */}
{coverPhoto ? (
<Image
src={resolveBackendUrl(coverPhoto.image_1500)}
alt={album.title}
h="180px"
w="100%"
objectFit="cover"
/>
) : (
<Box
h="180px"
w="100%"
bg="gray.200"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={32} color="gray" />
</Box>
)}
{/* Album Info */}
<VStack align="stretch" p={3} spacing={2}>
<Heading size="sm" noOfLines={2} color="gray.800">
{album.title}
</Heading>
<HStack spacing={3} fontSize="xs" color="gray.600">
{album.date && (
<HStack spacing={1}>
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
)}
<HStack spacing={1}>
<ImageIcon size={14} />
<Text>{album.photos_count} foto</Text>
</HStack>
</HStack>
</VStack>
</Box>
</GridItem>
);
})}
</Grid>
</Box>
);
};
export default PhotosSection;
@@ -0,0 +1,87 @@
import React from 'react';
import {
Box,
VStack,
Heading,
Text,
Spinner,
Alert,
AlertIcon,
useColorModeValue,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPolls, getPoll } from '../../services/polls';
import PollCard from '../polls/PollCard';
interface PollsWidgetProps {
featuredOnly?: boolean;
maxPolls?: number;
title?: string;
}
const PollsWidget: React.FC<PollsWidgetProps> = ({
featuredOnly = true,
maxPolls = 1,
title = 'Hlasování',
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
// Fetch polls list
const { data: polls, isLoading } = useQuery({
queryKey: ['polls', { featured: featuredOnly }],
queryFn: () => getPolls(featuredOnly ? { featured: true } : undefined),
staleTime: 2 * 60 * 1000, // 2 minutes
});
// Get full poll data for each featured poll
const pollsToDisplay = polls?.slice(0, maxPolls) || [];
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
queryKey: ['polls-details', pollsToDisplay.map((p) => p.id)],
queryFn: async () => {
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
return await Promise.all(promises);
},
enabled: pollsToDisplay.length > 0,
});
if (isLoading || isLoadingPolls) {
return (
<Box bg={bgSection} py={12} px={4}>
<VStack spacing={4}>
<Spinner size="lg" />
<Text>Načítání ankety...</Text>
</VStack>
</Box>
);
}
if (!pollsData || pollsData.length === 0) {
return null; // Don't show widget if no polls
}
return (
<Box bg={bgSection} py={12} px={4}>
<VStack spacing={8} maxW="4xl" mx="auto">
<Heading size="lg" textAlign="center">
{title}
</Heading>
<VStack spacing={6} w="full">
{pollsData.map((pollResponse) => (
<Box key={pollResponse.poll.id} w="full" maxW="600px">
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))}
</VStack>
</VStack>
</Box>
);
};
export default PollsWidget;
@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import { usePublicSettings } from '../../hooks/usePublicSettings';
// Normalizes various social URL formats to a proper https URL
const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => {
let v = String(raw || '').trim();
if (!v) return null;
// Replace whitespace
v = v.replace(/\s+/g, '');
// Accept handle like @club
if (v.startsWith('@')) {
const handle = v.slice(1);
if (network === 'facebook') return `https://www.facebook.com/${handle}`;
if (network === 'instagram') return `https://www.instagram.com/${handle}`;
if (network === 'youtube') return `https://www.youtube.com/@${handle}`;
}
// If only a username without slashes
if (!/^https?:\/\//i.test(v) && !v.includes('/') && !v.includes('.')) {
if (network === 'facebook') return `https://www.facebook.com/${v}`;
if (network === 'instagram') return `https://www.instagram.com/${v}`;
if (network === 'youtube') return `https://www.youtube.com/@${v}`;
}
// If looks like domain without scheme
if (!/^https?:\/\//i.test(v)) {
if (/^facebook\.com\//i.test(v)) return `https://www.${v}`;
if (/^instagram\.com\//i.test(v)) return `https://www.${v}`;
if (/^youtube\.com\//i.test(v)) return `https://www.${v}`;
}
return v;
};
const SocialEmbeds: React.FC<{ variant?: 'unified' | 'magazine' | 'pro' | 'edge' }>
= ({ variant = 'unified' }) => {
const { data: settings } = usePublicSettings();
const facebookHref = useMemo(() => {
const raw = (settings as any)?.facebook_url
|| (settings as any)?.facebook
|| (settings as any)?.facebookPage
|| (settings as any)?.facebook_page
|| (settings as any)?.facebookPageUrl
|| (settings as any)?.facebook_page_url;
return normalizeSocialUrl('facebook', raw);
}, [settings]);
const instagramHref = useMemo(() => {
const raw = (settings as any)?.instagram_url
|| (settings as any)?.instagram
|| (settings as any)?.instagramProfile
|| (settings as any)?.instagram_profile
|| (settings as any)?.ig
|| (settings as any)?.ig_url;
return normalizeSocialUrl('instagram', raw);
}, [settings]);
const youtubeHref = useMemo(() => {
const raw = (settings as any)?.youtube_url
|| (settings as any)?.youtube
|| (settings as any)?.yt
|| (settings as any)?.youtube_channel;
return normalizeSocialUrl('youtube', raw);
}, [settings]);
if (!instagramHref && !youtubeHref) return null;
const outerStyle: React.CSSProperties = {
margin: variant === 'pro' ? '16px 0' : '8px 0',
};
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr',
gap: 12,
};
const colStyle: React.CSSProperties = {
background: '#fff',
borderRadius: 10,
border: '1px solid var(--light-gray)',
padding: 8,
};
// Instagram profile embed is unofficial; try /embed fallback, else show CTA tile
const instagramEmbedSrc = instagramHref ? `${instagramHref.replace(/\/$/, '')}/embed` : null;
return (
<div className={`social-embeds ${variant}`} style={outerStyle}>
<div className="section-head" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>Sledujte nás</h3>
<div className="links" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{facebookHref && (<a className="btn" href={facebookHref} target="_blank" rel="noreferrer noopener">Facebook</a>)}
{instagramHref && (<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Instagram</a>)}
{youtubeHref && (<a className="btn" href={youtubeHref} target="_blank" rel="noreferrer noopener">YouTube</a>)}
</div>
</div>
<div className="grid" style={gridStyle}>
{instagramHref && (
<div className="col" style={colStyle}>
{instagramEmbedSrc ? (
<iframe
title="Instagram"
src={instagramEmbedSrc}
width="100%"
height={360}
style={{ border: 0, width: '100%' }}
frameBorder={0}
scrolling="no"
/>
) : (
<div style={{ padding: 16 }}>
<p>Sledujte nás na Instagramu.</p>
<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Otevřít Instagram</a>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default SocialEmbeds;
@@ -0,0 +1,266 @@
import React, { useEffect, useState } from 'react';
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Skeleton, Text, Badge, HStack, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import ClubModal from './ClubModal';
import { TeamLogo } from '../common/TeamLogo';
const TableSection: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
// movement map: compKey -> teamKey -> delta (prevRank - currentRank)
const [movementMap, setMovementMap] = useState<Record<string, Record<string, number>>>({});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleClubClick = (row: any) => {
// Transform row data to match ClubModal interface
const clubData = {
team: row.team || row.team_name || '-',
team_id: row.team_id || '',
team_logo_url: row.team_logo_url,
rank: row.rank,
played: row.played,
wins: row.wins,
draws: row.draws,
losses: row.losses,
score: row.score,
points: row.points,
};
setSelectedClub(clubData);
setIsModalOpen(true);
};
// Theme-aware movement colors (softer in dark mode)
const upColor = useColorModeValue('green.400', 'green.300');
const downColor = useColorModeValue('red.400', 'red.300');
const sameColor = useColorModeValue('gray.300', 'gray.600');
// Badge/background colors to avoid white-on-white
const badgeBg = useColorModeValue('gray.100', 'gray.700');
const badgeText = useColorModeValue('gray.800', 'whiteAlpha.900');
const rankTopBg = useColorModeValue('green.100', 'green.600');
const rankTopText = useColorModeValue('green.800', 'white');
const pointsBg = useColorModeValue('blue.600', 'blue.400');
const pointsText = 'white';
const { data, isLoading, isError, error } = useQuery({
queryKey: ['facr-table', clubId, clubType],
queryFn: () => facrApi.getClubTable(clubId, clubType),
enabled: Boolean(clubId),
staleTime: 1000 * 60 * 3, // 3 minutes
retry: 2,
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000),
});
// After data loads, compare with previous snapshot stored in localStorage to compute movement
useEffect(() => {
try {
if (!data?.competitions?.length) return;
const storageKey = `facr_table_prev_${clubId || 'unknown'}_${clubType || 'football'}`;
const prevRaw = localStorage.getItem(storageKey);
const prev = prevRaw ? JSON.parse(prevRaw) : null;
const map: Record<string, Record<string, number>> = {};
data.competitions.forEach((c: any) => {
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
const prevComp = prev?.competitions?.find((pc: any) => String(pc.id ?? pc.code ?? pc.name) === compKey);
const prevRanks: Record<string, number> = {};
(prevComp?.table?.overall || []).forEach((r: any, i: number) => {
const teamKey = String(r.team_id ?? r.team ?? r.team_name ?? i).toLowerCase();
const rank = Number(r.rank ?? (i + 1));
prevRanks[teamKey] = rank;
});
const compMov: Record<string, number> = {};
(c.table?.overall || []).forEach((r: any, i: number) => {
const teamKeyRaw = String(r.team_id ?? r.team ?? r.team_name ?? i);
const teamKey = teamKeyRaw.toLowerCase();
const currentRank = Number(r.rank ?? (i + 1));
const prevRank = prevRanks[teamKey];
if (typeof prevRank === 'number') {
compMov[teamKeyRaw] = prevRank - currentRank; // positive => moved up
}
});
map[compKey] = compMov;
});
setMovementMap(map);
// Save current snapshot for next comparison (trim to essentials)
const snapshot = {
competitions: (data.competitions || []).map((c: any) => ({
id: c.id,
code: c.code,
name: c.name,
table: { overall: (c.table?.overall || []).map((r: any, i: number) => ({
team_id: r.team_id,
team: r.team,
team_name: r.team_name,
rank: Number(r.rank ?? (i + 1)),
})) },
})),
};
localStorage.setItem(storageKey, JSON.stringify(snapshot));
} catch {}
}, [data, clubId, clubType]);
return (
<Box>
<Heading size="lg" mb={4}>Tabulka soutěží</Heading>
{!clubId && (
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení tabulek z FAČR.</Text>
)}
{isLoading && <Skeleton height="200px" />}
{isError && (
<Text color="red.500" mb={4}>
Nepodařilo se načíst tabulky z FAČR. Zkuste to prosím znovu později.
{process.env.NODE_ENV !== 'production' && error instanceof Error ? ` (${error.message})` : ''}
</Text>
)}
{/* Legend for movement */}
{!isLoading && !isError && (
<HStack spacing={4} mb={2} color="gray.600" fontSize="sm">
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={upColor} />
<Text>Lepší pozice</Text>
</HStack>
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={sameColor} />
<Text>Beze změny</Text>
</HStack>
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={downColor} />
<Text>Horší pozice</Text>
</HStack>
</HStack>
)}
{!isLoading && !isError && data && data.competitions?.length > 0 && (
<Tabs variant="enclosed" colorScheme="blue" isFitted>
<TabList bg={useColorModeValue('white', 'gray.800')} borderRadius="md" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
{data.competitions?.map((c) => (
<Tab
key={c.id}
_selected={{ bg: useColorModeValue('blue.50', 'blue.900'), color: useColorModeValue('blue.700', 'blue.200'), borderColor: useColorModeValue('blue.200', 'blue.600') }}
color={useColorModeValue('gray.800', 'gray.200')}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<Box maxH="420px" overflowY="auto" borderWidth="1px" borderRadius="md" bg={useColorModeValue('white', 'gray.800')} color={useColorModeValue('gray.800', 'gray.100')} borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Table size="sm" variant="striped" colorScheme="gray">
<Thead position="sticky" top={0} zIndex={1} bg="brand.primary">
<Tr>
<Th color="text.onPrimary">#</Th>
<Th color="text.onPrimary">Tým</Th>
<Th isNumeric color="text.onPrimary">Z</Th>
<Th isNumeric color="text.onPrimary">V</Th>
<Th isNumeric color="text.onPrimary">R</Th>
<Th isNumeric color="text.onPrimary">P</Th>
<Th isNumeric color="text.onPrimary">Skóre</Th>
<Th isNumeric color="text.onPrimary">Body</Th>
</Tr>
</Thead>
<Tbody>
{c.table?.overall?.map((row, idx) => {
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
const teamKeyRaw = String((row as any).team_id ?? (row as any).team ?? (row as any).team_name ?? idx);
const deltaStored = movementMap?.[compKey]?.[teamKeyRaw];
const movement: 'up' | 'same' | 'down' = typeof deltaStored === 'number' ? (deltaStored > 0 ? 'up' : (deltaStored < 0 ? 'down' : 'same')) : 'same';
const deltaVal = typeof deltaStored === 'number' ? deltaStored : 0;
const borderCol = movement === 'up' ? upColor : movement === 'down' ? downColor : sameColor;
const ourClubId = settings?.club_id;
const ourClubName = (settings?.club_name || '').toLowerCase();
const isOurClub = (ourClubId && row.team_id === ourClubId) || (!!ourClubName && String(row.team || '').toLowerCase() === ourClubName);
return (
<Tr
key={`${row.team_id}-${idx}`}
_hover={{ bg: useColorModeValue('gray.50', 'gray.700'), cursor: 'pointer' }}
bg={idx % 2 === 0 ? useColorModeValue('white', 'gray.800') : useColorModeValue('gray.50', 'gray.750')}
sx={{ borderLeftWidth: '4px', borderLeftStyle: 'solid', borderLeftColor: borderCol }}
onClick={() => handleClubClick(row)}
>
<Td>
<Badge
variant="subtle"
bg={idx <= 2 ? rankTopBg : badgeBg}
color={idx <= 2 ? rankTopText : badgeText}
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
>
{row.rank}
</Badge>
</Td>
<Td>
<HStack spacing={2} align="center">
<TeamLogo
teamId={row.team_id}
teamName={row.team}
facrLogo={row.team_logo_url}
size="small"
alt={row.team}
borderRadius="full"
bg="white"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
/>
<Text as="span" color={isOurClub ? 'brand.primary' : useColorModeValue('gray.800', 'gray.100')} fontWeight={isOurClub ? 'bold' : 'normal'}>
{row.team}
</Text>
<Text as="span" fontSize="xs" color={movement === 'up' ? 'green.500' : movement === 'down' ? 'red.500' : 'gray.500'}>
{movement === 'up' ? '▲' : movement === 'down' ? '▼' : '•'}
</Text>
</HStack>
{deltaVal !== 0 && (
<Badge
ml={2}
variant="subtle"
bg={movement === 'up' ? 'green.100' : movement === 'down' ? 'red.100' : badgeBg}
color={movement === 'up' ? 'green.700' : movement === 'down' ? 'red.700' : badgeText}
borderWidth="1px"
borderColor={useColorModeValue('green.200', movement === 'down' ? 'red.300' : 'whiteAlpha.300')}
>
{movement === 'up' ? `+${deltaVal}` : `${deltaVal}`}
</Badge>
)}
</Td>
<Td isNumeric>{row.played}</Td>
<Td isNumeric>{row.wins}</Td>
<Td isNumeric>{row.draws}</Td>
<Td isNumeric>{row.losses}</Td>
<Td isNumeric>{row.score}</Td>
<Td isNumeric>
<Badge variant="solid" bg={pointsBg} color={pointsText}>{row.points}</Badge>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
</TabPanel>
))}
</TabPanels>
</Tabs>
)}
{!isLoading && !isError && data && (!data.competitions || data.competitions.length === 0) && (
<Text color="gray.500">Pro tento klub nejsou dostupné tabulky.</Text>
)}
<ClubModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
club={selectedClub}
clubType={clubType as 'football' | 'futsal'}
/>
</Box>
);
};
export default TableSection;
@@ -0,0 +1,26 @@
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers, Player } from '../../services/players';
const TeamScroller: React.FC = () => {
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
const players = (data || []).filter(p => p.is_active);
if (!players.length) return null;
return (
<Box>
<Heading size="lg" mb={4} textAlign="center">Náš tým</Heading>
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
{players.map((p: Player) => (
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Image src={p.image_url || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" />
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
</VStack>
))}
</HStack>
</Box>
);
};
export default TeamScroller;
@@ -0,0 +1,72 @@
import React from 'react';
import ContactMap from './ContactMap';
import VectorMap from './VectorMap';
interface UnifiedMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: string;
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
useVectorMaps?: boolean;
}
/**
* Unified Map Component
*
* Automatically chooses between raster (Leaflet) and vector (MapLibre GL) maps
* based on the useVectorMaps prop or environment configuration.
*
* Usage:
* <UnifiedMap
* latitude={50.0755}
* longitude={14.4378}
* useVectorMaps={true} // or from settings
* />
*/
const UnifiedMap: React.FC<UnifiedMapProps> = ({
useVectorMaps = false,
...props
}) => {
// Map style conversion: raster styles to vector equivalents
const getVectorStyle = (rasterStyle?: string): 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic' => {
const styleMap: Record<string, any> = {
'default': 'osm-bright',
'positron': 'positron',
'positron-no-labels': 'positron',
'dark': 'dark-matter',
'dark-no-labels': 'dark-matter',
'dark-matter': 'dark-matter',
'toner': 'klokantech-basic',
'toner-lite': 'klokantech-basic',
'voyager': 'osm-bright',
'osm-bright': 'osm-bright',
'klokantech-basic': 'klokantech-basic',
};
return styleMap[rasterStyle || 'default'] || 'positron';
};
if (useVectorMaps) {
// Use vector maps (MapLibre GL JS)
return (
<VectorMap
{...props}
mapStyle={getVectorStyle(props.mapStyle)}
/>
);
} else {
// Use raster maps (Leaflet)
return (
<ContactMap
{...props}
/>
);
}
};
export default UnifiedMap;
@@ -0,0 +1,70 @@
import { Box, Flex, Heading, Text, HStack, Image, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { facrApi } from '../../services/facr/facrApi';
import { useClubTheme } from '../../contexts/ClubThemeContext';
function formatCountdown(dt: string) {
const target = new Date(dt).getTime();
const diff = target - Date.now();
if (isNaN(target) || diff <= 0) return '';
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
const m = Math.floor((diff / (1000 * 60)) % 60);
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
}
const UpcomingBanner: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType as any),
enabled: !!clubId,
});
const allMatches = (data?.competitions || []).flatMap(c => c.matches || []);
const upcoming = allMatches
.map(m => ({ m, t: new Date(m.date_time).getTime() }))
.filter(x => !isNaN(x.t) && x.t > Date.now())
.sort((a, b) => a.t - b.t)[0]?.m;
if (!upcoming) return null;
return (
<Box bg={theme.primary} color="white" borderRadius="xl" p={{ base: 4, md: 6 }} shadow="md">
<Text fontSize="sm" opacity={0.9} fontWeight="600">Nadcházející zápas</Text>
<Flex align="center" justify="space-between" gap={4} mt={2} direction={{ base: 'column', md: 'row' }}>
<HStack spacing={4} flex={1} justify="center">
<HStack>
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
<Text fontWeight="600">{upcoming.home}</Text>
</HStack>
<Heading size="md">vs</Heading>
<HStack>
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
<Text fontWeight="600">{upcoming.away}</Text>
</HStack>
</HStack>
<HStack spacing={6}>
<Box textAlign="center">
<Text fontSize="xs" opacity={0.8}>KICKOFF</Text>
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
</Box>
<Box textAlign="center">
<Text fontSize="xs" opacity={0.8}>ZAČÍNÁ ZA</Text>
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
</Box>
</HStack>
{upcoming.report_url && (
<Button as="a" href={upcoming.report_url} target="_blank" colorScheme="red" variant="solid">Detail</Button>
)}
</Flex>
</Box>
);
};
export default UpcomingBanner;
@@ -0,0 +1,105 @@
import React from 'react';
import { Box, HStack, VStack, Text, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Image, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { facrApi } from '../../services/facr/facrApi';
import { useClubTheme } from '../../contexts/ClubThemeContext';
function formatCountdown(dt?: string) {
if (!dt) return '';
const target = new Date(dt).getTime();
const diff = target - Date.now();
if (isNaN(target) || diff <= 0) return '';
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
const m = Math.floor((diff / (1000 * 60)) % 60);
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
}
const UpcomingSwitch: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = (settings?.club_type || 'football') as any;
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType),
enabled: !!clubId,
});
const comps = data?.competitions || [];
if (!comps.length) return null;
return (
<Tabs variant="unstyled" colorScheme="whiteAlpha">
<TabList gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
{comps.map((c) => (
<Tab
key={c.id}
px={3}
py={2}
borderRadius="full"
bg="whiteAlpha.200"
color="white"
_selected={{ bg: 'white', color: 'black' }}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{comps.map((c) => {
const upcoming = (c.matches || [])
.map((m) => ({ m, t: new Date(m.date_time).getTime() }))
.filter((x) => !isNaN(x.t) && x.t > Date.now())
.sort((a, b) => a.t - b.t)[0]?.m;
if (!upcoming) {
return (
<TabPanel key={c.id} px={0}>
<Box py={6} textAlign="center" color="whiteAlpha.800">Žádný nadcházející zápas.</Box>
</TabPanel>
);
}
return (
<TabPanel key={c.id} px={0}>
<HStack spacing={6} align="center" justify="space-between" flexWrap={{ base: 'wrap', md: 'nowrap' }}>
<HStack spacing={4} flex={1} minW={0} justify="center">
<HStack minW={0}>
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.home}</Text>
</HStack>
<Heading size="sm" color="white">vs</Heading>
<HStack minW={0}>
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.away}</Text>
</HStack>
</HStack>
<HStack spacing={6}>
<Box textAlign="center" color="white">
<Text fontSize="xs" opacity={0.85}>KICKOFF</Text>
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
</Box>
<Box textAlign="center" color="white">
<Text fontSize="xs" opacity={0.85}>ZAČÍNÁ ZA</Text>
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
</Box>
</HStack>
{upcoming.report_url && (
<Button as="a" href={upcoming.report_url} target="_blank" bg={theme.primary} color="white" _hover={{ bg: theme.accent }}>
Detail zápasu
</Button>
)}
</HStack>
</TabPanel>
);
})}
</TabPanels>
</Tabs>
);
};
export default UpcomingSwitch;
+323
View File
@@ -0,0 +1,323 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box } from '@chakra-ui/react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
interface VectorMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic';
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
customStyleUrl?: string;
}
// OpenMapTiles free demo server (for development/testing)
// For production, use your own tile server or a commercial provider
const MAPTILER_API_KEY = process.env.REACT_APP_MAPTILER_KEY || 'get_your_own_OpIi9ZULNHzrESv6T2vL';
// Vector tile style definitions
export const VECTOR_STYLES = {
'positron': {
name: 'Positron (Light)',
description: 'Clean light style, perfect for data visualization',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/positron/style.json?key=${apiKey}`,
},
'dark-matter': {
name: 'Dark Matter',
description: 'Sleek dark theme for modern interfaces',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/darkmatter/style.json?key=${apiKey}`,
},
'osm-bright': {
name: 'OSM Bright',
description: 'Colorful OpenStreetMap style',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/bright/style.json?key=${apiKey}`,
},
'klokantech-basic': {
name: 'Basic',
description: 'Simple and clean base map',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/basic/style.json?key=${apiKey}`,
},
};
// Custom Positron-like style with club colors (self-hosted tiles not required)
const createCustomPositronStyle = (primaryColor?: string, secondaryColor?: string): any => {
const mainColor = primaryColor || '#e11d48';
const accentColor = secondaryColor || '#3b82f6';
return {
version: 8,
name: 'Custom Positron',
sources: {
'openmaptiles': {
type: 'vector',
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${MAPTILER_API_KEY}`,
},
},
glyphs: 'https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key=' + MAPTILER_API_KEY,
layers: [
// Background
{
id: 'background',
type: 'background',
paint: { 'background-color': '#f8f8f8' },
},
// Water
{
id: 'water',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'water',
paint: { 'fill-color': '#e3e8ed' },
},
// Parks
{
id: 'park',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'park',
paint: { 'fill-color': '#e8f5e8' },
},
// Buildings
{
id: 'building',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'building',
paint: {
'fill-color': '#ececec',
'fill-opacity': 0.6,
},
},
// Roads - major
{
id: 'road-major',
type: 'line',
source: 'openmaptiles',
'source-layer': 'transportation',
filter: ['in', 'class', 'motorway', 'trunk', 'primary'],
paint: {
'line-color': '#ffffff',
'line-width': {
base: 1.4,
stops: [[6, 0.5], [20, 30]],
},
},
},
// Roads - minor
{
id: 'road-minor',
type: 'line',
source: 'openmaptiles',
'source-layer': 'transportation',
filter: ['in', 'class', 'secondary', 'tertiary', 'minor'],
paint: {
'line-color': '#ffffff',
'line-width': {
base: 1.4,
stops: [[6, 0.25], [20, 20]],
},
},
},
// Place labels
{
id: 'place-label',
type: 'symbol',
source: 'openmaptiles',
'source-layer': 'place',
layout: {
'text-field': '{name}',
'text-font': ['Noto Sans Regular'],
'text-size': {
base: 1.2,
stops: [[7, 11], [15, 14]],
},
},
paint: {
'text-color': '#666666',
'text-halo-color': '#ffffff',
'text-halo-width': 1.5,
},
},
],
};
};
const VectorMap: React.FC<VectorMapProps> = ({
latitude,
longitude,
zoom = 15,
address,
clubName,
mapStyle = 'positron',
height = 400,
clubPrimaryColor,
clubSecondaryColor,
customStyleUrl,
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const marker = useRef<maplibregl.Marker | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!mapContainer.current || map.current) return;
try {
// Determine style URL
let styleUrl: string | any;
if (customStyleUrl) {
styleUrl = customStyleUrl;
} else if (mapStyle === 'positron' && clubPrimaryColor) {
// Use custom style with club colors
styleUrl = createCustomPositronStyle(clubPrimaryColor, clubSecondaryColor);
} else {
// Use predefined style
styleUrl = VECTOR_STYLES[mapStyle]?.getStyleUrl(MAPTILER_API_KEY) ||
VECTOR_STYLES.positron.getStyleUrl(MAPTILER_API_KEY);
}
// Initialize map
map.current = new maplibregl.Map({
container: mapContainer.current,
style: styleUrl,
center: [longitude, latitude],
zoom: zoom,
});
// Add navigation controls
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
// Create custom marker with club color
const markerColor = clubPrimaryColor || '#e11d48';
// Create marker element
const el = document.createElement('div');
el.style.width = '36px';
el.style.height = '54px';
el.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
<defs>
<filter id="marker-shadow-${Date.now()}" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<path fill="${markerColor}" stroke="#fff" stroke-width="1.5"
filter="url(#marker-shadow-${Date.now()})"
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
<circle cx="12" cy="9" r="3" fill="#fff"/>
</svg>
`;
// Add marker to map
marker.current = new maplibregl.Marker({ element: el })
.setLngLat([longitude, latitude])
.addTo(map.current);
// Add popup if there's content
if (clubName || address) {
let popupContent = '';
if (clubName) popupContent += `<strong>${clubName}</strong><br>`;
if (address) popupContent += address;
const popup = new maplibregl.Popup({ offset: 25 })
.setHTML(popupContent);
marker.current.setPopup(popup);
}
// Handle map load event for additional customization
map.current.on('load', () => {
if (!map.current) return;
// Apply club color tint to water features if primary color is set
if (clubPrimaryColor && map.current.getLayer('water')) {
map.current.setPaintProperty('water', 'fill-color',
adjustColorBrightness(clubPrimaryColor, 0.9));
}
});
} catch (err: any) {
console.error('Error initializing map:', err);
setError(err?.message || 'Failed to load map');
}
// Cleanup
return () => {
if (marker.current) {
marker.current.remove();
marker.current = null;
}
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, [latitude, longitude, zoom, mapStyle, clubPrimaryColor, clubSecondaryColor, customStyleUrl]);
// Update marker and center when coordinates change
useEffect(() => {
if (!map.current || !marker.current) return;
const newCenter: [number, number] = [longitude, latitude];
marker.current.setLngLat(newCenter);
map.current.setCenter(newCenter);
}, [latitude, longitude]);
// Helper function to adjust color brightness
function adjustColorBrightness(color: string, factor: number): string {
try {
// Simple RGB adjustment
const hex = color.replace('#', '');
const r = Math.min(255, Math.floor(parseInt(hex.substring(0, 2), 16) * factor));
const g = Math.min(255, Math.floor(parseInt(hex.substring(2, 4), 16) * factor));
const b = Math.min(255, Math.floor(parseInt(hex.substring(4, 6), 16) * factor));
return `rgb(${r}, ${g}, ${b})`;
} catch {
return color;
}
}
if (error) {
return (
<Box
w="100%"
h={`${height}px`}
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
p={4}
>
{error}
</Box>
);
}
return (
<Box
ref={mapContainer}
w="100%"
h={`${height}px`}
borderRadius="md"
overflow="hidden"
boxShadow="md"
/>
);
};
export default VectorMap;
@@ -0,0 +1,337 @@
import { Box, AspectRatio, Text, useColorModeValue, SimpleGrid, Heading, HStack, Badge, Button, Link, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, useDisclosure, Icon, VStack } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { FaYoutube, FaPlay } from 'react-icons/fa';
import HorizontalScroller from '../ui/HorizontalScroller';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { useEffect, useMemo, useState } from 'react';
type Props = {
// optional manual override
videos?: string[];
};
type RenderItem = {
key: string;
title: string;
embedUrl: string;
thumbnail?: string;
date?: string; // YYYY-MM-DD
videoId?: string;
};
const toEmbed = (idOrUrl: string): string => {
// If a full URL is passed, try to extract the id; otherwise assume it's already an id
// supports https://www.youtube.com/watch?v=ID or youtu.be/ID
try {
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
const u = new URL(idOrUrl);
if (u.hostname.includes('youtu.be')) {
const id = u.pathname.replace('/', '');
return `https://www.youtube.com/embed/${id}`;
}
const id = u.searchParams.get('v');
if (id) return `https://www.youtube.com/embed/${id}`;
}
} catch {}
// otherwise treat as id
return `https://www.youtube.com/embed/${idOrUrl}`;
};
const VideosSection: React.FC<Props> = ({ videos }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const theme = useClubTheme();
const { data: settings } = usePublicSettings();
const [yt, setYt] = useState<YouTubeVideo[]>([]);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedVideo, setSelectedVideo] = useState<RenderItem | null>(null);
// If admin explicitly disabled, respect it. Otherwise default to ON when there are manual videos configured
// or when a YouTube URL is present for auto mode.
const hasManualConfigured = Boolean((settings as any)?.videos_items?.length || (settings as any)?.videos?.length);
const hasAutoConfigured = Boolean((settings as any)?.youtube_url || (settings as any)?.social_youtube);
// Default enablement: if not explicitly set, enable when manual items exist or when a YouTube URL is configured (auto mode).
// This avoids flicker caused by toggling visibility while data is loading.
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
? Boolean((settings as any)?.videos_module_enabled)
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
const style = settings?.videos_style || 'slider';
const source = settings?.videos_source || 'auto';
// Default to 6 items on homepage unless overridden by settings (max 12)
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
useEffect(() => {
let canceled = false;
const run = async () => {
if (source !== 'auto') return;
const payload = await getCachedYouTube();
if (!payload) return;
// Sort by published_date descending (safety; service should already do this)
const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
if (!canceled) setYt(vids);
};
run();
return () => { canceled = true; };
}, [source]);
const extractVideoId = (embedUrl: string): string | undefined => {
if (embedUrl?.includes('/embed/')) {
return embedUrl.split('/embed/')[1]?.split('?')[0];
}
return undefined;
};
const items: RenderItem[] = useMemo(() => {
if (source === 'auto') {
return (yt || []).slice(0, limit).map(v => ({
key: v.video_id,
title: v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
}));
}
// manual fallback from settings or prop
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
key: `${i}-${it.url}`,
title: it.title || `Video ${i+1}`,
embedUrl,
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
};
});
const legacy = (videos || settings?.videos || []).map((url, i) => {
const embedUrl = toEmbed(url as any);
return {
key: `${i}-${url}`,
title: `Video ${i+1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
};
});
return (manual.length ? manual : legacy).slice(0, limit);
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit]);
if (!enabled || items.length === 0) return null;
const handlePlayClick = (it: RenderItem) => {
setSelectedVideo(it);
onOpen();
};
const Card: React.FC<{ it: RenderItem; idx: number }> = ({ it, idx }) => {
const thumb = it.thumbnail || (it.videoId ? `https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg` : undefined);
const borderColor = useColorModeValue('gray.200', 'gray.600');
const placeholderBg = useColorModeValue('gray.100', 'gray.700');
const placeholderIcon = useColorModeValue('gray.400', 'gray.500');
const videoPrimaryColor = theme.primary;
return (
<Box
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="2px"
borderColor={borderColor}
transition="all 0.3s"
position="relative"
sx={{
'&:hover': {
transform: 'translateY(-8px)',
boxShadow: '0 20px 40px rgba(0,0,0,0.15)',
borderColor: 'brand.primary',
},
'&:hover .play-overlay': {
opacity: 1,
},
'&:hover .play-overlay > div': {
transform: 'scale(1.05)',
},
}}
>
<AspectRatio ratio={16 / 9}>
<Box position="relative" cursor="pointer" onClick={() => handlePlayClick(it)}>
{/* Thumbnail */}
{thumb ? (
<Box
as="img"
src={thumb}
alt={it.title}
width="100%"
height="100%"
style={{ objectFit: 'cover' }}
/>
) : (
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
<Icon as={FaPlay} boxSize={12} color={placeholderIcon} />
</Box>
)}
{/* Play overlay */}
<Box
className="play-overlay"
position="absolute"
inset={0}
display="flex"
alignItems="center"
justifyContent="center"
bg="blackAlpha.700"
opacity={0}
transition="opacity 0.3s ease"
pointerEvents="none"
>
<Box
bg="white"
color="brand.primary"
borderRadius="full"
px={8}
py={4}
fontWeight="bold"
display="flex"
alignItems="center"
gap={2}
transform="scale(0.9)"
transition="transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
boxShadow="0 12px 32px rgba(0,0,0,0.4)"
>
<Icon as={FaPlay} boxSize={5} />
<Text fontSize="lg">Přehrát</Text>
</Box>
</Box>
</Box>
</AspectRatio>
<Box p={4} borderTopWidth="2px" borderTopColor={videoPrimaryColor}>
<VStack align="start" spacing={2}>
<Text fontWeight="bold" fontSize="md" color={videoPrimaryColor} noOfLines={2}>
{it.title}
</Text>
<HStack justify="space-between" width="100%">
{it.date && (
<Badge colorScheme="gray" fontSize="0.7rem">
{new Date(it.date).toLocaleDateString('cs-CZ')}
</Badge>
)}
{it.videoId && (
<Link href={`https://www.youtube.com/watch?v=${it.videoId}`} isExternal onClick={(e) => e.stopPropagation()}>
<Button
size="xs"
variant="ghost"
colorScheme="red"
leftIcon={<Icon as={FaYoutube} />}
>
YouTube
</Button>
</Link>
)}
</HStack>
</VStack>
</Box>
</Box>
);
};
if (style === 'slider') {
return (
<Box>
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
<HStack spacing={3}>
<Heading as="h3" size="lg" fontWeight="700">Videa</Heading>
</HStack>
<Link as={RouterLink} to="/videa">
<Button
size="md"
variant="solid"
bg={theme.primary}
color="white"
rightIcon={<Box as="span"></Box>}
_hover={{ opacity: 0.9, transform: 'translateX(4px)' }}
transition="all 0.2s"
>
Více videí
</Button>
</Link>
</Box>
<HorizontalScroller draggable>
{items.map((it, idx) => (
<Box
key={it.key}
minW={{ base: '85%', md: '60%', lg: '33%' }}
display="flex"
flexDirection="column"
>
<Card it={it} idx={idx} />
</Box>
))}
</HorizontalScroller>
{/* Video Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
<ModalBody p={0}>
{selectedVideo && (
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
}
const cols = style === 'grid3' ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 3 };
return (
<Box>
<Box className="section-head">
<Heading as="h3" size="md">Videa</Heading>
<Link as={RouterLink} to="/videa">
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
</Link>
</Box>
<SimpleGrid columns={cols} spacing={4}>
{items.map((it, idx) => (
<Card key={it.key} it={it} idx={idx} />
))}
</SimpleGrid>
{/* Video Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
<ModalBody p={0}>
{selectedVideo && (
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
};
export default VideosSection;