This commit is contained in:
Tomas Dvorak
2025-10-31 18:22:04 +01:00
parent 16e4533202
commit ac886502e0
65 changed files with 3211 additions and 553 deletions
+16 -5
View File
@@ -287,7 +287,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
</Drawer>
);
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth = false, variant = 'unified' }) => {
const { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -726,6 +726,17 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
return out;
}, [navSplit]);
// Variant-driven styles for main nav bar
const headerVariant = variant || 'unified';
const navBgDefault = useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)');
const navBgMinimal = useColorModeValue('white', '#0f1115');
const isTransparent = headerVariant === 'transparent';
const isMinimal = headerVariant === 'minimal';
const navBg = isTransparent ? 'transparent' : (isMinimal ? navBgMinimal : navBgDefault);
const navBackdrop = (isTransparent || isMinimal) ? 'none' : 'saturate(180%) blur(10px)';
const navBorderBottomWidth = isTransparent ? '0px' : '1px';
const navBoxShadow = (isTransparent || isMinimal) ? 'none' : (scrolled ? 'sm' : 'none');
return (
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
@@ -758,11 +769,11 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
{/* Main Nav Bar */}
<Box
bg={useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)')}
backdropFilter="saturate(180%) blur(10px)"
borderBottomWidth="1px"
bg={navBg}
backdropFilter={navBackdrop}
borderBottomWidth={navBorderBottomWidth}
borderColor="border.subtle"
boxShadow={scrolled ? 'sm' : 'none'}
boxShadow={navBoxShadow}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={filteredDynamicNavItems} navLoading={navLoading} />
@@ -0,0 +1,199 @@
import React from 'react';
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box } from '@chakra-ui/react';
import { Share2 } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { createShortLink } from '../../services/shortlinks';
import { Article, getArticleMatchLink } from '../../services/articles';
import { API_URL } from '../../services/api';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot } from '../../services/instagram';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface Props {
article?: Article;
activity?: any;
match?: MatchSnapshot | null;
targetUrl?: string;
size?: 'sm' | 'md' | 'lg';
placement?: 'fixed' | 'inline';
mr?: number | string;
mb?: number | string;
zIndex?: number;
variant?: 'button' | 'icon';
onGenerated?: (text: string, shortUrl: string) => void;
}
const InstagramGeneratorButton: React.FC<Props> = ({
article,
activity,
match,
targetUrl,
size = 'md',
placement = 'fixed',
mr = 6,
mb = 6,
zIndex = 40,
variant = 'icon',
onGenerated,
}) => {
const { user } = useAuth();
const role = String(user?.role || '').toLowerCase();
const isAdmin = role === 'admin';
const { data: publicSettings } = usePublicSettings();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const [text, setText] = React.useState('');
const [shortUrl, setShortUrl] = React.useState('');
const [loading, setLoading] = React.useState(false);
if (!isAdmin) return null;
const computeTarget = () => {
if (targetUrl) return targetUrl;
if (typeof window !== 'undefined') return window.location.href;
return '';
};
const withUtm = (urlStr: string) => {
try {
const u = new URL(urlStr, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
if (!u.searchParams.get('utm_source')) u.searchParams.set('utm_source', 'instagram');
if (!u.searchParams.get('utm_medium')) u.searchParams.set('utm_medium', 'social');
const campaignBase = article ? `article-${article.id}` : (activity ? `activity-${activity.id}` : 'share');
if (!u.searchParams.get('utm_campaign')) u.searchParams.set('utm_campaign', campaignBase);
return u.toString();
} catch {
return urlStr;
}
};
const handleGenerate = async (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
setLoading(true);
const fullUrl = withUtm(computeTarget());
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
const payload = {
target_url: fullUrl,
title: article?.title || activity?.title || 'Link',
source_type: article ? 'article' : (activity ? 'event' : 'other'),
source_id: article?.id || activity?.id,
} as any;
const res = await createShortLink(payload);
const sUrl = res?.short_url || '';
setShortUrl(sUrl || fullUrl);
const clubName = publicSettings?.club_name || undefined;
let composed = '';
if (article) {
let resolvedMatch = match || null;
if (!resolvedMatch && article?.id) {
try {
const link = await getArticleMatchLink(article.id);
const extId = (link as any)?.external_match_id;
if (extId) {
try {
const origin = new URL(API_URL, window.location.origin).origin;
const resp = await fetch(`${origin}/cache/prefetch/facr_club_info.json`, { cache: 'no-cache' });
if (resp.ok) {
const json = await resp.json();
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
let snapshot: MatchSnapshot | null = null;
for (const c of comps) {
const matches = Array.isArray(c.matches) ? c.matches : [];
for (const m of matches) {
const mid = String(m.match_id || m.id);
if (mid === String(extId)) {
let score = '';
if (m?.score && m.score !== 'vs') score = String(m.score);
else if (m?.result_home != null && m?.result_away != null) score = `${m.result_home}:${m.result_away}`;
snapshot = {
external_match_id: String(extId),
competition: String(c.name || ''),
date_time: String(m.date_time || m.date || ''),
venue: m.venue ? String(m.venue) : undefined,
home: String(m.home || m.home_team || ''),
away: String(m.away || m.away_team || ''),
score,
};
break;
}
}
if (snapshot) break;
}
if (snapshot) resolvedMatch = snapshot;
}
} catch {}
}
} catch {}
}
composed = composeInstagramPostFromArticle({ article, trackingUrl: sUrl || fullUrl, clubName, match: resolvedMatch });
} else if (activity) {
composed = composeInstagramPostFromActivity({ activity, trackingUrl: sUrl || fullUrl, clubName });
} else {
composed = `${clubName || 'Náš klub'}\n\n🔗 ${sUrl || fullUrl}`;
}
setText(composed);
onGenerated?.(composed, sUrl || fullUrl);
onOpen();
} catch (err: any) {
toast({ status: 'error', title: 'Nelze vygenerovat příspěvek', description: err?.message || 'Zkuste to prosím znovu.' });
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
toast({ status: 'success', title: 'Zkopírováno', description: 'Text příspěvku byl zkopírován do schránky.' });
} catch {
toast({ status: 'warning', title: 'Nelze kopírovat', description: 'Zkopírujte prosím ručně.' });
}
};
const ButtonEl = (
<Tooltip label="Vygenerovat Instagram příspěvek" placement="left">
{variant === 'icon' ? (
<IconButton aria-label="IG post" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size} />
) : (
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size}>
Instagram post
</Button>
)}
</Tooltip>
);
return (
<>
{placement === 'fixed' ? (
<Box position="fixed" right={mr} bottom={mb} zIndex={zIndex}>
{ButtonEl}
</Box>
) : (
<Box position="absolute" top={2} right={2} zIndex={zIndex}>
{ButtonEl}
</Box>
)}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Instagram post</ModalHeader>
<ModalBody>
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={12} fontFamily="mono" />
</ModalBody>
<ModalFooter gap={3}>
<Button variant="outline" onClick={handleCopy}>Kopírovat</Button>
<Button onClick={onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default InstagramGeneratorButton;
+65 -36
View File
@@ -415,6 +415,71 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
{/* Tab 2: Create new poll */}
<TabPanel px={0} py={3}>
<VStack spacing={3} align="stretch">
<HStack spacing={2} flexWrap="wrap">
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
style: 'rating-stars',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}> 5</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení (110)',
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
style: 'rating-scale',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}>110</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka',
description: 'Dej vědět, zda dorazíš.',
type: 'single',
style: 'choices-chips',
allow_multiple: false,
max_choices: 1,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
]
}))}>Docházka</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka (více možností)',
description: 'Vyberte jednu nebo dvě možnosti.',
type: 'multiple',
style: 'choices-cards',
allow_multiple: true,
max_choices: 2,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Pozdě dorazím', display_order: 1 },
{ text: 'Ne', display_order: 2 },
]
}))}>Docházka (multi)</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Výběr možností',
description: 'Vyber až tři možnosti.',
type: 'multiple',
style: 'choices-list',
allow_multiple: true,
max_choices: 3,
options: [
{ text: 'A', display_order: 0 },
{ text: 'B', display_order: 1 },
{ text: 'C', display_order: 2 },
{ text: 'D', display_order: 3 },
]
}))}>Multi (3)</Button>
</HStack>
<FormControl isRequired>
<FormLabel fontSize="sm">Název ankety</FormLabel>
<Input
@@ -503,42 +568,6 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
>
Přidat možnost
</Button>
<HStack>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
style: 'rating-stars',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}> 5</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení (110)',
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
style: 'rating-scale',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}>110</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka',
description: 'Dej vědět, zda dorazíš.',
type: 'single',
style: 'choices-chips',
allow_multiple: false,
max_choices: 1,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
]
}))}>Docházka</Button>
</HStack>
</VStack>
</FormControl>
@@ -105,11 +105,11 @@ import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../
const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
news: ['grid_one', 'grid_two', 'grid', 'scroller'],
news: ['grid_one', 'grid_two', 'grid', 'list', 'scroller'],
matches: ['compact'],
sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
gallery: ['grid'],
videos: ['grid'],
videos: ['grid', 'carousel'],
merch: ['grid'],
table: ['split_news'],
banner: ['top'],
@@ -426,11 +426,16 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
...cfg,
variant: normalizeVariant(cfg.element_name, cfg.variant)
}));
// Load saved custom CSS from settings
// Load saved styles and custom CSS from settings
const cssByElement: Record<string, string> = {};
const stylesByElement: Record<string, Record<string, any>> = {};
sanitizedConfigs.forEach(cfg => {
const css = (cfg.settings && (cfg.settings as any).customCSS) || '';
if (css) cssByElement[cfg.element_name] = String(css);
const css = String(((cfg.settings as any)?.customCSS) || ((cfg.settings as any)?.styles?.customCSS) || '');
if (css) cssByElement[cfg.element_name] = css;
const st = (cfg.settings && (cfg.settings as any).styles) || {};
if (st && typeof st === 'object' && Object.keys(st).length > 0) {
stylesByElement[cfg.element_name] = st as Record<string, any>;
}
});
setConfigs(sanitizedConfigs);
const changes: Record<string, string> = {};
@@ -448,16 +453,19 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setLocalChanges(changes);
setVisibleElements(visible);
setElementOrder(order);
// Prime style state with saved custom CSS
if (Object.keys(cssByElement).length > 0) {
// Prime style state with saved styles + custom CSS
if (Object.keys(stylesByElement).length > 0 || Object.keys(cssByElement).length > 0) {
setElementStyles(prev => {
const next = { ...prev } as Record<string, any>;
Object.entries(stylesByElement).forEach(([name, st]) => {
next[name] = { ...(next[name] || {}), ...st };
});
Object.entries(cssByElement).forEach(([name, css]) => {
next[name] = { ...(next[name] || {}), customCSS: css };
});
return next;
});
// Inject saved CSS for preview (admin only)
// Inject saved custom CSS for preview (admin only)
Object.entries(cssByElement).forEach(([name, css]) => {
try {
const styleId = `custom-css-${name}`;
@@ -7,6 +7,7 @@ import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url';
import { Eye, Clock } from 'lucide-react';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const Card: React.FC<{ a: Article }> = ({ a }) => {
const cardBg = useColorModeValue('white', 'gray.800');
@@ -90,6 +91,14 @@ const Card: React.FC<{ a: Article }> = ({ a }) => {
)}
</HStack>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={a as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</Box>
);
};
+10
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
@@ -24,6 +25,7 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
borderColor={border}
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
position="relative"
>
<Box position="relative" overflow="hidden">
<Image
@@ -70,6 +72,14 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
)}
</HStack>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</VStack>
);
};
+18 -1
View File
@@ -5,6 +5,7 @@ import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { Eye, Clock } from 'lucide-react';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const FeaturedBlog: React.FC = () => {
const { data, isLoading } = useQuery({
@@ -51,13 +52,21 @@ const FeaturedBlog: React.FC = () => {
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
<Heading size="md">{main.title}</Heading>
</VStack>
<Box position="absolute" top={3} right={3} zIndex={2}>
<InstagramGeneratorButton
article={main as any}
targetUrl={typeof window !== 'undefined' ? new URL(main.slug ? `/news/${main.slug}` : `/articles/${main.id}`, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</Box>
)}
</GridItem>
<GridItem>
<VStack spacing={4} align="stretch">
{[side1, side2].filter(Boolean).map((a) => (
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`} position="relative">
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
<VStack align="stretch" spacing={2} flex={1}>
<HStack spacing={2} flexWrap="wrap">
@@ -80,6 +89,14 @@ const FeaturedBlog: React.FC = () => {
</HStack>
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={a as any}
targetUrl={typeof window !== 'undefined' ? new URL((a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</HStack>
))}
</VStack>
+11 -1
View File
@@ -19,7 +19,7 @@ const TeamScroller: React.FC = () => {
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
{p.date_of_birth ? (
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
Věk: {calculateAge(p.date_of_birth)} let
Věk: {(() => { const a = calculateAge(p.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}
</Text>
) : null}
</VStack>
@@ -43,4 +43,14 @@ function calculateAge(dob: string): number | null {
}
}
// Czech pluralization for years
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
return 'let';
}
export default TeamScroller;
@@ -8,8 +8,8 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { useEffect, useMemo, useState } from 'react';
type Props = {
// optional manual override
videos?: string[];
variant?: 'grid' | 'carousel';
};
type RenderItem = {
@@ -39,7 +39,7 @@ const toEmbed = (idOrUrl: string): string => {
return `https://www.youtube.com/embed/${idOrUrl}`;
};
const VideosSection: React.FC<Props> = ({ videos }) => {
const VideosSection: React.FC<Props> = ({ videos, variant }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const theme = useClubTheme();
const { data: settings } = usePublicSettings();
@@ -55,7 +55,11 @@ const VideosSection: React.FC<Props> = ({ videos }) => {
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
? Boolean((settings as any)?.videos_module_enabled)
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
const style = settings?.videos_style || 'slider';
const style = (() => {
if (variant === 'carousel') return 'slider';
if (variant === 'grid') return 'grid';
return settings?.videos_style || 'slider';
})();
const source = settings?.videos_source || 'auto';
// Default to 6 items on homepage unless overridden by settings (max 12)
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
@@ -50,7 +50,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
)}
</Box>
{children}
@@ -70,7 +70,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
)}
</Box>
<Container maxW="container.xl" py={8}>
+48 -1
View File
@@ -25,7 +25,8 @@ const MatchesSlider: React.FC<{
onActiveChange: (idx: number) => void;
onMatchClick?: (m: SliderMatch, compName?: string) => void;
elementProps?: any;
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps }) => {
variant?: 'carousel' | 'scroller' | 'ticker' | 'compact_split';
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps, variant }) => {
const trackRef = useRef<HTMLDivElement | null>(null);
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
@@ -56,6 +57,52 @@ const MatchesSlider: React.FC<{
} catch {}
}, [activeIndex, JSON.stringify(current?.matches)]);
// Ticker variant - continuous belt animation
if (variant === 'ticker') {
const items = (current?.matches || []);
const looped = [...items, ...items, ...items];
return (
<section className="matches-slider matches-ticker" {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div className="ticker-belt">
{looped.map((m, idx) => (
<div
key={`${m.id || idx}-ticker`}
className="match-card"
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
<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>
</section>
);
}
return (
<section className="matches-slider" {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>