mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
de day #74
This commit is contained in:
@@ -23,6 +23,7 @@ import { getCategories, CategoryItem } from '../services/categories';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import { assetUrl } from '../utils/url';
|
||||
|
||||
type AboutPageData = {
|
||||
title: string;
|
||||
@@ -155,7 +156,7 @@ const AboutPage: React.FC = () => {
|
||||
<Helmet>
|
||||
<title>{settings?.club_name ? `O klubu | ${settings.club_name}` : 'O klubu'}</title>
|
||||
<meta name="description" content="Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách." />
|
||||
{settings?.club_logo_url && <meta property="og:image" content={settings.club_logo_url} />}
|
||||
{settings?.club_logo_url && <meta property="og:image" content={assetUrl(settings.club_logo_url) || settings.club_logo_url} />}
|
||||
</Helmet>
|
||||
<Container maxW="container.lg" py={8}>
|
||||
<Box textAlign="center" py={6}>
|
||||
@@ -177,7 +178,7 @@ const AboutPage: React.FC = () => {
|
||||
const seoTitle = data.seo_title || data.title;
|
||||
const seoDesc = data.seo_description || data.subtitle;
|
||||
const clubName = settings?.club_name || data.title;
|
||||
const clubLogo = settings?.club_logo_url;
|
||||
const clubLogo = settings?.club_logo_url ? (assetUrl(settings.club_logo_url) || settings.club_logo_url) : undefined;
|
||||
const cleanContent = DOMPurify.sanitize(data.content);
|
||||
|
||||
const renderContent = () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button,
|
||||
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { trackEvent as umamiTrackEvent } from '../utils/umami';
|
||||
import EventLocationMap from '../components/events/EventLocationMap';
|
||||
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
||||
import FilePreview from '../components/common/FilePreview';
|
||||
@@ -35,6 +36,24 @@ const ActivityDetailPage: React.FC = () => {
|
||||
const typeLabel = (t?: string) => t === 'match' ? 'Zápas' : t === 'training' ? 'Trénink' : t === 'meeting' ? 'Schůzka' : 'Jiné';
|
||||
const typeColor = (t?: string) => t === 'match' ? 'red' : t === 'training' ? 'blue' : t === 'meeting' ? 'green' : 'gray';
|
||||
|
||||
// Delegate click tracking for links inside activity description
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const a = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
|
||||
if (a && a.href) {
|
||||
const href = a.getAttribute('href') || a.href;
|
||||
try { umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'activity_content' }); } catch {}
|
||||
}
|
||||
};
|
||||
el.addEventListener('click', handler);
|
||||
return () => { el.removeEventListener('click', handler); };
|
||||
}, [contentRef.current]);
|
||||
|
||||
// Extract YouTube video ID from various URL formats
|
||||
const getYouTubeEmbedUrl = (url: string): string | null => {
|
||||
if (!url) return null;
|
||||
@@ -92,7 +111,14 @@ const ActivityDetailPage: React.FC = () => {
|
||||
<VStack align="stretch" spacing={5}>
|
||||
{data.image_url && (
|
||||
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
|
||||
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" maxH="420px" objectFit="cover" />
|
||||
<Image
|
||||
src={assetUrl(data.image_url) || data.image_url}
|
||||
alt={data.title}
|
||||
w="100%"
|
||||
maxH="420px"
|
||||
objectFit="cover"
|
||||
fallbackSrc="/dist/img/logo-club-empty.svg"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -132,6 +158,7 @@ const ActivityDetailPage: React.FC = () => {
|
||||
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
|
||||
' img': { maxWidth: '100%', borderRadius: 'md' },
|
||||
}}
|
||||
ref={contentRef}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(data.description)) }}
|
||||
/>
|
||||
)}
|
||||
@@ -161,7 +188,7 @@ const ActivityDetailPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{data?.id && (
|
||||
<EmbeddedPoll eventId={data.id} />
|
||||
<EmbeddedPoll eventId={data.id} maxPolls={2} />
|
||||
)}
|
||||
|
||||
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio } from '@chakra-ui/react';
|
||||
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView } from '../services/articles';
|
||||
@@ -13,6 +13,11 @@ import React from 'react';
|
||||
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { API_URL } from '../services/api';
|
||||
import TeamLogo from '../components/common/TeamLogo';
|
||||
import { extractPalette } from '../utils/colors';
|
||||
import { getTeamLogo } from '../utils/sportLogosAPI';
|
||||
import FilePreview from '../components/common/FilePreview';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
|
||||
const toText = (html?: string) => {
|
||||
if (!html) return '';
|
||||
@@ -29,6 +34,22 @@ const ArticleDetailPage: React.FC = () => {
|
||||
enabled: Boolean(slug || id),
|
||||
});
|
||||
|
||||
|
||||
|
||||
// UI colors and public settings
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const cardBg = useColorModeValue('white','gray.900');
|
||||
const videoBg = useColorModeValue('gray.50','gray.800');
|
||||
const textMuted = useColorModeValue('gray.600','gray.400');
|
||||
// Hoist all color mode values to top-level to avoid conditional hook calls
|
||||
const videoTitleColor = useColorModeValue('gray.700','gray.300');
|
||||
const galleryBg = useColorModeValue('blue.50','blue.900');
|
||||
const galleryBorder = useColorModeValue('blue.200','blue.700');
|
||||
const attachmentsBg = useColorModeValue('gray.50','gray.800');
|
||||
|
||||
// Derive opponent color (for right edge fade) from team logo
|
||||
const [opponentColor, setOpponentColor] = React.useState<string | null>(null);
|
||||
|
||||
// Placeholders; moved tracking effects below to avoid using variables before declaration
|
||||
|
||||
// Track article view when data is loaded
|
||||
@@ -40,6 +61,27 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Delegated click tracking for normal links inside content
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
let target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
// Find nearest anchor
|
||||
const anchor = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
|
||||
if (anchor && anchor.href) {
|
||||
try {
|
||||
const href = anchor.getAttribute('href') || anchor.href;
|
||||
umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'article_content' });
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
el.addEventListener('click', handler);
|
||||
return () => { el.removeEventListener('click', handler); };
|
||||
}, [contentRef.current]);
|
||||
|
||||
// Fetch linked match (public)
|
||||
const matchLinkQuery = useQuery({
|
||||
queryKey: ['article-match-link', (data as any)?.id],
|
||||
@@ -81,10 +123,10 @@ const ArticleDetailPage: React.FC = () => {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Fetch gallery album if article has one
|
||||
// Fetch gallery album if article has one (fallback to URL when ID is missing)
|
||||
const galleryAlbumQuery = useQuery({
|
||||
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id],
|
||||
enabled: Boolean((data as any)?.gallery_album_id),
|
||||
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id || (data as any)?.gallery_album_url],
|
||||
enabled: Boolean((data as any)?.gallery_album_id || (data as any)?.gallery_album_url),
|
||||
queryFn: async () => {
|
||||
const albumId = (data as any)?.gallery_album_id;
|
||||
let photoIds: string[] = [];
|
||||
@@ -139,6 +181,24 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: load by URL via proxy endpoint
|
||||
const albumUrl = (data as any)?.gallery_album_url;
|
||||
if (albumUrl) {
|
||||
const params = new URLSearchParams({ link: albumUrl, photo_limit: '12', rendered: 'true' });
|
||||
const resp = await fetch(`${API_URL}/zonerama-album?${params.toString()}`);
|
||||
if (resp.ok) {
|
||||
const payload = await resp.json();
|
||||
let photos: any[] = [];
|
||||
if (Array.isArray(payload?.albums) && payload.albums.length > 0) {
|
||||
photos = payload.albums[0]?.photos || [];
|
||||
} else if (Array.isArray(payload?.photos)) {
|
||||
photos = payload.photos;
|
||||
}
|
||||
if (photoIds.length > 0) photos = photos.filter((p: any) => photoIds.includes(p.id));
|
||||
return { id: albumUrl, title: 'Album', date: '', photos } as any;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
@@ -152,15 +212,6 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}
|
||||
}, [(data as any)?.gallery_album_id, galleryAlbumQuery.data]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
|
||||
|
||||
const title = (data as any).seo_title || data.title;
|
||||
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
|
||||
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
|
||||
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
|
||||
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
|
||||
|
||||
// Transform content to ensure /uploads URLs resolve against API origin and allow iframes
|
||||
// Memoize the transformation function to prevent infinite loops
|
||||
const toAbsoluteUploads = React.useCallback((html?: string) => {
|
||||
@@ -177,13 +228,27 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const safeContentHTML = React.useMemo(() => {
|
||||
const transformed = toAbsoluteUploads(data.content);
|
||||
const transformed = toAbsoluteUploads((data as any)?.content);
|
||||
return DOMPurify.sanitize(transformed || '', {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen'],
|
||||
});
|
||||
}, [data.content, toAbsoluteUploads]);
|
||||
}, [(data as any)?.content, toAbsoluteUploads]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
|
||||
|
||||
const title = (data as any).seo_title || data.title;
|
||||
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
|
||||
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
|
||||
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
|
||||
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
|
||||
const publishedAt = (data as any).published_at || (data as any).created_at;
|
||||
const monthParam = (() => {
|
||||
if (!publishedAt) return '';
|
||||
try { const d = new Date(publishedAt); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } catch { return ''; }
|
||||
})();
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
@@ -243,201 +308,238 @@ const ArticleDetailPage: React.FC = () => {
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={4}>
|
||||
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={2}>
|
||||
<Container maxW="7xl">
|
||||
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={3}>{data.title}</Heading>
|
||||
<HStack spacing={4} fontSize="sm" color="gray.600">
|
||||
{((data as any).read_time || (data as any).estimated_read_minutes) && (
|
||||
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={2}>{data.title}</Heading>
|
||||
<HStack spacing={2} rowGap={2} wrap="wrap" fontSize="sm" color={textMuted}>
|
||||
{((data as any).read_time || (data as any).estimated_read_minutes) ? (
|
||||
<HStack spacing={1}>
|
||||
<Clock size={16} />
|
||||
<Text>{(data as any).read_time || (data as any).estimated_read_minutes} min čtení</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
{publishedAt && (
|
||||
<Tag as={RouterLink} to={`/news?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
|
||||
)}
|
||||
{(data as any).view_count !== undefined && (data as any).view_count > 0 && (
|
||||
<HStack spacing={1}>
|
||||
{(data as any)?.category?.id && (
|
||||
<Tag as={RouterLink} to={`/news?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
|
||||
)}
|
||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
||||
<Tag as={RouterLink} to={`/news?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
|
||||
)}
|
||||
{(data as any).view_count ? (
|
||||
<HStack spacing={1} ml={{ base: 0, md: 2 }}>
|
||||
<Eye size={16} />
|
||||
<Text>{(data as any).view_count} zobrazení</Text>
|
||||
</HStack>
|
||||
)}
|
||||
) : null}
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
<Container maxW="7xl">
|
||||
<Stack spacing={6}>
|
||||
{/* Featured Image - Top */}
|
||||
{/* Featured Image - smaller with subtle overlay */}
|
||||
{data.image_url && (
|
||||
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} borderRadius="lg" />
|
||||
<Box position="relative" borderRadius="xl" overflow="hidden">
|
||||
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h={{ base: '220px', md: '360px' }} objectFit="cover" />
|
||||
<Box position="absolute" inset={0} bg="brand.primary" opacity={0.08} pointerEvents="none" />
|
||||
<Box position="absolute" inset={0} bgGradient="linear(to-b, rgba(0,0,0,0.12), rgba(0,0,0,0.02))" pointerEvents="none" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* YouTube Video Section - If attached to article */}
|
||||
{/* YouTube Video Section - smaller and rounded */}
|
||||
{(data as any)?.youtube_video_id && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
|
||||
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Box
|
||||
as="iframe"
|
||||
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
title={(data as any).youtube_video_title || 'YouTube video'}
|
||||
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
|
||||
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
|
||||
/>
|
||||
</AspectRatio>
|
||||
{ (data as any).youtube_video_title ? (
|
||||
<Text mt={2} color="gray.700">{(data as any).youtube_video_title}</Text>
|
||||
) : null }
|
||||
<Box maxW="3xl" mx="auto" borderRadius="lg" overflow="hidden">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Box
|
||||
as="iframe"
|
||||
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
title={(data as any).youtube_video_title || 'YouTube video'}
|
||||
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
|
||||
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</Box>
|
||||
{(data as any).youtube_video_title ? (
|
||||
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Match Section - After Image */}
|
||||
{/* Match Section - Card with logos, score/countdown, venue/date */}
|
||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
|
||||
<HStack justify="space-between" align="start">
|
||||
<Box>
|
||||
<Heading as="h3" size="md" mb={1}>Zápas k článku</Heading>
|
||||
{facrMatchQuery.isLoading ? (
|
||||
<Text color="gray.600">Načítám údaje o zápasu…</Text>
|
||||
) : facrMatchQuery.data ? (
|
||||
<>
|
||||
<HStack spacing={2} wrap="wrap">
|
||||
{facrMatchQuery.data.competitionName && (
|
||||
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
|
||||
)}
|
||||
<Badge>{String(facrMatchQuery.data.date_time || facrMatchQuery.data.date || '')}</Badge>
|
||||
</HStack>
|
||||
<Text mt={2} fontWeight="600">
|
||||
{String(facrMatchQuery.data.home || facrMatchQuery.data.home_team || '')}
|
||||
{' '}
|
||||
{String(facrMatchQuery.data.score || (facrMatchQuery.data.result_home!=null && facrMatchQuery.data.result_away!=null ? `${facrMatchQuery.data.result_home}:${facrMatchQuery.data.result_away}` : 'vs'))}
|
||||
{' '}
|
||||
{String(facrMatchQuery.data.away || facrMatchQuery.data.away_team || '')}
|
||||
</Text>
|
||||
{facrMatchQuery.data.venue && (
|
||||
<Text color="gray.600">{String(facrMatchQuery.data.venue)}</Text>
|
||||
)}
|
||||
{facrMatchQuery.data.report_url && (
|
||||
<Box mt={2}>
|
||||
<Link href={String(facrMatchQuery.data.report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray.600">Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
|
||||
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
|
||||
{/* Edge fades */}
|
||||
<Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
|
||||
{opponentColor && (
|
||||
<Box position="absolute" top={0} right={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-l, ${opponentColor}, transparent)`} pointerEvents="none" />
|
||||
)}
|
||||
<Heading as="h3" size="md" mb={3}>Zápas k článku</Heading>
|
||||
{facrMatchQuery.isLoading ? (
|
||||
<Text color={textMuted}>Načítám údaje o zápasu…</Text>
|
||||
) : facrMatchQuery.data ? (
|
||||
<>
|
||||
<HStack spacing={2} wrap="wrap" mb={3}>
|
||||
{facrMatchQuery.data.competitionName && (
|
||||
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
|
||||
)}
|
||||
<Badge>{String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')}</Badge>
|
||||
</HStack>
|
||||
<Flex align="center" justify="space-between" gap={4}>
|
||||
<VStack flex={1} spacing={2} minW="0">
|
||||
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).home_team_id || (facrMatchQuery.data as any).home_id || '')} teamName={String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')} />
|
||||
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')}</Text>
|
||||
</VStack>
|
||||
<VStack minW={{ base: '100px', md: '140px' }}>
|
||||
{(() => {
|
||||
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
|
||||
const d = new Date(dRaw);
|
||||
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs');
|
||||
if (hasScore) {
|
||||
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
|
||||
return (<Heading size="2xl">{score}</Heading>);
|
||||
}
|
||||
const now = Date.now();
|
||||
const ms = d.getTime() - now;
|
||||
const days = Math.max(0, Math.floor(ms / (1000*60*60*24)));
|
||||
const hours = Math.max(0, Math.floor((ms % (1000*60*60*24))/(1000*60*60)));
|
||||
const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60)));
|
||||
return (<Text fontSize="lg" fontWeight="700">Za {days} d {hours} h {mins} min</Text>);
|
||||
})()}
|
||||
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</Text>}
|
||||
{(() => {
|
||||
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
|
||||
const d = new Date(dRaw);
|
||||
return <Text fontSize="sm" color={textMuted}>{d.toLocaleDateString('cs-CZ')} {d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}</Text>;
|
||||
})()}
|
||||
</VStack>
|
||||
<VStack flex={1} spacing={2} minW="0">
|
||||
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).away_team_id || (facrMatchQuery.data as any).away_id || '')} teamName={String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')} />
|
||||
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')}</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
{(facrMatchQuery.data as any).report_url && (
|
||||
<Box mt={3}>
|
||||
<Link href={String((facrMatchQuery.data as any).report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</HStack>
|
||||
</>
|
||||
) : (
|
||||
<Text color={textMuted}>Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Article Content - Main Section */}
|
||||
{/* Article Content - Main Section with editor-like lists */}
|
||||
<Box
|
||||
className="article-content"
|
||||
bg="white"
|
||||
bg={useColorModeValue('white','gray.900')}
|
||||
borderRadius="lg"
|
||||
p={{ base: 4, md: 6 }}
|
||||
ref={contentRef}
|
||||
sx={{ 'ul, ol': { pl: 6, listStylePosition: 'outside' }, 'ul': { listStyleType: 'disc' }, 'ol': { listStyleType: 'decimal' }, 'li': { mb: 2 } }}
|
||||
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
|
||||
/>
|
||||
|
||||
{/* Gallery Section - At the End */}
|
||||
{(data as any)?.gallery_album_id && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="blue.50" borderColor="blue.200">
|
||||
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
|
||||
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
|
||||
<Box mb={3}>
|
||||
<HStack justify="space-between" align="center" mb={2}>
|
||||
<Heading as="h3" size="md">Fotogalerie k článku</Heading>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${(data as any).gallery_album_id}`}
|
||||
to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
rightIcon={<ArrowRight size={16} />}
|
||||
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
|
||||
>
|
||||
Zobrazit celé album
|
||||
Zobrazit galerii
|
||||
</Button>
|
||||
</HStack>
|
||||
{galleryAlbumQuery.isLoading ? (
|
||||
<Text color="gray.600">Načítám fotografie…</Text>
|
||||
) : galleryAlbumQuery.data ? (
|
||||
<>
|
||||
<HStack spacing={2} mb={3}>
|
||||
<Badge colorScheme="purple">
|
||||
{galleryAlbumQuery.data.title}
|
||||
</Badge>
|
||||
{galleryAlbumQuery.data.date && (
|
||||
<Badge>{galleryAlbumQuery.data.date}</Badge>
|
||||
)}
|
||||
<Badge colorScheme="blue">
|
||||
{galleryAlbumQuery.data.photos?.length || 0} foto
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* Photo Grid */}
|
||||
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (
|
||||
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing={2}>
|
||||
{galleryAlbumQuery.data.photos.slice(0, 12).map((photo: any) => (
|
||||
<Box
|
||||
key={photo.id}
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${(data as any).gallery_album_id}`}
|
||||
position="relative"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'lg' }}
|
||||
onClick={() => umamiTrackEvent('Gallery Photo Click', { album_id: (data as any).gallery_album_id, photo_id: photo.id })}
|
||||
>
|
||||
<Image
|
||||
src={photo.image_1500}
|
||||
alt={`Fotka ${photo.id}`}
|
||||
w="100%"
|
||||
h="120px"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
{/* Custom 5-image mosaic */}
|
||||
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (() => {
|
||||
const photos = galleryAlbumQuery.data.photos.slice(0, 5);
|
||||
if (photos.length < 5) {
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
|
||||
{photos.map((p: any) => (
|
||||
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Zonerama Attribution */}
|
||||
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
|
||||
<Text>📸 Fotografie z</Text>
|
||||
<Link
|
||||
href={(data as any).gallery_album_url || `https://zonerama.com`}
|
||||
isExternal
|
||||
fontWeight="600"
|
||||
color="blue.600"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
Zonerama
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</HStack>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray.600">Album s ID: {(data as any).gallery_album_id}</Text>
|
||||
)}
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box position="relative" sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1.2fr 1fr',
|
||||
gridTemplateRows: 'repeat(2, 140px)',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Zonerama Attribution */}
|
||||
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
|
||||
<Text>📸 Fotografie z</Text>
|
||||
<Link
|
||||
href={(data as any).gallery_album_url || `https://zonerama.com`}
|
||||
isExternal
|
||||
fontWeight="600"
|
||||
color="blue.600"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
Zonerama
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Embedded Poll - directly under content/gallery */}
|
||||
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Embedded Poll - shows polls related to this article */}
|
||||
{data?.id && <EmbeddedPoll articleId={data.id} />}
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
{/* Attachments - bottom above CTA */}
|
||||
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
|
||||
<Container maxW="7xl" mt={4}>
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
|
||||
<Heading as="h3" size="md" mb={2}>Přílohy</Heading>
|
||||
<Stack spacing={2}>
|
||||
{(data as any).attachments.map((f: any, idx: number) => (
|
||||
<HStack key={idx} justify="space-between">
|
||||
<Text noOfLines={1}>{f.name || f.url}</Text>
|
||||
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
|
||||
</HStack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
)}
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleDetailPage;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article, Paginated } from '../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { getCategories, CategoryItem } from '../services/categories';
|
||||
@@ -92,7 +92,14 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
|
||||
const BlogPage: React.FC = () => {
|
||||
const pageSize = 18;
|
||||
const [categories, setCategories] = React.useState<CategoryItem[]>([]);
|
||||
const [categoryId, setCategoryId] = React.useState<number | ''>('');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialCategory = React.useMemo(() => {
|
||||
const cid = searchParams.get('category_id');
|
||||
return cid ? Number(cid) : '';
|
||||
}, []);
|
||||
const [categoryId, setCategoryId] = React.useState<number | ''>(initialCategory);
|
||||
const month = searchParams.get('month') || '';
|
||||
const matchId = searchParams.get('match_id') || '';
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
@@ -111,13 +118,15 @@ const BlogPage: React.FC = () => {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery<Paginated<Article>>(
|
||||
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined }],
|
||||
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined, month: month || undefined, match_id: matchId || undefined }],
|
||||
({ pageParam = 1 }) =>
|
||||
getArticles({
|
||||
page: pageParam,
|
||||
page_size: pageSize,
|
||||
published: true,
|
||||
...(categoryId ? { category_id: Number(categoryId) } : {}),
|
||||
...(month ? { month } : {}),
|
||||
...(matchId ? { match_id: matchId } : {}),
|
||||
}),
|
||||
{
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
@@ -159,7 +168,15 @@ const BlogPage: React.FC = () => {
|
||||
maxW={{ base: '52%', md: '320px' }}
|
||||
placeholder="Všechny kategorie"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : '')}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value ? Number(e.target.value) : '';
|
||||
setCategoryId(val);
|
||||
const next: Record<string, string> = {};
|
||||
if (val) next.category_id = String(val);
|
||||
if (month) next.month = month;
|
||||
if (matchId) next.match_id = matchId;
|
||||
setSearchParams(next);
|
||||
}}
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { sortCategoriesWithOrder } from '../utils/categorySort';
|
||||
import ClubModal from '../components/home/ClubModal';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { API_URL } from '../services/api';
|
||||
import { TeamLogo } from '../components/common/TeamLogo';
|
||||
|
||||
// Weekday headers (Czech, starting Monday)
|
||||
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
|
||||
@@ -226,9 +227,16 @@ const CalendarPage: React.FC = () => {
|
||||
} catch {}
|
||||
}
|
||||
const byName: Record<string, string> = (overrides?.by_name || {}) as any;
|
||||
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) as any;
|
||||
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {});
|
||||
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
|
||||
const getOverrideLogo = (teamName?: string, original?: string) => {
|
||||
const getOverrideLogo = (teamName?: string, original?: string, teamId?: string) => {
|
||||
// Prefer admin override by ID
|
||||
if (teamId && byId?.[teamId]?.logo_url) {
|
||||
const v = byId[teamId]!.logo_url as string;
|
||||
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
||||
return v;
|
||||
}
|
||||
if (!teamName) return original;
|
||||
const exact = (byName || {})[teamName];
|
||||
const normName = normalize(teamName);
|
||||
@@ -260,17 +268,19 @@ const CalendarPage: React.FC = () => {
|
||||
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
||||
const time = (t || '00:00').slice(0,5);
|
||||
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
|
||||
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
|
||||
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
|
||||
return {
|
||||
id: m.match_id || `${cIdx}-${idx}`,
|
||||
date: isoDate,
|
||||
time,
|
||||
home: m.home,
|
||||
away: m.away,
|
||||
home: homeName,
|
||||
away: awayName,
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
venue: m.venue,
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
||||
home_logo_url: getOverrideLogo(homeName, m.home_logo_url, m.home_id),
|
||||
away_logo_url: getOverrideLogo(awayName, m.away_logo_url, m.away_id),
|
||||
report_url: m.report_url,
|
||||
facr_link: m.facr_link,
|
||||
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
|
||||
@@ -309,13 +319,13 @@ const CalendarPage: React.FC = () => {
|
||||
id: m.match_id || `${cIdx}-${idx}`,
|
||||
date: isoDate,
|
||||
time,
|
||||
home: m.home,
|
||||
away: m.away,
|
||||
home: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
|
||||
away: (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away,
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
venue: m.venue,
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url, m.home_id),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url, m.away_id),
|
||||
report_url: m.report_url,
|
||||
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
|
||||
} as MatchItem;
|
||||
@@ -381,7 +391,7 @@ const CalendarPage: React.FC = () => {
|
||||
})();
|
||||
return {
|
||||
position: Number(r.rank || idx + 1),
|
||||
team_name: teamName,
|
||||
team_name: (teamId && byId?.[teamId]?.name && String(byId[teamId].name).trim()) ? String(byId[teamId].name) : teamName,
|
||||
team_id: teamId,
|
||||
points: Number(r.points || r.pts || 0),
|
||||
played: Number(r.played || r.matches || 0),
|
||||
@@ -390,7 +400,7 @@ const CalendarPage: React.FC = () => {
|
||||
losses: Number(r.losses || r.loss || 0),
|
||||
goals_for: Number(r.goals_for ?? r.gf ?? r.goalsFor ?? r.scored ?? r.goals ?? 0),
|
||||
goals_against: Number(r.goals_against ?? r.ga ?? r.goalsAgainst ?? r.conceded ?? 0),
|
||||
logo_url: r.team_logo_url || undefined,
|
||||
logo_url: (teamId && byId?.[teamId]?.logo_url) ? String(byId[teamId].logo_url) : (r.team_logo_url || undefined),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -660,10 +670,26 @@ const CalendarPage: React.FC = () => {
|
||||
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
|
||||
</Flex>
|
||||
<Flex align="center" gap={2} justify="center">
|
||||
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
|
||||
<TeamLogo
|
||||
teamId={m.home_id}
|
||||
teamName={m.home}
|
||||
facrLogo={m.home_logo_url}
|
||||
size="custom"
|
||||
boxSize="18px"
|
||||
alt={m.home}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<Text fontSize="sm">{m.home}</Text>
|
||||
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
|
||||
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
|
||||
<TeamLogo
|
||||
teamId={m.away_id}
|
||||
teamName={m.away}
|
||||
facrLogo={m.away_logo_url}
|
||||
size="custom"
|
||||
boxSize="18px"
|
||||
alt={m.away}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<Text fontSize="sm">{m.away}</Text>
|
||||
</Flex>
|
||||
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
|
||||
|
||||
+123
-322
@@ -3,6 +3,8 @@ import MainLayout from '../components/layout/MainLayout';
|
||||
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
import '../styles/theme.css';
|
||||
import '../styles/sparta-styles.css';
|
||||
import '../styles/club-styles.css';
|
||||
import '../styles/home-style-pack.css';
|
||||
import './styles/UnifiedHome.css';
|
||||
import { getPublicSettings } from '../services/settings';
|
||||
import { assetUrl, sanitizeClubName } from '../utils/url';
|
||||
@@ -25,6 +27,12 @@ import MatchModal from '../components/home/MatchModal';
|
||||
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
|
||||
import { API_URL } from '../services/api';
|
||||
import { TeamLogo } from '../components/common/TeamLogo';
|
||||
import ClubHeroTopbar from '../components/home/ClubHeroTopbar';
|
||||
import NewsList from '../components/pack/NewsList';
|
||||
import StandingsCard from '../components/pack/StandingsCard';
|
||||
import NextMatch from '../components/pack/NextMatch';
|
||||
import MatchesSlider from '../components/pack/MatchesSlider';
|
||||
import ActivitiesList from '../components/pack/ActivitiesList';
|
||||
|
||||
// Types for real API-driven data
|
||||
type NewsItem = {
|
||||
@@ -87,9 +95,7 @@ const HomePage: React.FC = () => {
|
||||
// Index for the NEXT MATCH competition carousel
|
||||
const [nextCompIdx, setNextCompIdx] = useState<number>(0);
|
||||
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
|
||||
// Ref to the draggable matches track and per-competition closest index
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const [closestIndexByComp, setClosestIndexByComp] = useState<number[]>([]);
|
||||
// Matches slider auto-centering handled internally by MatchesSlider component
|
||||
|
||||
// API-driven players and sponsors
|
||||
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string };
|
||||
@@ -114,6 +120,16 @@ const HomePage: React.FC = () => {
|
||||
|
||||
// MyUIbrix element configuration hook for live preview
|
||||
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
|
||||
const stylePack = getVariant('style-pack', 'default');
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const cls = `style-pack-${stylePack}`;
|
||||
const all = ['style-pack-default','style-pack-modern','style-pack-minimal','style-pack-sparta'];
|
||||
all.forEach(c => document.body.classList.remove(c));
|
||||
document.body.classList.add(cls);
|
||||
} catch {}
|
||||
}, [stylePack]);
|
||||
|
||||
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
|
||||
id: typeof item.id === 'number' ? item.id : index,
|
||||
@@ -360,22 +376,6 @@ const HomePage: React.FC = () => {
|
||||
});
|
||||
setFacrCompetitions(comps);
|
||||
|
||||
// Compute closest match index per competition to current time
|
||||
const nowTs = Date.now();
|
||||
const closestIdx: number[] = comps.map((c: { matches: any[] }) => {
|
||||
let bestIdx = -1;
|
||||
let bestDiff = Number.POSITIVE_INFINITY;
|
||||
(c.matches || []).forEach((m: any, idx: number) => {
|
||||
const ts = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
|
||||
if (!isNaN(ts)) {
|
||||
const diff = Math.abs(ts - nowTs);
|
||||
if (diff < bestDiff) { bestDiff = diff; bestIdx = idx; }
|
||||
}
|
||||
});
|
||||
return bestIdx;
|
||||
});
|
||||
setClosestIndexByComp(closestIdx);
|
||||
|
||||
// Next match FACR link
|
||||
const first = filteredMatches?.[0];
|
||||
setNextMatchLink((first && (first.facr_link || first.report_url)) || comps?.[0]?.matches_link || facrClubJSON?.url);
|
||||
@@ -528,24 +528,7 @@ const HomePage: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll matches track to the closest match for current tab
|
||||
useEffect(() => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const idx = (closestIndexByComp[matchesTab] ?? 0);
|
||||
const child = el.children?.[idx] as HTMLElement | undefined;
|
||||
if (!child) return;
|
||||
const run = () => {
|
||||
const targetLeft = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2;
|
||||
el.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
||||
};
|
||||
// Wait for layout
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
requestAnimationFrame(run);
|
||||
} else {
|
||||
setTimeout(run, 0);
|
||||
}
|
||||
}, [facrCompetitions, matchesTab, closestIndexByComp]);
|
||||
// Removed: legacy auto-scroll. Handled by MatchesSlider.
|
||||
|
||||
// MyUIbrix events are handled by useAllPageElementConfigs hook
|
||||
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
|
||||
@@ -1340,26 +1323,38 @@ const HomePage: React.FC = () => {
|
||||
return (
|
||||
<MainLayout headerInsideContainer>
|
||||
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
|
||||
{/* Header: logo + club name */}
|
||||
<div className="home-header">
|
||||
<TeamLogo
|
||||
teamId={settings?.club_id}
|
||||
teamName={clubName}
|
||||
facrLogo={assetUrl(clubLogo) || undefined}
|
||||
size="custom"
|
||||
alt="Klub"
|
||||
borderRadius="full"
|
||||
style={{ width: 56, height: 56 }}
|
||||
/>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>{clubName}</h1>
|
||||
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
|
||||
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
|
||||
{/* Above-hero club bar (MyUIbrix managed) */}
|
||||
{isVisible('hero-topbar', true) && (
|
||||
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'brand')} style={{ ...getStyles('hero-topbar') }}>
|
||||
<ClubHeroTopbar
|
||||
variant={(getVariant('hero-topbar', 'brand') as any) as 'brand' | 'minimal' | 'badge'}
|
||||
fullBleed={getVariant('header', 'unified') === 'fullwidth'}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{/* Header: logo + club name (legacy). Hidden when hero-topbar is visible */}
|
||||
{!isVisible('hero-topbar', true) && (
|
||||
<div className="home-header">
|
||||
<TeamLogo
|
||||
teamId={settings?.club_id}
|
||||
teamName={clubName}
|
||||
facrLogo={assetUrl(clubLogo) || undefined}
|
||||
size="custom"
|
||||
alt="Klub"
|
||||
borderRadius="full"
|
||||
style={{ width: 56, height: 56 }}
|
||||
/>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>{clubName}</h1>
|
||||
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
||||
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
|
||||
<section key={`hero-grid-${refreshKey}`} data-element="hero" className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<section key={`hero-grid-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
{featured[0] ? (
|
||||
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url(${assetUrl(featured[0].image) || '/images/news/placeholder.jpg'})` }} />
|
||||
@@ -1401,7 +1396,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
{/* Banner: homepage_middle */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_middle') && isVisible('banner', true) && (
|
||||
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
<section data-element="banner" data-variant={getVariant('banner', 'top')} className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
@@ -1415,7 +1410,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Sidebar banners (homepage_sidebar) */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
|
||||
<section data-element="sidebar" className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
|
||||
<section data-element="sidebar" data-variant={getVariant('sidebar', 'right')} className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
|
||||
{/* Simple responsive behavior: stack on mobile, sticky right rail on desktop */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
|
||||
@@ -1430,12 +1425,12 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
||||
<section key={`hero-scroller-${refreshKey}`} data-element="hero" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogCardsScroller />
|
||||
</section>
|
||||
)}
|
||||
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
|
||||
<section key={`hero-swiper-${refreshKey}`} data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
|
||||
<section key={`hero-swiper-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogSwiper fallbackArticles={heroFallbackArticles}
|
||||
/>
|
||||
</section>
|
||||
@@ -1457,155 +1452,54 @@ const HomePage: React.FC = () => {
|
||||
setSelectedMatch({
|
||||
...show,
|
||||
competition: comp?.name,
|
||||
competitionName: comp?.name,
|
||||
});
|
||||
setIsMatchModalOpen(true);
|
||||
} else if (link) {
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section data-element="matches" className="next-match" onClick={handleNextMatchClick} style={{ cursor: 'pointer', position: 'relative', ...getStyles('matches') }}>
|
||||
<button
|
||||
aria-label="Předchozí soutěž"
|
||||
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length); }}
|
||||
className="nav prev"
|
||||
style={{ background:'transparent', border:'none', color:'var(--text-on-primary)' }}
|
||||
>
|
||||
<FiChevronLeft size={24} />
|
||||
</button>
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
className="logo"
|
||||
teamId={show?.home_id}
|
||||
teamName={show?.home}
|
||||
facrLogo={show?.home_logo_url}
|
||||
size="custom"
|
||||
alt="Domácí"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div>{sanitizeClubName(show?.home || matches[0]?.homeTeam || clubName)}</div>
|
||||
</div>
|
||||
<div className="countdown">
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{comp?.name || 'Soutěž'}</div>
|
||||
{countdown || '—'}
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
|
||||
</div>
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
className="logo"
|
||||
teamId={show?.away_id}
|
||||
teamName={show?.away}
|
||||
facrLogo={show?.away_logo_url}
|
||||
size="custom"
|
||||
alt="Hosté"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div>{sanitizeClubName(show?.away || matches[0]?.awayTeam || 'Soupeř')}</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Další soutěž"
|
||||
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i + 1) % facrCompetitions.length); }}
|
||||
className="nav next"
|
||||
style={{ background:'transparent', border:'none', color:'var(--text-on-primary)' }}
|
||||
>
|
||||
<FiChevronRight size={24} />
|
||||
</button>
|
||||
</section>
|
||||
<NextMatch
|
||||
data={show}
|
||||
competitionName={comp?.name}
|
||||
countdown={countdown}
|
||||
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
|
||||
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
|
||||
onOpen={handleNextMatchClick}
|
||||
elementProps={{
|
||||
'data-element': 'matches' as any,
|
||||
'data-variant': getVariant('matches', 'compact') as any,
|
||||
style: { ...getStyles('matches') },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : isVisible('matches', true) ? (
|
||||
<section data-element="matches" className="next-match" style={{ position: 'relative', ...getStyles('matches') }}>
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(matches[0]?.homeLogoURL) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
|
||||
<div>{sanitizeClubName(matches[0]?.homeTeam || clubName)}</div>
|
||||
</div>
|
||||
<div className="countdown">
|
||||
{countdown || '—'}
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
|
||||
{nextMatchLink && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<a href={nextMatchLink} target="_blank" rel="noopener noreferrer" style={{ color: '#fff', textDecoration: 'underline', fontSize: '0.85rem' }}>Detail na FACR</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(matches[0]?.awayLogoURL) || '/images/club-opponent.png'} alt="Hosté" />
|
||||
<div>{sanitizeClubName(matches[0]?.awayTeam || 'Soupeř')}</div>
|
||||
</div>
|
||||
</section>
|
||||
<NextMatch
|
||||
data={{
|
||||
home: matches[0]?.homeTeam || clubName,
|
||||
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
||||
away: matches[0]?.awayTeam || 'Soupeř',
|
||||
away_logo_url: matches[0]?.awayLogoURL,
|
||||
}}
|
||||
countdown={countdown}
|
||||
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Matches slider with scores by competition (moved after news+tables) */}
|
||||
{facrCompetitions.length > 0 && (
|
||||
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h3>Zápasy</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
|
||||
</div>
|
||||
<div className="matches-grid">
|
||||
<div className="matches-track" ref={trackRef}>
|
||||
{(facrCompetitions[matchesTab]?.matches || []).map((m:any, idx:number) => {
|
||||
const handleMatchClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setSelectedMatch({
|
||||
...m,
|
||||
competition: facrCompetitions[matchesTab]?.name,
|
||||
competitionName: facrCompetitions[matchesTab]?.name,
|
||||
});
|
||||
setIsMatchModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<div key={m.id || idx} className="match-card" onClick={handleMatchClick} style={{ cursor: 'pointer' }}>
|
||||
<div className="match-meta">
|
||||
<span>{(m.venue || '').split(',')[0] || ''}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(`${m.date}T${(m.time||'00:00')}:00`).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="teams">
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
teamId={m.home_id}
|
||||
teamName={m.home}
|
||||
facrLogo={m.home_logo_url}
|
||||
size="custom"
|
||||
alt={m.home}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div className="name">{sanitizeClubName(m.home)}</div>
|
||||
</div>
|
||||
<div className="score">
|
||||
{m.score ? (
|
||||
<>
|
||||
<span className="home">{String(m.score).split(':')[0]}</span>
|
||||
<span className="sep">:</span>
|
||||
<span className="away">{String(m.score).split(':')[1]}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="time">{m.time}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
teamId={m.away_id}
|
||||
teamName={m.away}
|
||||
facrLogo={m.away_logo_url}
|
||||
size="custom"
|
||||
alt={m.away}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div className="name">{sanitizeClubName(m.away)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="matches-tabs">
|
||||
{facrCompetitions.map((c, i) => (
|
||||
<button key={`${c.name}-${i}`} className={i===matchesTab ? 'active' : ''} onClick={() => setMatchesTab(i)}>{c.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<MatchesSlider
|
||||
comps={facrCompetitions as any}
|
||||
activeIndex={matchesTab}
|
||||
onActiveChange={setMatchesTab}
|
||||
onMatchClick={(m: any, compName?: string) => {
|
||||
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
|
||||
setIsMatchModalOpen(true);
|
||||
}}
|
||||
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* News + Tables: split into two independent sections */}
|
||||
@@ -1631,121 +1525,40 @@ const HomePage: React.FC = () => {
|
||||
style={{ marginTop: 32 }}
|
||||
>
|
||||
{showNews && (
|
||||
<section data-element="news" className="news-list" style={{ ...getStyles('news') }}>
|
||||
<section data-element="news" data-variant={getVariant('news', 'grid')} className="news-list" style={{ ...getStyles('news') }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Další aktuality</h3>
|
||||
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<div className="blog-list">
|
||||
{news.length > 0 ? news.slice(0, 4).map((n) => (
|
||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div>
|
||||
<h4>{n.title}</h4>
|
||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
|
||||
</div>
|
||||
</a>
|
||||
)) : (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
|
||||
<p>Zatím nejsou k dispozici žádné aktuality.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{news.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
|
||||
</div>
|
||||
)}
|
||||
<NewsList items={news as any} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showTable && (
|
||||
<div data-element="table" style={{ ...getStyles('table') }}>
|
||||
<div className="table-card">
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<div className="standings-table-wrapper" style={{ overflowX: 'auto' }}>
|
||||
<table className="standings-table-compact" style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 4px' }}>
|
||||
<thead>
|
||||
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>#</th>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>Tým</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>Z</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>V</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>R</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>P</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none' }} className="hide-mobile">Skóre</th>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600 }}>Body</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(matchingStanding?.table || matchingStanding?.rows || []).slice(0,8).map((row: any, idx: number) => {
|
||||
const handleClick = () => {
|
||||
const clubData = {
|
||||
team: row.team?.name ?? row.team ?? row.club ?? '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
|
||||
played: row.played ?? row.matches ?? '-',
|
||||
wins: row.wins ?? row.win ?? '-',
|
||||
draws: row.draws ?? row.draw ?? '-',
|
||||
losses: row.losses ?? row.loss ?? '-',
|
||||
score: row.score ?? '-',
|
||||
points: row.points ?? row.pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
||||
e.currentTarget.style.borderColor = 'var(--primary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = 'var(--card-border)';
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--primary)', fontSize: '0.9rem' }}>#{row.position ?? row.pos ?? row.rank ?? idx+1}</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
{row.team_logo_url && (
|
||||
<TeamLogo
|
||||
teamId={row.team_id}
|
||||
teamName={row.team?.name ?? row.team ?? row.club}
|
||||
facrLogo={row.team_logo_url}
|
||||
size="custom"
|
||||
alt={row.team?.name ?? row.team ?? row.club ?? '-'}
|
||||
style={{ width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', background: 'var(--bg-soft)', border: '1px solid var(--card-border)', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: '0.9rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.played ?? row.matches ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.wins ?? row.win ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.draws ?? row.draw ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.losses ?? row.loss ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)', display: 'none' }} className="hide-mobile">{row.score ?? '-'}</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 700, color: 'var(--secondary)', fontSize: '1rem' }}>{row.points ?? row.pts ?? '-'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<StandingsCard
|
||||
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
|
||||
onRowClick={(row) => {
|
||||
const clubData = {
|
||||
team: (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-',
|
||||
team_id: (row as any).team_id || '',
|
||||
team_logo_url: (row as any).team_logo_url,
|
||||
rank: (row as any).position ?? (row as any).pos ?? (row as any).rank ?? 0,
|
||||
played: (row as any).played ?? (row as any).matches ?? '-',
|
||||
wins: (row as any).wins ?? (row as any).win ?? '-',
|
||||
draws: (row as any).draws ?? (row as any).draw ?? '-',
|
||||
losses: (row as any).losses ?? (row as any).loss ?? '-',
|
||||
score: (row as any).score ?? '-',
|
||||
points: (row as any).points ?? (row as any).pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -1755,32 +1568,20 @@ const HomePage: React.FC = () => {
|
||||
{/* Competition tables moved into right column below */}
|
||||
|
||||
{upcomingEvents.length > 0 && isVisible('activities', true) && (
|
||||
<section data-element="activities" style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
|
||||
<section data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Aktivity</h3>
|
||||
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
</div>
|
||||
<div className="blog-list">
|
||||
{upcomingEvents.slice(0,4).map((e) => (
|
||||
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div>
|
||||
<h4>{e.title}</h4>
|
||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
|
||||
{new Date(e.start_time).toLocaleDateString()} {e.location ? `• ${e.location}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<ActivitiesList items={upcomingEvents as any} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Players scroller */}
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||
<div className="section-head">
|
||||
<h3>Hráči</h3>
|
||||
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
@@ -1799,7 +1600,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Gallery */}
|
||||
{isVisible('gallery', false) && (
|
||||
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<section data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<GallerySection zoneramaUrl={galleryUrl} />
|
||||
</div>
|
||||
@@ -1808,7 +1609,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Videos */}
|
||||
{isVisible('videos', false) && (
|
||||
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<section data-element="videos" data-variant={getVariant('videos', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<VideosSection />
|
||||
</div>
|
||||
@@ -1816,7 +1617,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{isVisible('merch', true) && (
|
||||
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<section data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<MerchSection />
|
||||
</div>
|
||||
@@ -1825,7 +1626,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Polls / Voting */}
|
||||
{isVisible('poll', false) && (
|
||||
<section data-element="poll" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
|
||||
<section data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
||||
</div>
|
||||
@@ -1834,7 +1635,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Banner: homepage_footer */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_footer') && (
|
||||
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
<section data-element="banner" data-variant={getVariant('banner', 'bottom')} className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
@@ -1846,7 +1647,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* CTA (Newsletter) moved up */}
|
||||
{isVisible('newsletter', false) && (
|
||||
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<section data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<NewsletterSubscribe />
|
||||
</div>
|
||||
|
||||
@@ -277,8 +277,9 @@ const MatchesPage: React.FC = () => {
|
||||
setAliasMap(amap);
|
||||
|
||||
// Build override helpers
|
||||
const teamLogoOverridesJSON = (teamLogoOverridesAPI && teamLogoOverridesAPI.by_name) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
|
||||
const teamLogoOverridesJSON = (teamLogoOverridesAPI && (teamLogoOverridesAPI.by_name || teamLogoOverridesAPI.by_id)) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
|
||||
const byName: Record<string, string> = (teamLogoOverridesJSON?.by_name || {}) as any;
|
||||
const byId: Record<string, { name?: string; logo_url?: string }> = (teamLogoOverridesJSON?.by_id || {}) as any;
|
||||
const normalize = (s: string) => String(s)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
@@ -321,9 +322,27 @@ const MatchesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const getOverrideLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||
// Prefer explicit admin override by ID
|
||||
if (teamId && byId?.[teamId]?.logo_url) {
|
||||
const v = byId[teamId]!.logo_url as string;
|
||||
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
||||
return v;
|
||||
}
|
||||
// Prefer local club logo for our own team
|
||||
try {
|
||||
if (
|
||||
teamId && settingsJSON?.club_id && String(teamId) === String(settingsJSON.club_id) && settingsJSON?.club_logo_url
|
||||
) {
|
||||
const local = settingsJSON.club_logo_url as string;
|
||||
if (typeof local === 'string' && local.startsWith('/')) return resolveBackendUrl(local);
|
||||
return local;
|
||||
}
|
||||
} catch {}
|
||||
// If we have a team ID and no explicit override, use logoapi as a high-quality source
|
||||
if (teamId) {
|
||||
return `http://logoapi.sportcreative.eu/logos/${teamId}`;
|
||||
}
|
||||
// Fallback to by-name overrides or original
|
||||
return getFallbackLogo(teamName, original);
|
||||
};
|
||||
|
||||
@@ -351,6 +370,8 @@ const MatchesPage: React.FC = () => {
|
||||
const [day, month, year] = (d || '').split('.');
|
||||
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
||||
const time = (t || '18:00').slice(0,5);
|
||||
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
|
||||
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
|
||||
|
||||
// Check if match is in the future - if so, ignore score
|
||||
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
|
||||
@@ -361,12 +382,12 @@ const MatchesPage: React.FC = () => {
|
||||
id: m.match_id || idx + 1,
|
||||
date: isoDate,
|
||||
time,
|
||||
home: m.home,
|
||||
away: m.away,
|
||||
home: homeName,
|
||||
away: awayName,
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
|
||||
home_logo_url: getOverrideLogo(homeName, m.home_id, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(awayName, m.away_id, m.away_logo_url),
|
||||
score: actualScore,
|
||||
facr_link: m.facr_link,
|
||||
report_url: m.report_url,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
@@ -15,6 +16,11 @@ import {
|
||||
AlertIcon,
|
||||
Card,
|
||||
CardBody,
|
||||
SimpleGrid,
|
||||
Badge,
|
||||
Divider,
|
||||
Spinner,
|
||||
Spacer,
|
||||
} from '@chakra-ui/react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -54,8 +60,9 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
blogs: true,
|
||||
matches: true,
|
||||
events: true,
|
||||
scores: false,
|
||||
...(data?.preferences || {} as SubscriberPreferences),
|
||||
scores: true,
|
||||
weekly: true,
|
||||
...(data?.preferences || ({} as SubscriberPreferences)),
|
||||
}), [data]);
|
||||
|
||||
const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs);
|
||||
@@ -117,7 +124,14 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Box maxW="640px" mx="auto" p={6}><Text>Načítání…</Text></Box>;
|
||||
return (
|
||||
<Container maxW="container.md" py={8}>
|
||||
<HStack>
|
||||
<Spinner />
|
||||
<Text>Načítání…</Text>
|
||||
</HStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
@@ -129,73 +143,103 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
}
|
||||
|
||||
|
||||
// helpers to toggle all main content types on/off
|
||||
const setAll = (on: boolean) => setPrefs({
|
||||
...prefs,
|
||||
blogs: on,
|
||||
matches: on,
|
||||
events: on,
|
||||
scores: on,
|
||||
weekly: on,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box maxW="720px" mx="auto" p={6}>
|
||||
<Heading size="lg" mb={2}>Nastavení newsletteru</Heading>
|
||||
<Text color="gray.600" mb={6}>Spravujte, jaké e-maily chcete dostávat na adresu {data.email}.</Text>
|
||||
<Container maxW="container.md" py={8}>
|
||||
<HStack mb={2} align="center">
|
||||
<Heading size="lg">Nastavení newsletteru</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={() => setAll(true)}>Zapnout vše</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(false)}>Vypnout vše</Button>
|
||||
</HStack>
|
||||
<HStack mb={6} color="gray.600">
|
||||
<Text>Spravujte, jaké e-maily chcete dostávat na adresu</Text>
|
||||
<Badge colorScheme={data.is_active ? 'green' : 'red'}>{data.email}</Badge>
|
||||
</HStack>
|
||||
|
||||
<Card mb={6}><CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Články (blog)</FormLabel>
|
||||
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
|
||||
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Události</FormLabel>
|
||||
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
|
||||
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Preferované soutěže</FormLabel>
|
||||
{Array.isArray(competitions) && competitions.length > 0 ? (
|
||||
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
{competitions.map((c: any, idx: number) => {
|
||||
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
|
||||
const name = (c?.name || c?.code || code) as string;
|
||||
const checked = selectedCodes.has(String(code));
|
||||
return (
|
||||
<HStack key={code} justify="space-between">
|
||||
<Text>{name}</Text>
|
||||
<Switch isChecked={checked} onChange={(e) => toggleCode(String(code), e.target.checked)} />
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
) : (
|
||||
<Input placeholder="např. 5LM, POH" value={(prefs.competitions as string) || ''} onChange={(e) => setPrefs({ ...prefs, competitions: e.target.value })} />
|
||||
)}
|
||||
<Text mt={2} fontSize="sm" color="gray.500">Pokud není seznam k dispozici, můžete zadat kódy soutěží ručně, oddělené čárkou.</Text>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</CardBody></Card>
|
||||
<Card mb={6}>
|
||||
<CardBody>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Články (blog)</FormLabel>
|
||||
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
|
||||
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Události</FormLabel>
|
||||
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
|
||||
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Týdenní souhrn (digest)</FormLabel>
|
||||
<Switch isChecked={!!(prefs as any).weekly} onChange={(e) => setPrefs({ ...prefs, weekly: e.target.checked })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() => saveMut.mutate()}
|
||||
<Divider />
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Preferované soutěže</FormLabel>
|
||||
{Array.isArray(competitions) && competitions.length > 0 ? (
|
||||
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
{competitions.map((c: any, idx: number) => {
|
||||
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
|
||||
const name = (c?.name || c?.code || code) as string;
|
||||
const checked = selectedCodes.has(String(code));
|
||||
return (
|
||||
<HStack key={code} justify="space-between">
|
||||
<Text>{name}</Text>
|
||||
<Switch isChecked={checked} onChange={(e) => toggleCode(String(code), e.target.checked)} />
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
) : (
|
||||
<Input placeholder="např. 5LM, POH" value={(prefs.competitions as string) || ''} onChange={(e) => setPrefs({ ...prefs, competitions: e.target.value })} />
|
||||
)}
|
||||
<Text mt={2} fontSize="sm" color="gray.500">Pokud není seznam k dispozici, můžete zadat kódy soutěží ručně, oddělené čárkou.</Text>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<HStack spacing={3} pt={2} borderTopWidth="1px">
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() => saveMut.mutate()}
|
||||
isLoading={saveMut.isLoading}
|
||||
data-umami-event="Save Preferences"
|
||||
>
|
||||
Uložit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ['newsletter', 'prefs', token] })}
|
||||
data-umami-event="Refresh Preferences"
|
||||
>
|
||||
Obnovit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={() => unsubMut.mutate()}
|
||||
<Spacer />
|
||||
<Button
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={() => unsubMut.mutate()}
|
||||
isLoading={unsubMut.isLoading}
|
||||
data-umami-event="Unsubscribe"
|
||||
data-umami-event-source="preferences"
|
||||
@@ -203,7 +247,7 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
Zrušit odběr
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Container, Heading, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPlayers } from '../services/public';
|
||||
import type { Player } from '../services/public';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
@@ -8,7 +9,7 @@ import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
|
||||
const PlayersPage: React.FC = () => {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
|
||||
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: getPlayers });
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
@@ -106,6 +106,7 @@ const SetupPage: React.FC = () => {
|
||||
const [smtpUser, setSmtpUser] = useState('');
|
||||
const [smtpPass, setSmtpPass] = useState('');
|
||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||
const [smtpUserEdited, setSmtpUserEdited] = useState(false);
|
||||
// Sender display name only; actual email is derived from smtpUser
|
||||
const [smtpFromName, setSmtpFromName] = useState('');
|
||||
const [smtpTLS, setSmtpTLS] = useState(true);
|
||||
@@ -192,10 +193,10 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
// Auto-fill SMTP username from contact email
|
||||
useEffect(() => {
|
||||
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
|
||||
if (contactEmail && isValidEmail(contactEmail) && !smtpUserEdited) {
|
||||
setSmtpUser(contactEmail);
|
||||
}
|
||||
}, [contactEmail, smtpUser]);
|
||||
}, [contactEmail, smtpUserEdited]);
|
||||
|
||||
const handleSelectClub = async (item: SearchResult) => {
|
||||
const clubIdValue = item.club_id || '';
|
||||
@@ -791,7 +792,7 @@ const SetupPage: React.FC = () => {
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUser && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
|
||||
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUserEdited && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
|
||||
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
@@ -816,7 +817,7 @@ const SetupPage: React.FC = () => {
|
||||
</FormControl>
|
||||
<FormControl mb={3}>
|
||||
<FormLabel>SMTP uživatelské jméno</FormLabel>
|
||||
<Input value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} />
|
||||
<Input value={smtpUser} onChange={(e) => { setSmtpUser(e.target.value); setSmtpUserEdited(true); }} />
|
||||
</FormControl>
|
||||
<FormControl mb={3}>
|
||||
<FormLabel>SMTP heslo</FormLabel>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Center, Spinner, Text, VStack } from '@chakra-ui/react';
|
||||
import { API_URL } from '../services/api';
|
||||
|
||||
const ShortRedirectPage: React.FC = () => {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
try {
|
||||
const api = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||
const backendOrigin = api.origin;
|
||||
const target = `${backendOrigin}/s/${code}`;
|
||||
window.location.replace(target);
|
||||
} catch {
|
||||
window.location.href = `/s/${code}`;
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<Center minH="60vh">
|
||||
<VStack spacing={3}>
|
||||
<Spinner />
|
||||
<Text>Přesměrování…</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortRedirectPage;
|
||||
@@ -141,15 +141,22 @@ const TablesPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{!!competitions.length && (
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
<Tabs variant="enclosed" 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: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
|
||||
}}>
|
||||
{competitions.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
|
||||
_hover={{ bg: 'rgba(0,0,0,0.04)' }}
|
||||
flex="0 0 auto"
|
||||
px={3}
|
||||
py={2}
|
||||
>
|
||||
{c.name}
|
||||
<Text as="span" noOfLines={1} maxW="300px" title={c.name}>{c.name}</Text>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
useColorModeValue,
|
||||
Image as ChakraImage,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit2, FiPlus, FiTrash2 } from 'react-icons/fi';
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiLink } from 'react-icons/fi';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Event } from '../../types/event';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
@@ -60,9 +60,10 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
|
||||
import { FiVideo, FiYoutube } from 'react-icons/fi';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { createShortLink } from '../../services/shortlinks';
|
||||
|
||||
const types: Array<{ value: Event['type']; label: string }> = [
|
||||
{ value: 'match', label: 'Zápas' },
|
||||
@@ -124,6 +125,13 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
});
|
||||
const events = data || [];
|
||||
|
||||
// Localized label for event type
|
||||
const typeLabel = (t?: string) => {
|
||||
const v = String(t || '').trim() as any;
|
||||
const found = types.find((x) => x.value === v);
|
||||
return found ? found.label : 'Jiné';
|
||||
};
|
||||
|
||||
// Load club YouTube videos
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -266,22 +274,18 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const e = editing || {};
|
||||
// Build a helpful Czech prompt including known fields
|
||||
const lines: string[] = [];
|
||||
const clubName = String(settingsQ?.data?.club_name || '').trim();
|
||||
if (clubName) lines.push(`Klub: ${clubName}`);
|
||||
if (e.type) lines.push(`Typ: ${e.type}`);
|
||||
if (e.location) lines.push(`Místo: ${e.location}`);
|
||||
if (e.start_time) {
|
||||
try { lines.push(`Začátek: ${new Date(e.start_time as any).toLocaleString('cs-CZ')}`); } catch {}
|
||||
}
|
||||
if (e.end_time) {
|
||||
try { lines.push(`Konec: ${new Date(e.end_time as any).toLocaleString('cs-CZ')}`); } catch {}
|
||||
}
|
||||
if (e.description) lines.push(`Poznámky: ${e.description}`);
|
||||
const base = lines.join('\n');
|
||||
const toneText = aiTone === 'informative' ? 'informativním a věcným stylem' : aiTone === 'formal' ? 'formálním a profesionálním stylem' : 'přátelským, pozitivním a lákavým stylem';
|
||||
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim();
|
||||
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti.\nDetaily:\n${base}`.trim();
|
||||
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
|
||||
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
|
||||
const { data } = await api.post('/ai/blog/generate', {
|
||||
prompt,
|
||||
audience: 'Fanoušci klubu, oznámení/pozvánka',
|
||||
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
|
||||
min_words: 120,
|
||||
});
|
||||
|
||||
@@ -485,7 +489,7 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
)}
|
||||
</Td>
|
||||
<Td>{ev.title}</Td>
|
||||
<Td>{ev.type}</Td>
|
||||
<Td>{typeLabel(ev.type as any)}</Td>
|
||||
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
|
||||
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
|
||||
<Td>{ev.location || '-'}</Td>
|
||||
@@ -494,6 +498,23 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<HStack>
|
||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
|
||||
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(ev.id)} />
|
||||
<IconButton
|
||||
aria-label="Zkrátit odkaz"
|
||||
size="sm"
|
||||
icon={<FiLink />}
|
||||
title="Zkrátit odkaz pro sdílení"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
const target = `${origin}/aktivita/${ev.id}`;
|
||||
const res = await createShortLink({ target_url: target, title: ev.title, source_type: 'event', source_id: ev.id as any });
|
||||
await navigator.clipboard.writeText(res.short_url);
|
||||
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -70,7 +70,8 @@ import {
|
||||
FiZap,
|
||||
FiTrendingUp,
|
||||
FiCalendar,
|
||||
FiSearch
|
||||
FiSearch,
|
||||
FiInfo
|
||||
} from 'react-icons/fi';
|
||||
|
||||
// Register ChartJS components
|
||||
@@ -188,6 +189,11 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
|
||||
name: 'Kliknutí na externí odkaz',
|
||||
source: 'Různé stránky',
|
||||
description: 'Uživatel klikl na odkaz vedoucí mimo web'
|
||||
},
|
||||
'Poll Vote': {
|
||||
name: 'Hlasování v anketě',
|
||||
source: 'Ankety',
|
||||
description: 'Uživatel hlasoval v anketě'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -213,6 +219,7 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
const [timeRange, setTimeRange] = useState('0'); // Default to "today"
|
||||
const [hasData, setHasData] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [noDataInfo, setNoDataInfo] = useState<string | null>(null);
|
||||
const [selectedCountry, setSelectedCountry] = useState<{
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -230,6 +237,7 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
const fetchAnalytics = async (days: string) => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
setNoDataInfo(null);
|
||||
try {
|
||||
const daysNum = parseInt(days);
|
||||
|
||||
@@ -292,14 +300,19 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
|
||||
setPageviewsData(pageviewsDataArray);
|
||||
|
||||
// Determine if we have data
|
||||
const hasPageviews = pageviewsDataArray.length > 0 && pageviewsDataArray.some(d => d.value > 0);
|
||||
const hasMetrics = pages.data?.length > 0 || countries.data?.length > 0;
|
||||
setHasData(hasAnyStats || hasPageviews || hasMetrics);
|
||||
|
||||
// Set error message if no data
|
||||
if (!hasAnyStats && !hasPageviews && !hasMetrics) {
|
||||
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
|
||||
const noData = !hasAnyStats && !hasPageviews && !hasMetrics;
|
||||
if (noData) {
|
||||
if (daysNum <= 1) {
|
||||
setNoDataInfo('Pro vybrané denní období zatím nebyla zaznamenána žádná návštěvnost. Zkuste později nebo zvolte delší období.');
|
||||
} else {
|
||||
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
|
||||
}
|
||||
} else {
|
||||
setNoDataInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch analytics:', error);
|
||||
@@ -478,6 +491,21 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* No data info for daily view */}
|
||||
{noDataInfo && (
|
||||
<Card bg="yellow.50" borderColor="yellow.300" borderWidth={2}>
|
||||
<CardBody>
|
||||
<HStack spacing={3} align="start">
|
||||
<Icon as={FiInfo} color="yellow.600" boxSize={6} mt={1} />
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontWeight="bold" color="yellow.800">Zatím žádná data</Text>
|
||||
<Text fontSize="sm" color="yellow.700">{noDataInfo}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
|
||||
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw } from 'react-icons/fi';
|
||||
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
|
||||
@@ -30,6 +30,7 @@ import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import { createShortLink } from '../../services/shortlinks';
|
||||
|
||||
// Inline small component to show match link badge in list (with short label)
|
||||
const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||
@@ -581,15 +582,31 @@ const ArticlesAdminPage = () => {
|
||||
return dateStr.includes(matchDateFilter);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by proximity to current date (recent matches first)
|
||||
const now = Date.now();
|
||||
const parseTime = (s?: string): number => {
|
||||
if (!s) return Number.MAX_SAFE_INTEGER;
|
||||
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/);
|
||||
if (m) {
|
||||
const d = parseInt(m[1], 10);
|
||||
const mo = parseInt(m[2], 10) - 1;
|
||||
const y = parseInt(m[3], 10);
|
||||
const h = m[4] ? parseInt(m[4], 10) : 0;
|
||||
const mi = m[5] ? parseInt(m[5], 10) : 0;
|
||||
return new Date(y, mo, d, h, mi).getTime();
|
||||
}
|
||||
const t = Date.parse(s);
|
||||
return isNaN(t) ? Number.MAX_SAFE_INTEGER : t;
|
||||
};
|
||||
opts = opts.sort((a, b) => {
|
||||
const dateA = new Date(a.date || 0).getTime();
|
||||
const dateB = new Date(b.date || 0).getTime();
|
||||
const diffA = Math.abs(now - dateA);
|
||||
const diffB = Math.abs(now - dateB);
|
||||
return diffA - diffB; // Closest to today first
|
||||
const ta = parseTime(a.date);
|
||||
const tb = parseTime(b.date);
|
||||
const da = ta - now;
|
||||
const db = tb - now;
|
||||
const aUpcoming = da >= 0;
|
||||
const bUpcoming = db >= 0;
|
||||
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
|
||||
if (aUpcoming) return da - db;
|
||||
return Math.abs(da) - Math.abs(db);
|
||||
});
|
||||
|
||||
return opts;
|
||||
@@ -1267,6 +1284,25 @@ const ArticlesAdminPage = () => {
|
||||
<HStack spacing={1}>
|
||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(a)} />
|
||||
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => handleDeleteArticle(a)} />
|
||||
<IconButton
|
||||
aria-label="Zkrátit odkaz"
|
||||
size="sm"
|
||||
icon={<FiLink />}
|
||||
title="Zkrátit odkaz pro sdílení"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
const slug = (a as any)?.slug || (a as any)?.Slug;
|
||||
const path = slug ? `/news/${slug}` : `/articles/${a.id}`;
|
||||
const target = `${origin}${path}`;
|
||||
const res = await createShortLink({ target_url: target, title: a.title, source_type: 'article', source_id: a.id });
|
||||
await navigator.clipboard.writeText(res.short_url);
|
||||
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -1299,8 +1335,8 @@ const ArticlesAdminPage = () => {
|
||||
<Tab>Základní</Tab>
|
||||
<Tab>Obsah</Tab>
|
||||
<Tab>Média</Tab>
|
||||
<Tab>SEO</Tab>
|
||||
<Tab>Anketa</Tab>
|
||||
<Tab>SEO</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{/* AI first */}
|
||||
@@ -1880,6 +1916,60 @@ const ArticlesAdminPage = () => {
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Anketa (Poll) Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
|
||||
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
|
||||
<Text fontSize="sm" color="gray.700" mb={3}>
|
||||
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{editing?.id ? (
|
||||
<PollLinker articleId={editing.id} onPollsChanged={() => {
|
||||
// Invalidate queries to refresh polls
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="semibold">
|
||||
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
|
||||
</Text>
|
||||
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
|
||||
{saveStatus === 'idle' && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
💡 Vyplňte název článku pro aktivaci automatického ukládání
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={async () => {
|
||||
// Force save if needed
|
||||
try {
|
||||
await forceSave();
|
||||
// Switch to poll tab after save
|
||||
setActiveTabIndex(4); // Poll tab is index 4 after reordering
|
||||
} catch (error) {
|
||||
// Error is handled by onSubmit
|
||||
}
|
||||
}}
|
||||
isLoading={createMut.isLoading}
|
||||
>
|
||||
Uložit jako koncept a přidat ankety
|
||||
</Button>
|
||||
</VStack>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* SEO last - minimized */}
|
||||
<TabPanel>
|
||||
<Text fontSize="sm" color="gray.600" mb={4}>
|
||||
@@ -1923,60 +2013,6 @@ const ArticlesAdminPage = () => {
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabPanel>
|
||||
|
||||
{/* Anketa (Poll) Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
|
||||
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
|
||||
<Text fontSize="sm" color="gray.700" mb={3}>
|
||||
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{editing?.id ? (
|
||||
<PollLinker articleId={editing.id} onPollsChanged={() => {
|
||||
// Invalidate queries to refresh polls
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="semibold">
|
||||
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
|
||||
</Text>
|
||||
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
|
||||
{saveStatus === 'idle' && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
💡 Vyplňte název článku pro aktivaci automatického ukládání
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={async () => {
|
||||
// Force save if needed
|
||||
try {
|
||||
await forceSave();
|
||||
// Switch to poll tab after save
|
||||
setActiveTabIndex(5); // Poll tab is index 5
|
||||
} catch (error) {
|
||||
// Error is handled by onSubmit
|
||||
}
|
||||
}}
|
||||
isLoading={createMut.isLoading}
|
||||
>
|
||||
Uložit jako koncept a přidat ankety
|
||||
</Button>
|
||||
</VStack>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
@@ -2097,7 +2133,7 @@ const ArticlesAdminPage = () => {
|
||||
</Modal>
|
||||
|
||||
{/* Zonerama Gallery Picker Modal */}
|
||||
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
|
||||
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="90vh">
|
||||
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
|
||||
@@ -2185,94 +2221,7 @@ const ArticlesAdminPage = () => {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Zonerama Gallery Picker Modal */}
|
||||
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="90vh">
|
||||
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY="auto">
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* Loading State */}
|
||||
{galleryLoading && (
|
||||
<HStack spacing={2} justify="center" py={8}>
|
||||
<Spinner size="lg" color="purple.500" />
|
||||
<Text color="gray.600">Načítám alba z galerie...</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* Albums Grid */}
|
||||
{!galleryLoading && cachedAlbums.length > 0 && (
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{cachedAlbums.map((album) => (
|
||||
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
||||
<Text fontSize="sm" color="gray.500">{album.date} • {album.photos.length} fotografií</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 3, md: 4, lg: 6 }} spacing={2}>
|
||||
{album.photos.map((photo) => (
|
||||
<Box
|
||||
key={photo.id}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{ boxShadow: 'lg', transform: 'scale(1.05)' }}
|
||||
onClick={() => {
|
||||
pickZoneramaImage({
|
||||
id: photo.id,
|
||||
album_id: album.id,
|
||||
album_url: `https://eu.zonerama.com/FKKofolaKrnov/Album/${album.id}`,
|
||||
page_url: photo.page_url,
|
||||
image_url: photo.image_1500,
|
||||
title: album.title
|
||||
});
|
||||
onGalleryPickerClose();
|
||||
}}
|
||||
>
|
||||
<AspectRatio ratio={1}>
|
||||
<Image
|
||||
src={photo.image_1500}
|
||||
alt={photo.id}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</AspectRatio>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!galleryLoading && cachedAlbums.length === 0 && (
|
||||
<VStack py={8} spacing={3}>
|
||||
<Icon as={FiSearch} boxSize={12} color="gray.400" />
|
||||
<Text color="gray.600" textAlign="center">
|
||||
Žádná alba nebyla nalezena v cache.
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||
Zkontrolujte nastavení Zonerama nebo obnovte cache.
|
||||
</Text>
|
||||
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>
|
||||
Obnovit seznam
|
||||
</Button>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={onGalleryPickerClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { parse } from 'date-fns';
|
||||
@@ -546,28 +547,24 @@ const MatchesAdminPage = () => {
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [showScrollHint, setShowScrollHint] = useState(true);
|
||||
const thBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// Drag-to-scroll state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const [lastX, setLastX] = useState(0);
|
||||
const [lastTime, setLastTime] = useState(0);
|
||||
const lastXRef = useRef(0);
|
||||
const lastTimeRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const scrollRaf = useRef<number | null>(null);
|
||||
|
||||
// Color modes for past/future matches
|
||||
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const futureMatchBg = useColorModeValue('white', 'gray.800');
|
||||
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const updateScrollShadow = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 0);
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
const left = el.scrollLeft > 0;
|
||||
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||||
if (left !== canScrollLeft) setCanScrollLeft(left);
|
||||
if (right !== canScrollRight) setCanScrollRight(right);
|
||||
};
|
||||
|
||||
// Drag-to-scroll handlers
|
||||
@@ -581,8 +578,8 @@ const MatchesAdminPage = () => {
|
||||
setIsDragging(true);
|
||||
setStartX(e.pageX - scrollRef.current.offsetLeft);
|
||||
setScrollLeft(scrollRef.current.scrollLeft);
|
||||
setLastX(e.pageX);
|
||||
setLastTime(Date.now());
|
||||
lastXRef.current = e.pageX;
|
||||
lastTimeRef.current = Date.now();
|
||||
velocityRef.current = 0;
|
||||
scrollRef.current.style.cursor = 'grabbing';
|
||||
scrollRef.current.style.userSelect = 'none';
|
||||
@@ -632,13 +629,13 @@ const MatchesAdminPage = () => {
|
||||
|
||||
// Calculate velocity for momentum
|
||||
const now = Date.now();
|
||||
const timeDelta = now - lastTime;
|
||||
const timeDelta = now - lastTimeRef.current;
|
||||
if (timeDelta > 0) {
|
||||
const currentX = e.pageX;
|
||||
const distance = currentX - lastX;
|
||||
const distance = currentX - lastXRef.current;
|
||||
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
|
||||
setLastX(currentX);
|
||||
setLastTime(now);
|
||||
lastXRef.current = currentX;
|
||||
lastTimeRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -653,8 +650,8 @@ const MatchesAdminPage = () => {
|
||||
setIsDragging(true);
|
||||
setStartX(touch.pageX - scrollRef.current.offsetLeft);
|
||||
setScrollLeft(scrollRef.current.scrollLeft);
|
||||
setLastX(touch.pageX);
|
||||
setLastTime(Date.now());
|
||||
lastXRef.current = touch.pageX;
|
||||
lastTimeRef.current = Date.now();
|
||||
velocityRef.current = 0;
|
||||
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
|
||||
};
|
||||
@@ -667,13 +664,13 @@ const MatchesAdminPage = () => {
|
||||
scrollRef.current.scrollLeft = scrollLeft - walk;
|
||||
|
||||
const now = Date.now();
|
||||
const timeDelta = now - lastTime;
|
||||
const timeDelta = now - lastTimeRef.current;
|
||||
if (timeDelta > 0) {
|
||||
const currentX = touch.pageX;
|
||||
const distance = currentX - lastX;
|
||||
const distance = currentX - lastXRef.current;
|
||||
velocityRef.current = distance / timeDelta * 16;
|
||||
setLastX(currentX);
|
||||
setLastTime(now);
|
||||
lastXRef.current = currentX;
|
||||
lastTimeRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -734,6 +731,12 @@ const MatchesAdminPage = () => {
|
||||
const headerText = useColorModeValue('text.onPrimary', 'white');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const edgeGradientLeft = useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)');
|
||||
const edgeGradientRight = useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)');
|
||||
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const futureMatchBg = useColorModeValue('white', 'gray.800');
|
||||
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
@@ -856,12 +859,24 @@ const MatchesAdminPage = () => {
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onScroll={(e) => {
|
||||
updateScrollShadow();
|
||||
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
||||
if (scrollRaf.current == null) {
|
||||
scrollRaf.current = requestAnimationFrame(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
updateScrollShadow();
|
||||
if (el.scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
||||
}
|
||||
scrollRaf.current = null;
|
||||
});
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollBehavior: 'smooth',
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'transform',
|
||||
overscrollBehaviorX: 'contain',
|
||||
touchAction: 'pan-x',
|
||||
'th, td': { whiteSpace: 'nowrap' },
|
||||
'::-webkit-scrollbar': { height: '14px' },
|
||||
'::-webkit-scrollbar-thumb': {
|
||||
@@ -885,13 +900,13 @@ const MatchesAdminPage = () => {
|
||||
{/* Gradient edges to indicate horizontal scroll */}
|
||||
{canScrollLeft && (
|
||||
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
||||
bgGradient={useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)')}
|
||||
bgGradient={edgeGradientLeft}
|
||||
zIndex={1}
|
||||
/>
|
||||
)}
|
||||
{canScrollRight && (
|
||||
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
||||
bgGradient={useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)')}
|
||||
bgGradient={edgeGradientRight}
|
||||
zIndex={1}
|
||||
/>
|
||||
)}
|
||||
@@ -945,6 +960,9 @@ const MatchesAdminPage = () => {
|
||||
alt={m.home || m.home_team || ''}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
@@ -962,6 +980,9 @@ const MatchesAdminPage = () => {
|
||||
alt={m.away || m.away_team || ''}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
@@ -1167,6 +1188,7 @@ const MatchesAdminPage = () => {
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -133,7 +133,77 @@ export default function NewsletterAdminPage() {
|
||||
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
||||
|
||||
type MailType = 'weekly' | 'matches' | 'scores' | 'blogs' | 'events';
|
||||
const mailTypeLabel: Record<MailType, string> = { weekly: 'Týdenní přehled', matches: 'Zápasy', scores: 'Výsledky', blogs: 'Novinky', events: 'Akce' };
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [activeType, setActiveType] = useState<MailType | null>(null);
|
||||
const [typePreview, setTypePreview] = useState<Record<string, { subject: string; html: string } | undefined>>({});
|
||||
const [detailsCompetitions, setDetailsCompetitions] = useState<string>('');
|
||||
const [detailsLoading, setDetailsLoading] = useState<boolean>(false);
|
||||
const [sendNowLoading, setSendNowLoading] = useState<boolean>(false);
|
||||
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
|
||||
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
|
||||
const recipientsForType = (t: MailType): string[] => {
|
||||
const key = t === 'weekly' ? 'weekly' : t;
|
||||
return subscribers
|
||||
.filter((s: any) => s.is_active && s?.preferences && s.preferences[key] === true)
|
||||
.map((s: any) => s.email);
|
||||
};
|
||||
const getRecipientsFor = (t: MailType, comps?: string): string[] => {
|
||||
const key = t === 'weekly' ? 'weekly' : t;
|
||||
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[key] === true);
|
||||
if (!comps || !comps.trim() || (t !== 'matches' && t !== 'scores')) {
|
||||
return base.map((s: any) => s.email);
|
||||
}
|
||||
const list = comps.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
|
||||
const filtered = base.filter((s: any) => {
|
||||
const prefs: any = s?.preferences || {};
|
||||
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions ? prefs.competitions : (typeof prefs.categories === 'string' ? prefs.categories : '');
|
||||
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
|
||||
if (arr.length === 0) return true;
|
||||
return arr.some((v: string) => list.includes(v));
|
||||
});
|
||||
return filtered.map((s: any) => s.email);
|
||||
};
|
||||
const exportRecipientsCSV = (t: MailType, comps?: string) => {
|
||||
const list = getRecipientsFor(t, comps);
|
||||
const safeCSV = (value: any) => {
|
||||
const s = String(value ?? '');
|
||||
return /^[=+\-@]/.test(s) ? `'${s}` : s;
|
||||
};
|
||||
const header = ['email','type','competitions'];
|
||||
const lines = [
|
||||
header.join(','),
|
||||
...list.map(e => [safeCSV(e), t, (comps || '').trim()].join(','))
|
||||
];
|
||||
const blob = new Blob(["\ufeff" + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `newsletter_recipients_${t}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const loadPreviewForType = async (t: MailType, comps?: string) => {
|
||||
const prefs: any = {};
|
||||
if (t === 'weekly') { prefs.blogs = true; prefs.events = true; prefs.matches = true; prefs.scores = true; }
|
||||
else { (prefs as any)[t] = true; }
|
||||
if (comps && comps.trim()) { prefs.competitions = comps.trim(); }
|
||||
const res = await previewNewsletter({ preferences: prefs });
|
||||
setTypePreview(prev => ({ ...prev, [t]: { subject: res.subject, html: res.html } }));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (detailsOpen && activeType && !typePreview[activeType]) {
|
||||
(async () => {
|
||||
try {
|
||||
setDetailsLoading(true);
|
||||
await loadPreviewForType(activeType!, detailsCompetitions);
|
||||
} finally {
|
||||
setDetailsLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [detailsOpen, activeType]);
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const testModal = useDisclosure();
|
||||
@@ -501,6 +571,29 @@ export default function NewsletterAdminPage() {
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
|
||||
<Heading size="md" mb={3}>Typy e‑mailů</Heading>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{(['weekly','matches','scores','blogs','events'] as MailType[]).map((t)=>{
|
||||
const count = recipientsForType(t).length;
|
||||
const enabled = t === 'weekly' ? !!settings?.enable_weekly : t === 'matches' ? !!settings?.enable_match_reminders : t === 'scores' ? !!settings?.enable_results : undefined;
|
||||
return (
|
||||
<Flex key={t} align="center" justify="space-between" p={3} borderWidth="1px" borderRadius="md" _hover={{ bg: hoverBg }}>
|
||||
<HStack spacing={3}>
|
||||
<Text fontWeight="600">{mailTypeLabel[t]}</Text>
|
||||
{enabled !== undefined && (
|
||||
<Badge colorScheme={enabled ? 'green' : 'gray'}>{enabled ? 'Zapnuto' : 'Vypnuto'}</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={4}>
|
||||
<Text color={textSecondary}>Příjemci: <b>{count}</b></Text>
|
||||
<Button size="sm" onClick={()=> openDetails(t)}>Detail</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
{/* Scheduling controls */}
|
||||
@@ -519,13 +612,13 @@ export default function NewsletterAdminPage() {
|
||||
<FormControl maxW="220px">
|
||||
<FormLabel>Den v týdnu</FormLabel>
|
||||
<Select value={weeklyDay} onChange={(e)=> setWeeklyDay(e.target.value as any)}>
|
||||
<option value="sun">Neděle</option>
|
||||
<option value="mon">Pondělí</option>
|
||||
<option value="tue">Úterý</option>
|
||||
<option value="wed">Středa</option>
|
||||
<option value="thu">Čtvrtek</option>
|
||||
<option value="fri">Pátek</option>
|
||||
<option value="sat">Sobota</option>
|
||||
<option value="sun">Neděle</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl maxW="160px">
|
||||
@@ -534,6 +627,41 @@ export default function NewsletterAdminPage() {
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="600">Připomínky zápasů</Text>
|
||||
<Switch isChecked={enableMatchReminders} onChange={(e)=> setEnableMatchReminders(e.target.checked)} />
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl maxW="220px">
|
||||
<FormLabel>Odeslat před (hodin)</FormLabel>
|
||||
<Input type="number" min={1} max={168} value={reminderLead} onChange={(e)=> setReminderLead(Math.max(1, Math.min(168, Number(e.target.value)||0)))} />
|
||||
<FormHelperText>Výchozí 48 h před výkopem. Systém posílá i upozornění v den zápasu.</FormHelperText>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="600">Výsledky po zápase</Text>
|
||||
<Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} />
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl maxW="160px">
|
||||
<FormLabel>Tiché hodiny od</FormLabel>
|
||||
<Input type="number" min={0} max={23} value={quietStart} onChange={(e)=> setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
|
||||
</FormControl>
|
||||
<FormControl maxW="160px">
|
||||
<FormLabel>Tiché hodiny do</FormLabel>
|
||||
<Input type="number" min={0} max={23} value={quietEnd} onChange={(e)=> setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
|
||||
<FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
<HStack pt={2}>
|
||||
<Button colorScheme="blue" onClick={()=> saveScheduleMutation.mutate()} isLoading={saveScheduleMutation.isLoading}>Uložit plánování</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
@@ -871,6 +999,159 @@ export default function NewsletterAdminPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={detailsOpen} onClose={closeDetails} size="5xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="95vw" maxH="90vh" overflowY="auto">
|
||||
<ModalHeader>
|
||||
{activeType ? `Detail: ${mailTypeLabel[activeType]}` : 'Detail'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack spacing={4} align="flex-end">
|
||||
<FormControl maxW="360px">
|
||||
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
|
||||
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
|
||||
</FormControl>
|
||||
<Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button>
|
||||
{activeType && typePreview[activeType]?.subject && (
|
||||
<Badge colorScheme="blue">{typePreview[activeType]!.subject}</Badge>
|
||||
)}
|
||||
<Button colorScheme="blue" variant="solid" isLoading={sendNowLoading} onClick={async()=>{
|
||||
if(!activeType) return;
|
||||
const ok = window.confirm(`Odeslat "${mailTypeLabel[activeType]}" nyní? E‑mail bude odeslán všem aktivním odběratelům.`);
|
||||
if(!ok) return;
|
||||
try {
|
||||
setSendNowLoading(true);
|
||||
await sendNewsletterDigest(activeType as DigestType, (detailsCompetitions || '').trim() || undefined);
|
||||
toast({ title: 'Digest odeslán', status: 'success' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba při odeslání', description: e?.response?.data?.error || e?.message, status: 'error' });
|
||||
} finally {
|
||||
setSendNowLoading(false);
|
||||
}
|
||||
}}>Odeslat nyní</Button>
|
||||
<Button variant="outline" onClick={()=>{ if(!activeType) return; exportRecipientsCSV(activeType, detailsCompetitions); }}>Export CSV</Button>
|
||||
</HStack>
|
||||
<Box p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
|
||||
<Box bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Příjemci</Heading>
|
||||
{(() => {
|
||||
let list: string[] = [];
|
||||
if (activeType) {
|
||||
const typeKey = activeType === 'weekly' ? 'weekly' : activeType;
|
||||
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[typeKey] === true);
|
||||
let filtered = base;
|
||||
const compsInput = (detailsCompetitions || '').trim();
|
||||
if (compsInput && (activeType === 'matches' || activeType === 'scores')) {
|
||||
const comps = compsInput.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
|
||||
if (comps.length > 0) {
|
||||
filtered = base.filter((s: any) => {
|
||||
const prefs: any = s?.preferences || {};
|
||||
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions
|
||||
? prefs.competitions
|
||||
: (typeof prefs.categories === 'string' ? prefs.categories : '');
|
||||
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
|
||||
if (arr.length === 0) return true;
|
||||
return arr.some((v: string) => comps.includes(v));
|
||||
});
|
||||
}
|
||||
}
|
||||
list = filtered.map((s: any) => s.email);
|
||||
}
|
||||
const shown = list.slice(0, 50);
|
||||
return (
|
||||
<>
|
||||
{shown.length === 0 ? (
|
||||
<Text color="gray.600">Žádní příjemci pro tento typ.</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={1} maxH="240px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
{shown.map((e)=> (<Text key={e} fontFamily="mono">{e}</Text>))}
|
||||
{list.length > shown.length && (
|
||||
<Text color="gray.600">… a dalších {list.length - shown.length}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={closeDetails}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* SMTP Test Modal */}
|
||||
<Modal isOpen={smtpModal.isOpen} onClose={smtpModal.onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
|
||||
<ModalHeader>Otestovat SMTP</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<HStack spacing={3} align="flex-end">
|
||||
<FormControl>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<Input placeholder="smtp.example.com" value={smtpHost} onChange={(e)=> setSmtpHost(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl maxW="140px">
|
||||
<FormLabel>Port</FormLabel>
|
||||
<Input type="number" placeholder="465" value={smtpPort} onChange={(e)=> setSmtpPort(Number(e.target.value)||0)} />
|
||||
</FormControl>
|
||||
<FormControl maxW="140px">
|
||||
<FormLabel> </FormLabel>
|
||||
<Checkbox isChecked={smtpTLS} onChange={(e)=> setSmtpTLS(e.target.checked)}>TLS/SSL</Checkbox>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Uživatel</FormLabel>
|
||||
<Input value={smtpUser} onChange={(e)=> setSmtpUser(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Heslo</FormLabel>
|
||||
<InputGroup>
|
||||
<Input type={showSmtpPass ? 'text' : 'password'} value={smtpPass} onChange={(e)=> setSmtpPass(e.target.value)} />
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={()=> setShowSmtpPass(v=> !v)}>
|
||||
{showSmtpPass ? 'Skrýt' : 'Zobrazit'}
|
||||
</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>From</FormLabel>
|
||||
<Input placeholder="club@example.com" value={smtpFrom} onChange={(e)=> setSmtpFrom(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>To (kam poslat test)</FormLabel>
|
||||
<Input placeholder="you@example.com" value={smtpTo} onChange={(e)=> setSmtpTo(e.target.value)} />
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Předmět</FormLabel>
|
||||
<Input value={smtpSubject} onChange={(e)=> setSmtpSubject(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Tělo zprávy (HTML)</FormLabel>
|
||||
<Textarea rows={6} value={smtpBody} onChange={(e)=> setSmtpBody(e.target.value)} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={smtpModal.onClose}>Zavřít</Button>
|
||||
<Button colorScheme="blue" onClick={()=> adminSmtpTestMutation.mutate()} isLoading={adminSmtpTestMutation.isLoading}>Odeslat test</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Test Email Modal */}
|
||||
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
|
||||
<ModalOverlay />
|
||||
|
||||
@@ -235,7 +235,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
const HEIGHT_MIN = 0;
|
||||
const HEIGHT_MAX = 250;
|
||||
const WEIGHT_MIN = 0;
|
||||
const WEIGHT_MAX = 200;
|
||||
const WEIGHT_MAX = 400;
|
||||
|
||||
// Local state to persist partial DOB selections so the user sees what they picked
|
||||
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
|
||||
@@ -325,6 +325,11 @@ const PlayersAdminPage: React.FC = () => {
|
||||
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
|
||||
return;
|
||||
}
|
||||
// Require date of birth: all three values must be selected
|
||||
if (!dobParts.day || !dobParts.month || !dobParts.year) {
|
||||
toast({ title: 'Datum narození je povinné', description: 'Vyberte den, měsíc i rok.', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
// Build payload by including only present values to satisfy backend validation
|
||||
const payload: any = {
|
||||
first_name: fn,
|
||||
@@ -428,19 +433,19 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</FormControl>
|
||||
|
||||
{/* Custom DOB picker: day / month / year (timezone-safe) */}
|
||||
<FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Datum narození</FormLabel>
|
||||
<HStack>
|
||||
<Select value={dobParts.day} onChange={(e) => updateDobPart('day', e.target.value)}>
|
||||
<option value="">Den</option>
|
||||
<option value="" disabled>Den</option>
|
||||
{Array.from({ length: 31 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
|
||||
</Select>
|
||||
<Select value={dobParts.month} onChange={(e) => updateDobPart('month', e.target.value)}>
|
||||
<option value="">Měsíc</option>
|
||||
<option value="" disabled>Měsíc</option>
|
||||
{Array.from({ length: 12 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
|
||||
</Select>
|
||||
<Select value={dobParts.year} onChange={(e) => updateDobPart('year', e.target.value)}>
|
||||
<option value="">Rok</option>
|
||||
<option value="" disabled>Rok</option>
|
||||
{Array.from({ length: 80 }).map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={String(y)}>{y}</option>; })}
|
||||
</Select>
|
||||
</HStack>
|
||||
@@ -542,7 +547,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
<FormControl>
|
||||
<FormLabel>Fotka</FormLabel>
|
||||
<HStack>
|
||||
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" />
|
||||
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
|
||||
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
|
||||
<Input
|
||||
type="file"
|
||||
|
||||
@@ -309,16 +309,19 @@ const PollsAdminPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Validate that all options have text
|
||||
const invalidOptions = formData.options.filter(opt => !opt.text || opt.text.trim() === '');
|
||||
if (invalidOptions.length > 0) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Všechny možnosti musí mít vyplněný text',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
if (formData.type !== 'rating') {
|
||||
const invalidOptions = formData.options.filter(
|
||||
(opt) => !opt.text || opt.text.trim() === ''
|
||||
);
|
||||
if (invalidOptions.length > 0) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Všechny možnosti musí mít vyplněný text',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingPoll) {
|
||||
@@ -398,6 +401,35 @@ const PollsAdminPage: React.FC = () => {
|
||||
}
|
||||
}, [isOpen, clubVideos.length, toast]);
|
||||
|
||||
// Keep rating polls consistent: enforce style and auto-generate options
|
||||
useEffect(() => {
|
||||
if (formData.type !== 'rating') return;
|
||||
const currentStyle = (formData as any).style || 'auto';
|
||||
const desiredStyle = currentStyle === 'rating-scale' ? 'rating-scale' : 'rating-stars';
|
||||
const count = desiredStyle === 'rating-scale' ? 10 : 5;
|
||||
const optionsMatch =
|
||||
formData.options.length === count &&
|
||||
formData.options.every((opt, idx) => String(opt.text) === String(idx + 1));
|
||||
|
||||
if (
|
||||
currentStyle !== desiredStyle ||
|
||||
formData.allow_multiple ||
|
||||
(formData.max_choices || 1) !== 1 ||
|
||||
!optionsMatch
|
||||
) {
|
||||
setFormData({
|
||||
...formData,
|
||||
style: desiredStyle as any,
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: count }).map((_, i) => ({
|
||||
text: String(i + 1),
|
||||
display_order: i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [formData.type, (formData as any).style, formData.options, formData.allow_multiple, formData.max_choices]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
@@ -542,7 +574,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Základní</Tab>
|
||||
<Tab>Možnosti</Tab>
|
||||
{formData.type !== 'rating' && <Tab>Možnosti</Tab>}
|
||||
<Tab>Nastavení</Tab>
|
||||
</TabList>
|
||||
|
||||
@@ -550,6 +582,14 @@ const PollsAdminPage: React.FC = () => {
|
||||
{/* Basic Info Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4}>
|
||||
<HStack w="full" justify="space-between">
|
||||
<Text fontWeight="semibold">Doporučené předvolby</Text>
|
||||
<HStack>
|
||||
<Button size="sm" onClick={() => applyPreset('rating5')}>Hodnocení (5 hvězd)</Button>
|
||||
<Button size="sm" onClick={() => applyPreset('rating10')}>Hodnocení (1–10)</Button>
|
||||
<Button size="sm" onClick={() => applyPreset('attendance')}>Docházka</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název ankety</FormLabel>
|
||||
<Input
|
||||
@@ -625,6 +665,17 @@ const PollsAdminPage: React.FC = () => {
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
{formData.type === 'rating' && (
|
||||
<Box w="full" borderWidth="1px" borderRadius="md" p={3} bg="gray.50">
|
||||
<Text fontSize="sm" mb={2}>Možnosti se generují automaticky podle stylu:</Text>
|
||||
<Text fontSize="sm">
|
||||
{Array.from({ length: ((formData as any).style === 'rating-scale' ? 10 : 5) })
|
||||
.map((_, i) => String(i + 1))
|
||||
.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SimpleGrid columns={2} spacing={4} w="full">
|
||||
<FormControl>
|
||||
<FormLabel>Datum zahájení</FormLabel>
|
||||
@@ -722,73 +773,77 @@ const PollsAdminPage: React.FC = () => {
|
||||
</TabPanel>
|
||||
|
||||
{/* Options Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{formData.options.map((option, index) => (
|
||||
<Card key={index}>
|
||||
<CardBody>
|
||||
<HStack align="start">
|
||||
<VStack flex={1} spacing={3}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Možnost {index + 1}</FormLabel>
|
||||
<Input
|
||||
value={option.text}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'text', e.target.value)
|
||||
}
|
||||
placeholder="Text možnosti"
|
||||
{formData.type !== 'rating' && (
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{formData.options.map((option, index) => (
|
||||
<Card key={index}>
|
||||
<CardBody>
|
||||
<HStack align="start">
|
||||
<VStack flex={1} spacing={3}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Možnost {index + 1}</FormLabel>
|
||||
<Input
|
||||
value={option.text}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'text', e.target.value)
|
||||
}
|
||||
placeholder="Text možnosti"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis (volitelné)</FormLabel>
|
||||
<Input
|
||||
value={option.description || ''}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'description', e.target.value)
|
||||
}
|
||||
placeholder="Doplňující informace"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
{formData.options.length > 2 && (
|
||||
<IconButton
|
||||
aria-label="Odstranit možnost"
|
||||
icon={<DeleteIcon />}
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => removeOption(index)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis (volitelné)</FormLabel>
|
||||
<Input
|
||||
value={option.description || ''}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'description', e.target.value)
|
||||
}
|
||||
placeholder="Doplňující informace"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
{formData.options.length > 2 && (
|
||||
<IconButton
|
||||
aria-label="Odstranit možnost"
|
||||
icon={<DeleteIcon />}
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => removeOption(index)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
leftIcon={<AddIcon />}
|
||||
onClick={addOption}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
>
|
||||
Přidat možnost
|
||||
</Button>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
<Button
|
||||
leftIcon={<AddIcon />}
|
||||
onClick={addOption}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
>
|
||||
Přidat možnost
|
||||
</Button>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
{/* Settings Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4}>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Povolit více voleb</FormLabel>
|
||||
<Switch
|
||||
isChecked={formData.allow_multiple}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, allow_multiple: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{formData.type !== 'rating' && (
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Povolit více voleb</FormLabel>
|
||||
<Switch
|
||||
isChecked={formData.allow_multiple}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, allow_multiple: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{formData.allow_multiple && (
|
||||
{formData.type !== 'rating' && formData.allow_multiple && (
|
||||
<FormControl>
|
||||
<FormLabel>Max. počet voleb</FormLabel>
|
||||
<NumberInput
|
||||
|
||||
@@ -178,7 +178,7 @@ const SettingsAdminPage: React.FC = () => {
|
||||
smtp_from: (settings as any).smtp_from,
|
||||
smtp_from_name: (settings as any).smtp_from_name,
|
||||
smtp_encryption: (settings as any).smtp_encryption as any,
|
||||
smtp_auth: (settings as any).smtp_auth as any,
|
||||
...(typeof (settings as any).smtp_auth === 'boolean' ? { smtp_auth: (settings as any).smtp_auth as any } : {}),
|
||||
smtp_skip_verify: (settings as any).smtp_skip_verify as any,
|
||||
// videos module
|
||||
videos_module_enabled: (settings as any).videos_module_enabled as any,
|
||||
@@ -193,8 +193,9 @@ const SettingsAdminPage: React.FC = () => {
|
||||
location_latitude: (settings as any).location_latitude as any,
|
||||
location_longitude: (settings as any).location_longitude as any,
|
||||
map_zoom_level: (settings as any).map_zoom_level as any,
|
||||
// Auto-enable map display if coordinates are set
|
||||
show_map_on_homepage: ((settings as any).location_latitude && (settings as any).location_longitude) as any,
|
||||
show_map_on_homepage:
|
||||
(typeof (settings as any).location_latitude === 'number') &&
|
||||
(typeof (settings as any).location_longitude === 'number'),
|
||||
map_style: (settings as any).map_style,
|
||||
// homepage matches display
|
||||
finished_match_display_days: (settings as any).finished_match_display_days as any,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useToast,
|
||||
Text,
|
||||
VStack,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
Link as ChakraLink,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
|
||||
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
|
||||
|
||||
const ShortlinksAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [targetUrl, setTargetUrl] = React.useState('');
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [code, setCode] = React.useState('');
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const statsModal = useDisclosure();
|
||||
const [statsLink, setStatsLink] = React.useState<any>(null);
|
||||
const [statsData, setStatsData] = React.useState<any>(null);
|
||||
|
||||
const linksQ = useQuery({
|
||||
queryKey: ['admin-shortlinks'],
|
||||
queryFn: listShortLinks,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
const t = targetUrl.trim();
|
||||
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
|
||||
try {
|
||||
setCreating(true);
|
||||
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: code.trim() || undefined, active: true });
|
||||
await navigator.clipboard.writeText(res.short_url);
|
||||
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
|
||||
setTargetUrl(''); setTitle(''); setCode('');
|
||||
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Vytvoření selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openStats = async (item: any) => {
|
||||
try {
|
||||
setStatsLink(item);
|
||||
setStatsData(null);
|
||||
statsModal.onOpen();
|
||||
const data = await getShortLinkStats(item.id);
|
||||
setStatsData(data);
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Načtení statistik selhalo', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
|
||||
<IconButton aria-label="Obnovit" icon={<FiRefreshCcw />} onClick={() => qc.invalidateQueries({ queryKey: ['admin-shortlinks'] })} />
|
||||
</HStack>
|
||||
|
||||
{/* Create form */}
|
||||
<Box borderWidth="1px" borderRadius="lg" p={4} mb={6} bg="bg.card">
|
||||
<Text fontWeight="semibold" mb={2}>Vytvořit nový odkaz</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Input placeholder="https://…" value={targetUrl} onChange={(e)=>setTargetUrl(e.target.value)} flex={3} />
|
||||
<Input placeholder="Titulek (volitelný)" value={title} onChange={(e)=>setTitle(e.target.value)} flex={2} />
|
||||
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} />
|
||||
<Button onClick={handleCreate} isLoading={creating} colorScheme="blue">Vytvořit</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* List */}
|
||||
<Box borderWidth="1px" borderRadius="lg" overflowX="auto" bg="bg.card">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Kód</Th>
|
||||
<Th>Cíl</Th>
|
||||
<Th>Titulek</Th>
|
||||
<Th>Zdroj</Th>
|
||||
<Th>Prokliky</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{linksQ.data?.items?.map((it: any) => {
|
||||
const shortUrl = `${window.location.origin}/s/${it.code}`;
|
||||
const source = it.source_type ? `${it.source_type}${it.source_id ? `#${it.source_id}` : ''}` : '-';
|
||||
return (
|
||||
<Tr key={it.id}>
|
||||
<Td><Badge colorScheme="blue">{it.code}</Badge></Td>
|
||||
<Td maxW="420px">
|
||||
<ChakraLink href={it.target_url} isExternal color="blue.600">{it.target_url}</ChakraLink>
|
||||
</Td>
|
||||
<Td>{it.title || '-'}</Td>
|
||||
<Td>{source}</Td>
|
||||
<Td>{it.click_count ?? 0}</Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
|
||||
<IconButton aria-label="Zkopírovat" icon={<FiClipboard />} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
|
||||
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{(!linksQ.data?.items || linksQ.data.items.length === 0) && (
|
||||
<Tr><Td colSpan={6}><Text p={3}>Žádné odkazy</Text></Td></Tr>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Stats modal */}
|
||||
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Statistiky: {statsLink?.code}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{!statsData ? (
|
||||
<Text>Načítání…</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1}>Prokliky za posledních 30 dní</Text>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead><Tr><Th>Den</Th><Th isNumeric>Počet</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{statsData.timeseries?.map((row: any, idx: number) => (
|
||||
<Tr key={idx}><Td>{row.date}</Td><Td isNumeric>{row.count}</Td></Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1}>Referrers (Top)</Text>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead><Tr><Th>Referrer</Th><Th isNumeric>Počet</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{(statsData.referrers || []).map((r: any, i: number) => (
|
||||
<Tr key={i}><Td>{r.Referrer || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1}>UTM kombinace (Top)</Text>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead><Tr><Th>Source</Th><Th>Medium</Th><Th>Campaign</Th><Th isNumeric>Počet</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{(statsData.utms || []).map((r: any, i: number) => (
|
||||
<Tr key={i}><Td>{r.Source || '-'}</Td><Td>{r.Medium || '-'}</Td><Td>{r.Campaign || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={statsModal.onClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortlinksAdminPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||||
import { fetchLogoFromLogoAPI } from '../../utils/sportLogosAPI';
|
||||
import {
|
||||
Heading,
|
||||
Text,
|
||||
@@ -77,6 +78,8 @@ const TeamsAdminPage = () => {
|
||||
});
|
||||
|
||||
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
|
||||
const mainClubId: string | undefined = (data?.club_id ? String(data.club_id).toLowerCase() : undefined);
|
||||
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
|
||||
// Backend origin (used to resolve relative URLs like /uploads/...)
|
||||
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
|
||||
@@ -86,6 +89,7 @@ const TeamsAdminPage = () => {
|
||||
queryFn: fetchTeamLogoOverrides,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||
|
||||
// Fetch logos from logoapi.sportcreative.eu for all teams
|
||||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||||
@@ -100,6 +104,10 @@ const TeamsAdminPage = () => {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
for (const r of rows) {
|
||||
if (r.team_id) teamIds.add(r.team_id);
|
||||
else {
|
||||
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
if (derived) teamIds.add(derived);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +130,7 @@ const TeamsAdminPage = () => {
|
||||
// Unify various dash characters to a simple hyphen
|
||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||||
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
|
||||
out = out.replace(/[,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
||||
const orgPhrases = [
|
||||
'fotbalovy klub',
|
||||
@@ -133,7 +141,7 @@ const TeamsAdminPage = () => {
|
||||
'futsal',
|
||||
];
|
||||
for (const phrase of orgPhrases) {
|
||||
const re = new RegExp(`(^|\b)${phrase}(\b|$)`, 'g');
|
||||
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
|
||||
@@ -152,40 +160,61 @@ const TeamsAdminPage = () => {
|
||||
}
|
||||
return idx;
|
||||
}, [byName]);
|
||||
|
||||
// Derive FACR team UUID from the logo URL if team_id is missing in the row
|
||||
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
|
||||
const deriveTeamIdFromLogoUrl = (url?: string): string | undefined => {
|
||||
try {
|
||||
const u = String(url || '');
|
||||
if (!u) return undefined;
|
||||
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
|
||||
return m ? m[0].toLowerCase() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||
|
||||
// Priority 1: Try logoapi.sportcreative.eu if we have a team ID
|
||||
if (teamId && sportLogosMap[teamId]) {
|
||||
return sportLogosMap[teamId];
|
||||
// Priority 0: Admin override by team ID
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
|
||||
const u = String(overridesById[teamId].logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
|
||||
// Priority 2: Try exact match from local overrides
|
||||
// Priority 1: Local admin override (exact + normalized)
|
||||
let overrideUrl = byName[teamName];
|
||||
if (!overrideUrl) {
|
||||
// Fallback: diacritics-insensitive + case-insensitive + trimmed match
|
||||
const norm = normalize(teamName);
|
||||
overrideUrl = byNameNormalized[norm];
|
||||
}
|
||||
|
||||
// Priority 3: Use override if found
|
||||
if (overrideUrl) {
|
||||
// Resolve against backend for relative assets
|
||||
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
|
||||
return assetUrl(overrideUrl) as string;
|
||||
}
|
||||
return overrideUrl;
|
||||
}
|
||||
|
||||
// Priority 4: Use FACR original
|
||||
|
||||
// Priority 2: logoapi.sportcreative.eu if we have a team ID
|
||||
if (teamId && sportLogosMap[teamId]) {
|
||||
return sportLogosMap[teamId];
|
||||
}
|
||||
|
||||
// Priority 3: FACR original
|
||||
if (original) {
|
||||
return original;
|
||||
}
|
||||
|
||||
|
||||
// Final fallback: empty logo
|
||||
return '/dist/img/logo-club-empty.svg';
|
||||
};
|
||||
|
||||
const getName = (teamName?: string, teamId?: string) => {
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
|
||||
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
|
||||
}
|
||||
return String(teamName || '');
|
||||
};
|
||||
|
||||
// View mode: 'table' per competition, or 'grid' of unique teams across competitions
|
||||
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
|
||||
// Selected competition for quick switching (only applies in table mode)
|
||||
@@ -204,32 +233,50 @@ const TeamsAdminPage = () => {
|
||||
name: string; // representative name
|
||||
logo: string;
|
||||
variants: string[]; // all raw names found
|
||||
teamId?: string;
|
||||
};
|
||||
const allTeamsUnique: TeamAggregate[] = useMemo(() => {
|
||||
const map: Record<string, TeamAggregate> = {};
|
||||
for (const comp of competitions) {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
for (const r of rows) {
|
||||
const teamName = (r.team || '').trim();
|
||||
if (!teamName) continue;
|
||||
const key = normalize(teamName);
|
||||
const logo = getLogo(teamName, r.team_id, r.team_logo_url);
|
||||
const rawName = (r.team || '').trim();
|
||||
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
if (!teamId && mainClubId) {
|
||||
const rn = normalize(rawName);
|
||||
if (
|
||||
rn === mainClubBase ||
|
||||
rn.endsWith(' ' + mainClubBase) ||
|
||||
rn.startsWith(mainClubBase + ' ') ||
|
||||
rn.includes(' ' + mainClubBase + ' ')
|
||||
) {
|
||||
teamId = mainClubId;
|
||||
}
|
||||
}
|
||||
const canonicalName = getName(rawName, teamId);
|
||||
if (!canonicalName) continue;
|
||||
const key = teamId ? `id:${teamId}` : normalize(canonicalName);
|
||||
const logo = getLogo(canonicalName, teamId, r.team_logo_url);
|
||||
if (!map[key]) {
|
||||
map[key] = { key, name: teamName, logo, variants: [teamName] };
|
||||
map[key] = { key, name: canonicalName, logo, variants: [rawName, canonicalName], teamId };
|
||||
} else {
|
||||
map[key].variants.push(teamName);
|
||||
map[key].variants.push(rawName);
|
||||
map[key].variants.push(canonicalName);
|
||||
// Update logo - prefer non-empty logos
|
||||
const currentIsEmpty = !map[key].logo || /logo-club-empty\.svg$/.test(String(map[key].logo));
|
||||
const newIsNotEmpty = logo && !/logo-club-empty\.svg$/.test(String(logo));
|
||||
if (currentIsEmpty && newIsNotEmpty) {
|
||||
map[key].logo = logo as string;
|
||||
}
|
||||
if (!map[key].teamId && teamId) {
|
||||
map[key].teamId = teamId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by representative name
|
||||
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
|
||||
}, [competitions, getLogo]);
|
||||
}, [competitions, getLogo, mainClubBase, mainClubId]);
|
||||
|
||||
// Fast lookup from normalized name to variant list
|
||||
const variantsByKey = useMemo(() => {
|
||||
@@ -253,6 +300,25 @@ const TeamsAdminPage = () => {
|
||||
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
||||
const [externalUploadError, setExternalUploadError] = useState<string | null>(null);
|
||||
|
||||
const showExternalUploadInfo = useMemo(() => {
|
||||
try {
|
||||
if (uploadedFile) return true;
|
||||
const raw = (form.logo_url || '').trim();
|
||||
if (!raw) return false;
|
||||
const abs = raw.startsWith('/') ? new URL(raw, backendOrigin).toString() : raw;
|
||||
const u = new URL(abs);
|
||||
const host = u.hostname.toLowerCase();
|
||||
const path = u.pathname;
|
||||
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
|
||||
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
|
||||
const isLogoAPI = host === 'logoapi.sportcreative.eu';
|
||||
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
|
||||
return !isFacr && !isLogoAPI && isLocalUpload;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [uploadedFile, form.logo_url, backendOrigin]);
|
||||
|
||||
// Club search
|
||||
const [query, setQuery] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
@@ -266,7 +332,7 @@ const TeamsAdminPage = () => {
|
||||
enabled: debounced.trim().length >= 2,
|
||||
});
|
||||
|
||||
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[]) => {
|
||||
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[], teamId?: string) => {
|
||||
// If variants not explicitly provided (e.g., from table view), compute from normalized key
|
||||
let v = variantNames;
|
||||
if (!v || v.length === 0) {
|
||||
@@ -274,7 +340,7 @@ const TeamsAdminPage = () => {
|
||||
v = variantsByKey[key] || [];
|
||||
}
|
||||
setSelected({ teamName, teamLogoUrl, variantNames: v });
|
||||
setForm({ external_team_id: '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
|
||||
setForm({ external_team_id: teamId || '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
|
||||
setQuery(teamName || '');
|
||||
setIsOpen(true);
|
||||
};
|
||||
@@ -285,53 +351,79 @@ const TeamsAdminPage = () => {
|
||||
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
|
||||
}
|
||||
let logoUrl = (form.logo_url || '').trim();
|
||||
const primaryName = (selected?.teamName || form.team_name || '').trim();
|
||||
// Prefer the edited input over the pre-selected name
|
||||
const primaryName = (form.team_name || selected?.teamName || '').trim();
|
||||
// All variants to update (deduped), always include the primary name
|
||||
const names = Array.from(new Set([primaryName, ...((selected?.variantNames || []) as string[])]))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
|
||||
if (logoUrl) {
|
||||
setExternalUploadStatus('uploading');
|
||||
setExternalUploadError(null);
|
||||
try {
|
||||
let logoFileToUpload: File | Blob | null = uploadedFile;
|
||||
if (!logoFileToUpload && logoUrl) {
|
||||
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
||||
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
|
||||
try {
|
||||
if (!uploadedFile && form.external_team_id) {
|
||||
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
|
||||
if (apiLogo) {
|
||||
logoUrl = apiLogo;
|
||||
}
|
||||
if (logoFileToUpload) {
|
||||
const logaResult = await uploadToLogaSportcreative(
|
||||
form.external_team_id,
|
||||
logoFileToUpload,
|
||||
{
|
||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||
}
|
||||
);
|
||||
if (logaResult.success) {
|
||||
setExternalUploadStatus('success');
|
||||
if (logaResult.url) {
|
||||
logoUrl = logaResult.url;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (logoUrl) {
|
||||
let shouldUpload = Boolean(uploadedFile);
|
||||
try {
|
||||
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
|
||||
const u = new URL(abs);
|
||||
const host = u.hostname.toLowerCase();
|
||||
const path = u.pathname;
|
||||
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
|
||||
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
|
||||
const isLogoAPI = host === 'logoapi.sportcreative.eu';
|
||||
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
|
||||
if (!shouldUpload) {
|
||||
shouldUpload = isLocalUpload;
|
||||
}
|
||||
if (isFacr || isLogoAPI) {
|
||||
shouldUpload = false;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (shouldUpload) {
|
||||
setExternalUploadStatus('uploading');
|
||||
setExternalUploadError(null);
|
||||
try {
|
||||
let logoFileToUpload: File | Blob | null = uploadedFile;
|
||||
if (!logoFileToUpload && logoUrl) {
|
||||
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
||||
}
|
||||
if (logoFileToUpload) {
|
||||
const logaResult = await uploadToLogaSportcreative(
|
||||
form.external_team_id,
|
||||
logoFileToUpload,
|
||||
{
|
||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||
}
|
||||
);
|
||||
if (logaResult.success) {
|
||||
setExternalUploadStatus('success');
|
||||
if (logaResult.url) {
|
||||
logoUrl = logaResult.url;
|
||||
}
|
||||
} else {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
|
||||
}
|
||||
} else {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
|
||||
setExternalUploadError('Could not fetch logo file');
|
||||
}
|
||||
} else {
|
||||
} catch (error: any) {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError('Could not fetch logo file');
|
||||
setExternalUploadError(error?.message || 'Upload failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(error?.message || 'Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Save override for each variant name so editing one updates all duplicates
|
||||
await Promise.all(
|
||||
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
|
||||
);
|
||||
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -489,12 +581,12 @@ const TeamsAdminPage = () => {
|
||||
<Td py={1.5}>
|
||||
<HStack spacing={2} align="center">
|
||||
<Image
|
||||
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
|
||||
alt={r.team}
|
||||
src={getLogo(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url), r.team_logo_url)}
|
||||
alt={getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
|
||||
<Text fontSize="xs" noOfLines={1}>{getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.played}</Td>
|
||||
@@ -504,7 +596,12 @@ const TeamsAdminPage = () => {
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.score}</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
|
||||
<Td py={1.5}>
|
||||
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(r.team || '', r.team_logo_url)}>Upravit</Button>
|
||||
<Button size="xs" fontSize="xs" onClick={() => {
|
||||
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
const displayName = getName(r.team, tid);
|
||||
const key = tid ? `id:${tid}` : normalize(displayName);
|
||||
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
|
||||
}}>Upravit</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
@@ -558,7 +655,7 @@ const TeamsAdminPage = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants)} w="full">Upravit</Button>
|
||||
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants, t.teamId)} w="full">Upravit</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
@@ -604,9 +701,15 @@ const TeamsAdminPage = () => {
|
||||
px={3}
|
||||
py={2}
|
||||
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setForm((f) => ({ ...f, external_team_id: r.id, team_name: r.name, logo_url: r.logo_url || f.logo_url }));
|
||||
setQuery(r.name);
|
||||
try {
|
||||
const apiUrl = await fetchLogoFromLogoAPI(r.id, r.name);
|
||||
if (apiUrl) {
|
||||
setForm((f) => ({ ...f, logo_url: apiUrl }));
|
||||
}
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
<HStack justify="space-between" spacing={3}>
|
||||
@@ -656,7 +759,7 @@ const TeamsAdminPage = () => {
|
||||
Upravíte také duplicitní názvy: {Array.from(new Set(selected.variantNames)).join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
{form.logo_url && (
|
||||
{showExternalUploadInfo && (
|
||||
<Alert status="info" variant="left-accent">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={1}>
|
||||
|
||||
@@ -236,50 +236,48 @@ const UsersAdminPage = () => {
|
||||
Správa uživatelských účtů a jejich oprávnění. <strong>Editor</strong> může vytvářet a upravovat články a aktivity. <strong>Admin</strong> má přístup ke všem funkcím.
|
||||
</Text>
|
||||
|
||||
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
|
||||
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
||||
<Td>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<HamburgerIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<EditIcon />}
|
||||
onClick={() => openEditModal(user)}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<Heading size="md" mb={2}>Admini a editoři</Heading>
|
||||
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto" mb={8}>
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.filter(u => u.role !== 'fan').map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
|
||||
{user.role === 'admin' ? 'Admin' : 'Editor'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
||||
<Td>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<HamburgerIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem onClick={async () => {
|
||||
try {
|
||||
await api.post(`/admin/users/${user.id}/reset-password`);
|
||||
@@ -287,34 +285,80 @@ const UsersAdminPage = () => {
|
||||
} catch (e: any) {
|
||||
const errorMsg = e?.response?.data?.message || e?.response?.data?.error || e?.message || 'Nelze odeslat reset hesla';
|
||||
const errorDetails = e?.response?.data?.details;
|
||||
toast({
|
||||
title: 'Chyba při odesílání resetu hesla',
|
||||
description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg,
|
||||
status: 'error',
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
});
|
||||
toast({ title: 'Chyba při odesílání resetu hesla', description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg, status: 'error', duration: 10000, isClosable: true });
|
||||
}
|
||||
}}>
|
||||
Odeslat reset hesla
|
||||
</MenuItem>
|
||||
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
|
||||
<MenuItem
|
||||
icon={<DeleteIcon />}
|
||||
color="red.500"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Td>
|
||||
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
|
||||
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Heading size="md" mb={2}>Fanoušci</Heading>
|
||||
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.filter(u => u.role === 'fan').map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={'gray'}>
|
||||
Fan
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
||||
<Td>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<HamburgerIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{String(authUser?.id) !== String(user.id) && (
|
||||
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add/Edit User Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
|
||||
@@ -1,31 +1,93 @@
|
||||
import { Box, Heading, Text, List, ListItem, Link, Container } from '@chakra-ui/react';
|
||||
import { Container, Heading, Text, List, ListItem, Link, Box, VStack, Divider, Button, useColorModeValue } from '@chakra-ui/react';
|
||||
import MainLayout from '../../components/layout/MainLayout';
|
||||
|
||||
const CookiePolicyPage: React.FC = () => {
|
||||
const textColor = useColorModeValue('gray.700', 'gray.300');
|
||||
const headingColor = useColorModeValue('gray.900', 'gray.100');
|
||||
const boxBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const boxText = useColorModeValue('blue.900', 'blue.100');
|
||||
|
||||
const openCookieSettings = () => {
|
||||
window.dispatchEvent(new Event('cookie-consent-open'));
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Container maxW="3xl">
|
||||
<Heading as="h1" size="lg" mb={4}>Pravidla používání souborů Cookies</Heading>
|
||||
<Text mb={4}>
|
||||
Tento web používá soubory cookies pro zajištění správného fungování webu, analýzu návštěvnosti a zlepšení nabízených služeb.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>Co jsou cookies?</Heading>
|
||||
<Text mb={4}>
|
||||
Cookies jsou malé textové soubory, které se ukládají do vašeho zařízení při návštěvě webových stránek. Umožňují webu rozpoznat vaše zařízení a přizpůsobit obsah.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>Jaké typy cookies používáme</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6}>
|
||||
<ListItem>Nezbytné cookies – zajišťují základní funkce webu.</ListItem>
|
||||
<ListItem>Preferenční cookies – pamatují si vaše volby (např. jazyk).</ListItem>
|
||||
<ListItem>Analytické cookies – pomáhají nám porozumět, jak web používáte (anonymně).</ListItem>
|
||||
</List>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>Správa cookies</Heading>
|
||||
<Text mb={4}>
|
||||
Používání cookies můžete upravit nebo zakázat v nastavení vašeho prohlížeče. Upozorňujeme, že vypnutí některých cookies může ovlivnit funkčnost webu.
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
V případě dotazů nás kontaktujte na adrese <Link href="/kontakt">/kontakt</Link>.
|
||||
</Text>
|
||||
<Container maxW="3xl" py={8}>
|
||||
<VStack align="stretch" spacing={6}>
|
||||
<Heading as="h1" size="xl" mb={2} color={headingColor}>Pravidla používání souborů cookies</Heading>
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
|
||||
<Box bg={boxBg} p={4} borderRadius="md">
|
||||
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
|
||||
<Text fontSize="sm" color={boxText}>
|
||||
Cookies používáme pro zajištění nezbytných funkcí webu, zlepšení uživatelského zážitku a měření návštěvnosti. Nepovinné cookies ukládáme pouze na základě vašeho souhlasu.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Co jsou cookies?</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Cookies jsou malé textové soubory ukládané do vašeho zařízení při návštěvě webu. Umožňují rozpoznat prohlížeč, pamatovat si vaše volby a zajistit fungování webu.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>2. Jaké cookies používáme</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem><strong>Nezbytné:</strong> zajišťují bezpečnost a základní funkce webu. Vždy aktivní.</ListItem>
|
||||
<ListItem><strong>Preferenční:</strong> pamatují si vaše volby (např. jazyk, zobrazení).</ListItem>
|
||||
<ListItem><strong>Analytické:</strong> anonymní měření návštěvnosti a výkonu stránek.</ListItem>
|
||||
<ListItem><strong>Marketingové:</strong> přizpůsobení obsahu a případného marketingu.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>3. Právní základy a účely</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem><strong>Nezbytné cookies:</strong> oprávněný zájem na provozu a zabezpečení webu (GDPR čl. 6 odst. 1 písm. f).</ListItem>
|
||||
<ListItem><strong>Preferenční, analytické, marketingové:</strong> pouze na základě vašeho souhlasu (GDPR čl. 6 odst. 1 písm. a), který můžete kdykoli odvolat.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>4. Správa souhlasu</Heading>
|
||||
<Text color={textColor} mb={3}>
|
||||
Nastavení cookies můžete kdykoli změnit pomocí tlačítka níže nebo v nastavení prohlížeče. Odvolání souhlasu nemá vliv na zákonnost předchozího zpracování.
|
||||
</Text>
|
||||
<Button onClick={openCookieSettings} colorScheme="blue" size="sm">Otevřít nastavení cookies</Button>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>5. Doba uchovávání</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem><strong>Nezbytné cookies:</strong> po dobu nezbytnou pro fungování relace či bezpečnost.</ListItem>
|
||||
<ListItem><strong>Preferenční a analytické:</strong> typicky 6–26 měsíců, nebo do odvolání souhlasu.</ListItem>
|
||||
<ListItem><strong>Marketingové:</strong> dle konkrétní kategorie, nejdéle do odvolání souhlasu.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>6. Třetí strany</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Pro analytiku nebo měření můžeme využívat nástroje třetích stran. Tyto služby zpracovávají údaje pouze v rozsahu nezbytném pro poskytování měření a jsou vázány smluvními závazky ochrany údajů.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>7. Další informace</Heading>
|
||||
<Text color={textColor} mb={2}>
|
||||
Podrobnosti o zpracování osobních údajů najdete v <Link href="/zasady-ochrany-osobnich-udaju" color="brand.primary">Zásadách ochrany osobních údajů</Link>.
|
||||
</Text>
|
||||
<Text color={textColor}>
|
||||
V případě dotazů nás kontaktujte prostřednictvím stránky <Link href="/kontakt" color="brand.primary">Kontakt</Link>.
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Container>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
<ListItem><strong>Kontaktní údaje:</strong> jméno, příjmení, e-mailová adresa, telefonní číslo - poskytnuté dobrovolně prostřednictvím kontaktního formuláře nebo při registraci k newsletteru</ListItem>
|
||||
<ListItem><strong>IP adresa:</strong> automaticky zaznamenávaná při návštěvě webu pro účely bezpečnosti, analýzy návštěvnosti a prevence zneužití</ListItem>
|
||||
<ListItem><strong>Technické údaje:</strong> informace o zařízení, prohlížeči, operačním systému, datum a čas návštěvy, navštívené stránky</ListItem>
|
||||
<ListItem><strong>Cookies:</strong> soubory ukládané do vašeho zařízení (viz samostatná <Link href="/cookies" color="brand.primary">Pravidla používání cookies</Link>)</ListItem>
|
||||
<ListItem><strong>Cookies:</strong> soubory ukládané do vašeho zařízení (viz samostatná <Link href="/pravidla-cookies" color="brand.primary">Pravidla používání cookies</Link>)</ListItem>
|
||||
<ListItem><strong>Analytické údaje:</strong> anonymizovaná data o chování na webu prostřednictvím analytických nástrojů</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
@@ -1,30 +1,108 @@
|
||||
import { Container, Heading, Text, List, ListItem } from '@chakra-ui/react';
|
||||
import { Container, Heading, Text, List, ListItem, Link, Box, VStack, Divider, useColorModeValue } from '@chakra-ui/react';
|
||||
import MainLayout from '../../components/layout/MainLayout';
|
||||
|
||||
const TermsPage: React.FC = () => {
|
||||
const textColor = useColorModeValue('gray.700', 'gray.300');
|
||||
const headingColor = useColorModeValue('gray.900', 'gray.100');
|
||||
const boxBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const boxText = useColorModeValue('blue.900', 'blue.100');
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Container maxW="3xl">
|
||||
<Heading as="h1" size="lg" mb={4}>Obchodní podmínky</Heading>
|
||||
<Text mb={4}>
|
||||
Tyto obchodní podmínky upravují používání webových stránek a poskytované služby.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>1. Obecná ustanovení</Heading>
|
||||
<Text mb={4}>
|
||||
Provozovatelem webu je subjekt uvedený v kontaktech. Používáním webu vyjadřujete souhlas s těmito podmínkami.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>2. Obsah webu</Heading>
|
||||
<Text mb={4}>
|
||||
Veškerý obsah je poskytován pro informační účely. Provozovatel si vyhrazuje právo na změny bez předchozího upozornění.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>3. Odpovědnost</Heading>
|
||||
<Text mb={4}>
|
||||
Provozovatel nenese odpovědnost za škody vzniklé v souvislosti s používáním webu, ledaže je stanoveno jinak právními předpisy.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>4. Kontakt</Heading>
|
||||
<Text mb={4}>
|
||||
V případě dotazů nás kontaktujte na stránce Kontakt.
|
||||
</Text>
|
||||
<Container maxW="3xl" py={8}>
|
||||
<VStack align="stretch" spacing={6}>
|
||||
<Heading as="h1" size="xl" mb={2} color={headingColor}>Obchodní podmínky</Heading>
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
|
||||
<Box bg={boxBg} p={4} borderRadius="md">
|
||||
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
|
||||
<Text fontSize="sm" color={boxText}>
|
||||
Používáním tohoto webu souhlasíte s níže uvedenými podmínkami. Obsah slouží pro informační účely, bez záruky úplnosti a správnosti. Provozovatel může podmínky a obsah kdykoli měnit.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Provozovatel a kontakt</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Provozovatelem webových stránek je subjekt uvedený v sekci <Link href="/kontakt" color="brand.primary">Kontakt</Link>. Pro veškeré dotazy nebo podněty prosím využijte uvedené kontaktní údaje.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>2. Rozsah a přijetí podmínek</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Tyto podmínky upravují používání tohoto webu a souvisejících služeb (např. newsletter). Vstupem na web a jeho používáním vyjadřujete souhlas s těmito podmínkami.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>3. Obsah, zdroje dat a práva k duševnímu vlastnictví</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem><strong>Autorská práva:</strong> Texty, grafika a prvky webu podléhají ochraně autorského práva. Kopírování či šíření je možné pouze s předchozím souhlasem.</ListItem>
|
||||
<ListItem><strong>Externí data FAČR:</strong> Informace o zápasech, soutěžích a tabulkách pocházejí z veřejných zdrojů FAČR (<Link href="https://www.fotbal.cz" isExternal color="brand.primary">www.fotbal.cz</Link>) a jsou majetkem FAČR. Zobrazujeme je výhradně pro informační účely.</ListItem>
|
||||
<ListItem><strong>Loga klubů a týmů:</strong> Loga náleží příslušným klubům a organizacím a slouží pouze k identifikaci.</ListItem>
|
||||
<ListItem><strong>Fotografie a videa:</strong> Mediální obsah může pocházet z různých zdrojů, včetně YouTube a Zonerama. Veškerá práva náleží původním autorům.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>4. Pravidla chování uživatelů</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem>Neporušujte právní předpisy ani práva třetích osob.</ListItem>
|
||||
<ListItem>Nevkládejte škodlivý kód a nepokoušejte se narušit bezpečnost webu.</ListItem>
|
||||
<ListItem>Nevyužívejte web k nevyžádané reklamě ani automatizovanému sběru dat (scraping) v rozporu s podmínkami.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>5. Newsletter a účty</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Přihlášení k odběru newsletteru je dobrovolné a lze jej kdykoli odhlásit prostřednictvím odkazu v e‑mailu nebo na stránce <Link href="/newsletter/preferences" color="brand.primary">nastavení newsletteru</Link>.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>6. Odpovědnost a záruky</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Obsah webu je poskytován „tak jak je“, bez jakýchkoli záruk. Provozovatel neodpovídá za případné škody vzniklé užíváním webu, v nejširším rozsahu povoleném právem. Neodpovídáme za dostupnost a obsah externích odkazů (např. FAČR, YouTube, Zonerama).
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>7. E‑shop a externí služby</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Pokud web odkazuje na externí e‑shop nebo platební službu, nákup se řídí podmínkami daného poskytovatele. Provozovatel tohoto webu není stranou takového smluvního vztahu, pokud není výslovně uvedeno jinak.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>8. Ochrana osobních údajů a cookies</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Zpracování osobních údajů se řídí dokumentem <Link href="/zasady-ochrany-osobnich-udaju" color="brand.primary">Zásady ochrany osobních údajů</Link>. Informace o cookies naleznete v <Link href="/pravidla-cookies" color="brand.primary">Pravidlech používání cookies</Link>.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>9. Změny podmínek</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Provozovatel si vyhrazuje právo tyto podmínky kdykoli upravit nebo doplnit. Změny jsou účinné zveřejněním na tomto webu.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>10. Rozhodné právo</Heading>
|
||||
<Text color={textColor} mb={2}>
|
||||
Tyto podmínky se řídí právním řádem České republiky. Případné spory budou řešeny u příslušných soudů České republiky.
|
||||
</Text>
|
||||
<Text color={textColor}>
|
||||
Máte‑li dotazy, kontaktujte nás prostřednictvím stránky <Link href="/kontakt" color="brand.primary">Kontakt</Link>.
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Container>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user