import React, { useEffect, useState, useMemo } from 'react'; import { Box, Flex, Button, useColorModeValue, useColorMode, IconButton, Avatar, Menu, MenuButton, MenuList, MenuItem, Text, HStack, Tooltip, useDisclosure, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton, VStack, Divider, Container, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton, InputGroup, InputLeftElement, Input, useToast, } from '@chakra-ui/react'; import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons'; import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa'; import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { usePublicSettings } from '../hooks/usePublicSettings'; import { useClubTheme } from '../contexts/ClubThemeContext'; import { Image } from '@chakra-ui/react'; import { getCategories, Category } from '../services/public'; import { FaSearch as FaSearchIcon } from 'react-icons/fa'; import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../services/navigation'; import { getEvents } from '../services/eventService'; import { getPlayers } from '../services/public'; import { getArticles } from '../services/articles'; import { getCachedYouTube } from '../services/youtube'; import { getZoneramaManifestWithFallbacks } from '../services/zonerama'; import { getMyNewsletterToken } from '../services/public/newsletter'; import { API_URL } from '../services/api'; type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean }; // Minimal normalization for social URLs so admins can input @handle or domain-less usernames const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => { let v = String(raw || '').trim(); if (!v) return null; v = v.replace(/\s+/g, ''); if (v.startsWith('@')) { const handle = v.slice(1); if (network === 'facebook') return `https://www.facebook.com/${handle}`; if (network === 'instagram') return `https://www.instagram.com/${handle}`; if (network === 'youtube') return `https://www.youtube.com/@${handle}`; } if (!/^https?:\/\//i.test(v) && !v.includes('/') && !v.includes('.')) { if (network === 'facebook') return `https://www.facebook.com/${v}`; if (network === 'instagram') return `https://www.instagram.com/${v}`; if (network === 'youtube') return `https://www.youtube.com/@${v}`; } if (!/^https?:\/\//i.test(v)) { if (/^facebook\.com\//i.test(v)) return `https://www.${v}`; if (/^instagram\.com\//i.test(v)) return `https://www.${v}`; if (/^youtube\.com\//i.test(v)) return `https://www.${v}`; } return v; }; // Mobile menu component const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: { isOpen: boolean; onClose: () => void; isAdmin: boolean; isAuthenticated: boolean; menuBg: string; dividerColor: string; settings?: any; categories?: Category[] | null; galleryHref?: string | null; galleryLabel?: string; hasTables?: boolean | null; hasActivities?: boolean | null; hasPlayers?: boolean | null; hasArticles?: boolean | null; hasVideos?: boolean | null; hasGallery?: boolean | null; dynamicNavItems: NavigationItem[]; navLoading: boolean; }) => ( Menu {/* Dynamic navigation items in mobile */} {(!navLoading && dynamicNavItems.length > 0) ? ( // Use dynamic navigation dynamicNavItems.map((item, idx) => { const linkIsExternal = item.type === 'external'; const hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0; const linkProps = linkIsExternal ? { href: item.url } : { to: item.url || '/' }; const Comp: any = linkIsExternal ? 'a' : RouterLink; return ( {/* Render children for dropdown items */} {hasChildren && ( {item.children!.map((child) => { const childIsExternal = child.type === 'external'; const childLinkProps = childIsExternal ? { href: child.url } : { to: child.url || '/' }; const ChildComp: any = childIsExternal ? 'a' : RouterLink; return ( ); })} )} ); }) ) : ( // Fallback to hardcoded navigation <> {(settings?.show_about_in_nav ?? true) && ( )} {hasActivities !== false && ( )} {hasPlayers !== false && ( )} {hasTables ? ( ) : null} {Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => { const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url); const linkProps = customLinkIsExternal ? { href: item.url } : { to: item.url || '/' }; const Comp: any = customLinkIsExternal ? 'a' : RouterLink; return ( ); })} {hasArticles !== false && ( <> {Array.isArray(categories) && categories.length > 0 && ( {categories.map((cat: any) => { const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url); const catHref = cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'); const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref }; return ( ); })} )} )} {hasVideos !== false && ( )} {hasGallery !== false && ( )} {settings?.shop_url && ( )} )} {isAdmin && ( <> Administrace )} {!isAuthenticated && ( <> )} ); const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => { const { colorMode, toggleColorMode } = useColorMode(); const { isAuthenticated, logout, user } = useAuth(); const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure(); const isAdmin = user?.role === 'admin'; const { data: settings } = usePublicSettings(); const theme = useClubTheme(); const location = useLocation(); const navigate = useNavigate(); const toast = useToast(); const menuBg = useColorModeValue('white', '#0f1115'); const dividerColor = useColorModeValue('gray.600', 'gray.300'); const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200'); const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100'); const activeTextColor = useColorModeValue('brand.primary', 'brand.accent'); const navTextColor = useColorModeValue('gray.700', 'gray.200'); const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500'); const [scrolled, setScrolled] = useState(false); const [hasTables, setHasTables] = useState(null); const [hasActivities, setHasActivities] = useState(null); const [hasPlayers, setHasPlayers] = useState(null); const [hasArticles, setHasArticles] = useState(null); const [hasVideos, setHasVideos] = useState(null); const [hasGallery, setHasGallery] = useState(null); const [dynamicNavItems, setDynamicNavItems] = useState([]); const [navLoading, setNavLoading] = useState(true); const containerMaxW = fullWidth ? 'full' as const : '7xl' as const; // Search modal state const [query, setQuery] = useState(''); const submitSearch = () => { const text = query.trim(); if (!text) return; onSearchClose(); setQuery(''); navigate(`/hledat?q=${encodeURIComponent(text)}`); }; useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 8); onScroll(); window.addEventListener('scroll', onScroll, { passive: true } as any); return () => window.removeEventListener('scroll', onScroll as any); }, []); // Open newsletter preferences for logged-in user (fetch token and redirect) const openMyNewsletterPrefs = async () => { try { const { token } = await getMyNewsletterToken(); navigate(`/newsletter/preferences?token=${encodeURIComponent(token)}`); } catch (err: any) { toast({ title: 'Chyba', description: 'Nelze načíst odkaz na e‑mailové preference. Zkuste to prosím znovu.', status: 'error', duration: 4000, }); } }; // Also set document title to club name ASAP (SEO component will refine further) useEffect(() => { const name = settings?.club_name || theme.name; if (name && typeof document !== 'undefined') { document.title = name; } }, [settings?.club_name, theme.name]); // Set favicon/logo in head for fan pages (SPA) useEffect(() => { try { let url = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg'; if (!url) return; // Normalize relative upload paths to API origin so favicon resolves on all pages try { const apiOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin; if (/^\/.+/.test(url) && !/^https?:\/\//i.test(url)) { // If starts with /uploads or any absolute path, prefix API origin url = apiOrigin + url; } } catch {} const setIcon = (rel: string) => { let link = document.querySelector(`link[rel="${rel}"]`); if (!link) { link = document.createElement('link'); link.rel = rel as any; document.head.appendChild(link); } link.href = url; // Try to hint type if svg if (url.endsWith('.svg')) link.type = 'image/svg+xml'; }; setIcon('icon'); setIcon('shortcut icon'); } catch {} }, [settings?.club_logo_url, theme.logoUrl]); // gallery link (generic first, fallback to zonerama) const galleryHref = settings?.gallery_url || settings?.zonerama_url; const galleryLabel = settings?.gallery_label || 'Fotogalerie'; // Load dynamic navigation from API useEffect(() => { let active = true; (async () => { try { const items = await getNavigationItems(); if (active && Array.isArray(items)) { // Filter out admin-only navigation items for public display const publicItems = items.filter(item => !item.requires_admin); // Auto-seed if navigation is empty (only if user is authenticated as admin) if (publicItems.length === 0 && isAdmin) { try { console.log('Navigation empty, auto-seeding...'); await seedDefaultNavigation(); const newItems = await getNavigationItems(); if (active && Array.isArray(newItems)) { const publicNewItems = newItems.filter(item => !item.requires_admin); setDynamicNavItems(publicNewItems); } } catch (seedError) { console.error('Auto-seed failed:', seedError); // Continue with empty navigation } } else { setDynamicNavItems(publicItems); } } } catch (error) { console.error('Failed to load navigation:', error); } finally { if (active) setNavLoading(false); } })(); return () => { active = false }; }, [isAdmin]); // categories: prefer API, fallback to settings.categories const [navCategories, setNavCategories] = useState(null); useEffect(() => { let active = true; (async () => { try { const cats = await getCategories(); if (active && Array.isArray(cats) && cats.length > 0) { setNavCategories(cats); } else if (active && Array.isArray(settings?.categories)) { setNavCategories(settings!.categories as any); } } catch { if (active && Array.isArray(settings?.categories)) { setNavCategories(settings!.categories as any); } } })(); return () => { active = false }; }, [settings?.categories]); // Determine if there is any table data available (prefetch snapshot) useEffect(() => { let disposed = false; const resolveBackendUrl = (path: string) => { try { if (/^https?:\/\//i.test(path)) return path; if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) { const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin; return new URL(path, origin).toString(); } return path; } catch { return path; } }; (async () => { try { const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' }); if (!res.ok) { if (!disposed) setHasTables(false); return; } const json = await res.json(); const anyRows = Array.isArray(json?.competitions) && json.competitions.some((c: any) => Array.isArray(c?.table?.overall) && c.table.overall.length > 0); if (!disposed) setHasTables(!!anyRows); } catch { if (!disposed) setHasTables(false); } })(); return () => { disposed = true; }; }, []); // Determine if there are any activities/events available useEffect(() => { let disposed = false; (async () => { try { const events = await getEvents(); if (!disposed) setHasActivities(Array.isArray(events) && events.length > 0); } catch { if (!disposed) setHasActivities(false); } })(); return () => { disposed = true; }; }, []); // Determine if there are any players available useEffect(() => { let disposed = false; (async () => { try { const players = await getPlayers(); if (!disposed) setHasPlayers(Array.isArray(players) && players.length > 0); } catch { if (!disposed) setHasPlayers(false); } })(); return () => { disposed = true; }; }, []); // Determine if there are any articles available useEffect(() => { let disposed = false; (async () => { try { const result = await getArticles({ page: 1, page_size: 1, published: true }); if (!disposed) setHasArticles(result.total > 0); } catch { if (!disposed) setHasArticles(false); } })(); return () => { disposed = true; }; }, []); // Determine if there are any videos available useEffect(() => { let disposed = false; (async () => { try { const youtube = await getCachedYouTube(); if (!disposed) setHasVideos(youtube && Array.isArray(youtube.videos) && youtube.videos.length > 0); } catch { if (!disposed) setHasVideos(false); } })(); return () => { disposed = true; }; }, []); // Determine if there is any gallery content available useEffect(() => { let disposed = false; (async () => { try { const manifest = await getZoneramaManifestWithFallbacks(); if (!disposed) setHasGallery(Array.isArray(manifest) && manifest.length > 0); } catch { if (!disposed) setHasGallery(false); } })(); return () => { disposed = true; }; }, []); const isPathActive = (to?: string) => { if (!to) return false; // Active when current pathname starts with target (handles subroutes) return location.pathname === to || location.pathname.startsWith(to + '/'); }; // Convert NavigationItem to NavLink format const convertToNavLink = (item: NavigationItem): NavLink => { const link: NavLink = { label: item.label, to: item.url || '#', external: item.type === 'external', }; // Add children for dropdown items if (item.type === 'dropdown' && item.children && item.children.length > 0) { link.items = item.children.map(child => ({ label: child.label, to: child.url || '#', })); } return link; }; // Build categories as items for Články dropdown (fallback) const categoryItems = useMemo(() => { const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : []; return source.map((cat: any) => ({ label: cat.name, to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog') })); }, [navCategories]); // Use dynamic navigation if available, otherwise fallback to hardcoded let NAV_LINKS: NavLink[] = useMemo(() => { if (!navLoading && dynamicNavItems.length > 0) { // Use dynamic navigation from API const navLinks = dynamicNavItems.map(convertToNavLink); // Inject categories into "Články" or "Blog" navigation item if it exists if (categoryItems.length > 0) { const articlesIndex = navLinks.findIndex(link => link.label === 'Články' || link.label === 'Blog' || link.to === '/blog' ); if (articlesIndex !== -1) { // Add or merge categories into the articles navigation item navLinks[articlesIndex] = { ...navLinks[articlesIndex], items: categoryItems }; } } return navLinks; } // Fallback to hardcoded navigation let links: NavLink[] = [ { label: 'Domů', to: '/' }, ...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]), { label: 'Kalendář', to: '/kalendar' }, { label: 'Zápasy', to: '/zapasy' }, { label: 'Aktivity', to: '/aktivity' }, { label: 'Hráči', to: '/hraci' }, { label: 'Tabulky', to: '/tabulky' }, // Články with categories as subcategories categoryItems.length > 0 ? { label: 'Články', to: '/blog', items: categoryItems } : { label: 'Články', to: '/blog' }, { label: 'Videa', to: '/videa' }, { label: galleryLabel, to: '/galerie' }, ...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []), { label: 'Sponzoři', to: '/sponzori' }, { label: 'Kontakt', to: '/kontakt' }, ]; // Inject custom pages from settings.custom_nav (label + url + external?) const customNav = Array.isArray((settings as any)?.custom_nav) ? ((settings as any).custom_nav as any[]) : []; if (customNav.length > 0) { const mapped: NavLink[] = customNav.map((it) => ({ label: String(it.label || 'Stránka'), to: String(it.url || '#'), external: Boolean(it.external) })); const insertIdx = links.findIndex((n) => n.label === 'Tabulky'); if (insertIdx >= 0) { links = [...links.slice(0, insertIdx + 1), ...mapped, ...links.slice(insertIdx + 1)]; } else { links = [...links, ...mapped]; } } // Hide Tabulky when there is no table data if (hasTables === false) { links = links.filter((n) => n.label !== 'Tabulky'); } // Hide Aktivity when there are no activities if (hasActivities === false) { links = links.filter((n) => n.label !== 'Aktivity'); } // Hide Hráči when there are no players if (hasPlayers === false) { links = links.filter((n) => n.label !== 'Hráči'); } // Hide Články when there are no articles if (hasArticles === false) { links = links.filter((n) => n.label !== 'Články'); } // Hide Videa when there are no videos if (hasVideos === false) { links = links.filter((n) => n.label !== 'Videa'); } // Hide Fotogalerie when there is no gallery content if (hasGallery === false) { links = links.filter((n) => n.label === galleryLabel).length === 0 ? links : links.filter((n) => n.label !== galleryLabel); } return links; }, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]); return ( {/* Top bar with socials and quick external links */} {(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && ( {settings?.shop_url && ( )} {normalizeSocialUrl('facebook', settings?.facebook_url) && ( } variant="ghost" size="xs" /> )} {normalizeSocialUrl('instagram', settings?.instagram_url) && ( } variant="ghost" size="xs" /> )} {normalizeSocialUrl('youtube', settings?.youtube_url) && ( } variant="ghost" size="xs" /> )} )} {/* Main Nav Bar */} {/* Club Logo only */} {(settings?.club_logo_url || theme.logoUrl) && ( {settings?.club_name )} {/* Desktop navigation with hover dropdowns */} {NAV_LINKS.map((nav) => { const commonProps = { variant: 'ghost' as const, size: 'sm' as const, px: 3, _hover: { bg: hoverBg, transform: 'translateY(-1px)' }, fontWeight: isPathActive(nav.to) ? '700' : '600', color: isPathActive(nav.to) ? activeTextColor : navTextColor, bg: isPathActive(nav.to) ? activeBg : 'transparent', transition: 'all 0.2s', }; // Handle items with dropdown (like Články with categories) if (nav.items && nav.items.length > 0) { return ( ); } if (nav.external && nav.to) { return ( ); } return ( ); })} {/* Mobile menu button */} } aria-label="Otevřít menu" variant="ghost" mr={2} /> {/* Space reserved (socials moved to top bar) */} {/* Search button */} } size="sm" mr={2} variant="ghost" onClick={onSearchOpen} /> {/* Admin edit button */} {isAdmin && ( } size="sm" mr={2} colorScheme="blue" variant="ghost" /> )} {/* Color mode toggle */} : } /> {/* Auth buttons (desktop) */} {!isAuthenticated && ( <> )} {isAuthenticated && ( Můj účet E‑mailové preference Nastavení stránky {isAdmin && Administrace} Odhlásit se )} {/* Close outer Flex */} {/* Search Modal */} Vyhledávání
{ e.preventDefault(); submitSearch(); }} > setQuery(e.target.value)} autoFocus />
Zadejte klíčová slova pro vyhledávání
); }; // HoverMenu component for desktop dropdown nav const HoverMenu = ({ label, items, isActive }: { label: string; items: { label: string; to: string }[]; isActive?: boolean }) => { const { isOpen, onOpen, onClose } = useDisclosure(); const menuColorActive = useColorModeValue('brand.primary', 'brand.accent'); const menuColorInactive = useColorModeValue('gray.700', 'gray.200'); const menuBgActive = useColorModeValue('blackAlpha.50', 'whiteAlpha.100'); const menuHoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200'); return ( } variant="ghost" size="sm" px={3} fontWeight={isActive ? '700' : '600'} color={isActive ? menuColorActive : menuColorInactive} bg={isActive ? menuBgActive : 'transparent'} _hover={{ bg: menuHoverBg, transform: 'translateY(-1px)' }} transition="all 0.2s" > {label} {items.map((it) => ( {it.label} ))} ); }; export default Navbar; // Search Modal rendered alongside Navbar content // Note: We append the modal inside Navbar return to keep code compact