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 && (
}>
Fanshop
)}
{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) && (
)}
{/* 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 (
} {...commonProps}>
{nav.label}
);
}
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 && (
)}
{/* Close outer Flex */}
{/* Search Modal */}
Vyhledávání
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 (
);
};
export default Navbar;
// Search Modal rendered alongside Navbar content
// Note: We append the modal inside Navbar return to keep code compact