This commit is contained in:
Tomas Dvorak
2025-11-03 19:54:39 +01:00
parent 087f30e82c
commit d5b4faea61
141 changed files with 78770 additions and 966 deletions
+29 -3
View File
@@ -13,6 +13,7 @@ import CookieBanner from './components/CookieBanner';
import ProtectedRoute from './components/ProtectedRoute';
import { getSetupStatus } from './services/setup';
import { useState, useEffect } from 'react';
import { usePublicSettings } from './hooks/usePublicSettings';
// Create a client
const queryClient = new QueryClient({
@@ -40,6 +41,10 @@ const PageLoader = () => (
// Lazy load pages for code splitting
const HomePage = lazy(() => import('./pages/HomePage'));
const BlogPage = lazy(() => import('./pages/BlogPage'));
// Premium pages
const PremiumHomePage = lazy(() => import('./pages/premium/PremiumHomePage'));
const PremiumBlogPage = lazy(() => import('./pages/premium/PremiumBlogPage'));
const PremiumNotFound = lazy(() => import('./pages/premium/PremiumNotFound'));
const ArticleDetailPage = lazy(() => import('./pages/ArticleDetailPage'));
const ActivityDetailPage = lazy(() => import('./pages/ActivityDetailPage'));
const MatchDetailPage = lazy(() => import('./pages/MatchDetailPage'));
@@ -110,6 +115,8 @@ const ScoreboardAdminPage = lazy(() => import('./pages/admin/ScoreboardAdminPage
const MobileScoreboardControlPage = lazy(() => import('./pages/admin/MobileScoreboardControlPage'));
const ShortlinksAdminPage = lazy(() => import('./pages/admin/ShortlinksAdminPage'));
const EngagementAdminPage = lazy(() => import('./pages/admin/EngagementAdminPage'));
const SweepstakesAdminPage = lazy(() => import('./pages/admin/SweepstakesAdminPage'));
const SweepstakeVisualPage = lazy(() => import('./pages/admin/SweepstakeVisualPage'));
const SemiAdminPage = lazy(() => import('./pages/SemiAdminPage'));
// Analytics and font loader
@@ -164,6 +171,23 @@ const AdminRoutesWrapper = () => {
return <Outlet />;
};
// Premium-aware route elements (wait for settings before deciding)
const HomeRoute: React.FC = () => {
const { data, isLoading } = usePublicSettings();
if (isLoading && !data) return <PageLoader />;
return data?.premium ? <PremiumHomePage /> : <HomePage />;
};
const BlogRoute: React.FC = () => {
const { data, isLoading } = usePublicSettings();
if (isLoading && !data) return <PageLoader />;
return data?.premium ? <PremiumBlogPage /> : <BlogPage />;
};
const NotFoundRoute: React.FC = () => {
const { data, isLoading } = usePublicSettings();
if (isLoading && !data) return <PageLoader />;
return data?.premium ? <PremiumNotFound /> : <NotFoundPage />;
};
const AppLazy: React.FC = () => {
return (
<ChakraProvider theme={theme}>
@@ -178,12 +202,12 @@ const AppLazy: React.FC = () => {
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/" element={<HomeRoute />} />
<Route path="/hledat" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
<Route path="/overlay/sponsors" element={<OverlaySponsorsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog" element={<BlogRoute />} />
<Route path="/klub" element={<ClubPage />} />
<Route path="/o-klubu" element={<AboutPage />} />
<Route path="/kalendar" element={<CalendarPage />} />
@@ -267,6 +291,8 @@ const AppLazy: React.FC = () => {
<Route path="/admin/komentare" element={<CommentsAdminPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
</Route>
{/* Legacy admin routes */}
@@ -277,7 +303,7 @@ const AppLazy: React.FC = () => {
<Route path="/admin/settings" element={<ProtectedRoute requiredRole="admin"><SettingsAdminPage /></ProtectedRoute>} />
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
<Route path="*" element={<NotFoundRoute />} />
</Routes>
</Suspense>
<CookieBanner />
+21 -3
View File
@@ -10,6 +10,9 @@ import DashboardPage from './pages/DashboardPage';
import ArticlesListPage from './pages/ArticlesListPage';
import HomePage from './pages/HomePage';
import BlogPage from './pages/BlogPage';
import PremiumHomePage from './pages/premium/PremiumHomePage';
import PremiumBlogPage from './pages/premium/PremiumBlogPage';
import PremiumNotFound from './pages/premium/PremiumNotFound';
import ArticleDetailPage from './pages/ArticleDetailPage';
import ActivityDetailPage from './pages/ActivityDetailPage';
import MatchDetailPage from './pages/MatchDetailPage';
@@ -87,6 +90,7 @@ import PollsPage from './pages/PollsPage';
import { useUmami } from './hooks/useUmami';
import { checkin } from './services/engagement';
import { useFontLoader } from './hooks/useFontLoader';
import { usePublicSettings } from './hooks/usePublicSettings';
// Create a client with better cache configuration
const queryClient = new QueryClient({
@@ -354,6 +358,20 @@ const App: React.FC = () => {
return <Outlet />;
};
// Premium-aware route elements
const HomeRoute: React.FC = () => {
const { data } = usePublicSettings();
return data?.premium ? <PremiumHomePage /> : <HomePage />;
};
const BlogRoute: React.FC = () => {
const { data } = usePublicSettings();
return data?.premium ? <PremiumBlogPage /> : <BlogPage />;
};
const NotFoundRoute: React.FC = () => {
const { data } = usePublicSettings();
return data?.premium ? <PremiumNotFound /> : <NotFoundPage />;
};
return (
<ChakraProvider theme={theme}>
<QueryClientProvider client={queryClient}>
@@ -366,12 +384,12 @@ const App: React.FC = () => {
<DefaultSEO />
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/" element={<HomeRoute />} />
<Route path="/hledat" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
<Route path="/overlay/sponsors" element={<OverlaySponsorsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog" element={<BlogRoute />} />
<Route path="/klub" element={<ClubPage />} />
<Route path="/o-klubu" element={<AboutPage />} />
<Route path="/kalendar" element={<CalendarPage />} />
@@ -558,7 +576,7 @@ const App: React.FC = () => {
/>
{/* Not found route */}
<Route path="*" element={<NotFoundPage />} />
<Route path="*" element={<NotFoundRoute />} />
</Routes>
{/* Cookie consent banner shown across the whole site */}
<CookieBanner />
+142 -16
View File
@@ -1,4 +1,4 @@
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner } from '@chakra-ui/react';
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner, Collapse } from '@chakra-ui/react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import {
@@ -35,6 +35,7 @@ import {
FaComments,
FaGift
} from 'react-icons/fa';
import { ChevronDownIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
import { getUpcomingEvents } from '../../services/eventService';
@@ -149,8 +150,10 @@ const getIconForPageType = (pageType?: string): any => {
users: FaUserShield,
settings: FaPalette,
files: FaFolder,
media: FaFolder,
docs: FaBook,
shortlinks: FaLink,
comments: FaComments,
engagement: FaAward,
sweepstakes: FaGift,
};
@@ -182,19 +185,62 @@ const AdminSidebar = ({
// Dynamic navigation state
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const hasShortlinks = useMemo(() => {
return navItems.some(it => (it.page_type === 'shortlinks') || (it.url === '/admin/shortlinks'));
// presence checks consider children as well
const hasItemDeep = useCallback((predicate: (it: NavigationItem) => boolean): boolean => {
const check = (it: NavigationItem): boolean => {
if (predicate(it)) return true;
if (Array.isArray(it.children)) {
return it.children.some(check);
}
return false;
};
return navItems.some(check);
}, [navItems]);
const hasEngagement = useMemo(() => {
return navItems.some(it => (it.page_type === 'engagement') || (it.url === '/admin/engagement'));
}, [navItems]);
const hasComments = useMemo(() => {
return navItems.some(it => (it.page_type === 'comments') || (it.url === '/admin/komentare'));
}, [navItems]);
const hasSweepstakes = useMemo(() => {
return navItems.some(it => (it.page_type === 'sweepstakes') || (it.url === '/admin/sweepstakes'));
const hasShortlinks = useMemo(() => hasItemDeep(it => (it.page_type === 'shortlinks') || (it.url === '/admin/shortlinks')), [hasItemDeep]);
const hasEngagement = useMemo(() => hasItemDeep(it => (it.page_type === 'engagement') || (it.url === '/admin/engagement')), [hasItemDeep]);
const hasComments = useMemo(() => hasItemDeep(it => (it.page_type === 'comments') || (it.url === '/admin/komentare')), [hasItemDeep]);
const hasSweepstakes = useMemo(() => hasItemDeep(it => (it.page_type === 'sweepstakes') || (it.url === '/admin/sweepstakes')), [hasItemDeep]);
const hasCompetitionAliases = useMemo(() => hasItemDeep(it => (it.page_type === 'competition_aliases') || (it.url === '/admin/aliasy-soutezi')), [hasItemDeep]);
const hasClothing = useMemo(() => hasItemDeep(it => (it.page_type === 'clothing') || (it.url === '/admin/obleceni')), [hasItemDeep]);
const hasMedia = useMemo(() => hasItemDeep(it => (it.page_type === 'media') || (it.url === '/admin/media')), [hasItemDeep]);
// Collapsed state for admin categories (dropdown items)
type CollapsedMap = Record<number, boolean>;
const COLLAPSE_KEY = 'admin-sidebar-collapsed-v1';
const [collapsed, setCollapsed] = useState<CollapsedMap>({});
useEffect(() => {
try {
const raw = localStorage.getItem(COLLAPSE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as CollapsedMap;
if (parsed && typeof parsed === 'object') setCollapsed(parsed);
}
} catch {}
}, []);
useEffect(() => {
// Ensure keys exist for current dropdown categories
setCollapsed(prev => {
const next: CollapsedMap = { ...prev };
navItems.forEach(it => {
if (it.type === 'dropdown' && typeof it.id === 'number' && typeof next[it.id] === 'undefined') {
next[it.id] = false; // default expanded
}
});
return next;
});
}, [navItems]);
const toggleCollapsed = useCallback((id?: number) => {
if (!id) return;
setCollapsed(prev => {
const next = { ...prev, [id]: !prev[id] } as CollapsedMap;
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch {}
return next;
});
}, []);
// Restore scroll on mount
useEffect(() => {
const node = scrollRef.current;
@@ -345,16 +391,66 @@ const AdminSidebar = ({
<Spinner size="sm" />
</Flex>
) : navItems.length > 0 ? (
// Render dynamic navigation
// Render dynamic navigation with collapsible categories
<>
{navItems.filter(item => item.visible).map((item, index) => {
const isCategory = item.type === 'dropdown';
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
const catCollapsed = !!(item.id && collapsed[item.id]);
const categoryHeader = (
<Box key={`cat-${item.id || index}`} px={2} py={2} onClick={() => toggleCollapsed(item.id)} cursor="pointer" role="button" aria-expanded={!catCollapsed}>
<Flex align="center" gap={2}>
<Box flex="1" height="1px" bg={useColorModeValue('gray.200','whiteAlpha.300')} />
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider" color={useColorModeValue('gray.600','gray.300')}>
{item.label}
</Text>
<Icon as={catCollapsed ? ChevronRightIcon : ChevronDownIcon} boxSize={3.5} color={useColorModeValue('gray.500','gray.400')} />
<Box flex="1" height="1px" bg={useColorModeValue('gray.200','whiteAlpha.300')} />
</Flex>
</Box>
);
if (isCategory) {
return (
<Box key={item.id || index}>
{categoryHeader}
{hasChildren && (
<Collapse in={!catCollapsed} animateOpacity unmountOnExit>
<VStack align="stretch" spacing={1} px={1}>
{item.children!.filter(c => c.visible).map((child, cidx) => {
const childIcon = getIconForPageType(child.page_type);
const childUrl = child.url || '#';
const showBadge = child.page_type === 'activities' && upcomingCount > 0;
return (
<NavItem
key={child.id || `${item.id}-c-${cidx}`}
icon={childIcon}
to={childUrl}
onClick={onClose}
>
<Text as="span">
{child.label}
{showBadge && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
);
})}
</VStack>
</Collapse>
)}
</Box>
);
}
// Non-category top-level item
const itemIcon = getIconForPageType(item.page_type);
const itemUrl = item.url || '#';
// Add badge for activities showing upcoming count
const isActivities = item.page_type === 'activities';
const showBadge = isActivities && upcomingCount > 0;
return (
<NavItem
key={item.id || index}
@@ -373,7 +469,7 @@ const AdminSidebar = ({
</NavItem>
);
})}
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
{!hasShortlinks && (
<NavItem
@@ -416,6 +512,36 @@ const AdminSidebar = ({
Soutěže
</NavItem>
)}
{/* Ensure Competition Aliases is present even if not configured in dynamic nav */}
{!hasCompetitionAliases && (
<NavItem
icon={FaAward}
to="/admin/aliasy-soutezi"
onClick={onClose}
>
Alias soutěží
</NavItem>
)}
{/* Ensure Media Library is present even if not configured in dynamic nav */}
{!hasMedia && (
<NavItem
icon={FaFolder}
to="/admin/media"
onClick={onClose}
>
Média
</NavItem>
)}
{/* Ensure Clothing is present even if not configured in dynamic nav */}
{!hasClothing && (
<NavItem
icon={FaTshirt}
to="/admin/obleceni"
onClick={onClose}
>
Oblečení
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
+3 -3
View File
@@ -5,8 +5,8 @@ export const usePublicSettings = () =>
useQuery<PublicSettings>({
queryKey: ['public-settings'],
queryFn: getPublicSettings,
staleTime: 10 * 60 * 1000, // 10 minutes - settings don't change often
cacheTime: 30 * 60 * 1000, // 30 minutes - keep in cache longer
staleTime: 0,
cacheTime: 30 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnMount: true,
});
+12 -5
View File
@@ -36,7 +36,7 @@ import { assetUrl } from '../utils/url';
const SearchPage: React.FC = () => {
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const initial = String(params.get('q') || '');
const initial = String(params.get('q') || params.get('s') || '');
const [q, setQ] = useState(initial);
const [debounced, setDebounced] = useState(initial);
const [loading, setLoading] = useState(false);
@@ -106,10 +106,17 @@ const SearchPage: React.FC = () => {
// Trigger on load and when debounced query changes
useEffect(() => {
const current = String(params.get('q') || '');
if (debounced !== current) {
const currentQ = String(params.get('q') || '');
const currentS = String(params.get('s') || '');
if (debounced !== currentQ || debounced !== currentS) {
const next = new URLSearchParams(params.toString());
if (debounced) next.set('q', debounced); else next.delete('q');
if (debounced) {
next.set('q', debounced);
next.set('s', debounced);
} else {
next.delete('q');
next.delete('s');
}
setParams(next, { replace: true });
}
setMatchLimit(10);
@@ -135,7 +142,7 @@ const SearchPage: React.FC = () => {
setQ(next);
setDebounced(next);
const sp = new URLSearchParams(params.toString());
if (next) sp.set('q', next); else sp.delete('q');
if (next) { sp.set('q', next); sp.set('s', next); } else { sp.delete('q'); sp.delete('s'); }
navigate(`/hledat?${sp.toString()}`);
};
+70 -3
View File
@@ -5,6 +5,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
import { getArticles } from '../../services/articles';
import { getEvents } from '../../services/eventService';
import { getCachedYouTube } from '../../services/youtube';
import api from '../../services/api';
import { adminListUsers } from '../../services/admin/engagement';
const CommentsAdminPage: React.FC = () => {
const [status, setStatus] = React.useState<string>('');
@@ -16,6 +21,11 @@ const CommentsAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const [targetOptions, setTargetOptions] = React.useState<Array<{ value: string; label: string }>>([]);
const [targetLoading, setTargetLoading] = React.useState<boolean>(false);
const [userOptions, setUserOptions] = React.useState<Array<{ value: string; label: string }>>([]);
const [userLoading, setUserLoading] = React.useState<boolean>(false);
const listQ = useQuery({
queryKey: ['admin-comments', { status, targetType, targetId, userId, page }],
queryFn: () => adminListComments({ status: status as any, target_type: targetType, target_id: targetId, user_id: userId, page, page_size: 50 }),
@@ -50,6 +60,52 @@ const CommentsAdminPage: React.FC = () => {
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
});
React.useEffect(() => {
const load = async () => {
if (!targetType) { setTargetOptions([]); return; }
try {
setTargetLoading(true);
if (targetType === 'article') {
const res = await getArticles({ page: 1, page_size: 100 });
setTargetOptions((res.data || []).map((a: any) => ({ value: String(a.id), label: `${a.title} (#${a.id})` })));
} else if (targetType === 'event') {
const res = await getEvents();
setTargetOptions((res || []).map((e: any) => ({ value: String(e.id), label: `${e.title} (#${e.id})` })));
} else if (targetType === 'gallery_album') {
const r = await api.get('/gallery/albums');
const arr = Array.isArray(r.data) ? r.data : (r.data?.data || r.data?.albums || []);
setTargetOptions((arr || []).map((al: any) => ({ value: String(al.id), label: `${al.title} (${al.date || ''})` })));
} else if (targetType === 'youtube_video') {
const yt = await getCachedYouTube();
const vids = yt?.videos || [];
setTargetOptions(vids.map((v: any) => ({ value: String(v.video_id), label: `${v.title} (${v.published_date || ''})` })));
} else {
setTargetOptions([]);
}
} catch (e: any) {
setTargetOptions([]);
} finally {
setTargetLoading(false);
}
};
load();
}, [targetType]);
React.useEffect(() => {
const loadUsers = async () => {
try {
setUserLoading(true);
const users = await adminListUsers();
setUserOptions((users || []).map((u: any) => ({ value: String(u.id), label: `${u.name || u.email} (#${u.id})` })));
} catch {
setUserOptions([]);
} finally {
setUserLoading(false);
}
};
loadUsers();
}, []);
const itemsAll = listQ.data?.items || [];
const items = React.useMemo(() => {
if (!reportedOnly) return itemsAll;
@@ -66,18 +122,29 @@ const CommentsAdminPage: React.FC = () => {
<option value="visible">Viditelné</option>
<option value="hidden">Skryté</option>
</Select>
<Select placeholder="Typ cíle" value={targetType} onChange={(e) => { setTargetType(e.target.value); setPage(1); }} maxW="220px">
<Select placeholder="Typ cíle" value={targetType} onChange={(e) => { setTargetType(e.target.value); setPage(1); setTargetId(''); }} maxW="220px">
<option value="article">Článek</option>
<option value="event">Aktivita</option>
<option value="gallery_album">Galerie</option>
<option value="youtube_video">YouTube video</option>
</Select>
<Input placeholder="Target ID" value={targetId} onChange={(e) => { setTargetId(e.target.value); setPage(1); }} maxW="200px" />
<Input placeholder="User ID" value={userId} onChange={(e) => { setUserId(e.target.value); setPage(1); }} maxW="200px" />
{targetType && (
<Select placeholder={targetLoading ? 'Načítání…' : 'Cíl'} value={targetId} onChange={(e) => { setTargetId(e.target.value); setPage(1); }} maxW="320px" isDisabled={targetLoading}>
{targetOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Select>
)}
<Select placeholder={userLoading ? 'Načítání uživatelů…' : 'Uživatel'} value={userId} onChange={(e) => { setUserId(e.target.value); setPage(1); }} maxW="260px" isDisabled={userLoading}>
{userOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Select>
<HStack>
<Text fontSize="sm" color="gray.500">Jen nahlášené</Text>
<Switch isChecked={reportedOnly} onChange={(e)=>setReportedOnly(e.target.checked)} />
</HStack>
<Button size="sm" variant="ghost" onClick={() => { setStatus(''); setTargetType(''); setTargetId(''); setUserId(''); setReportedOnly(false); setPage(1); }}>Reset</Button>
</HStack>
</VStack>
+145 -55
View File
@@ -37,7 +37,8 @@ import {
ModalBody,
ModalFooter,
ModalCloseButton,
Textarea,
Wrap,
WrapItem,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -52,6 +53,8 @@ import {
adminAdjustPoints,
AdminRewardItem,
AdminRedemption,
adminListUsers,
type AdminUserListItem,
} from '../../services/admin/engagement';
import { FiTrash2, FiEdit2 } from 'react-icons/fi';
import api from '../../services/api';
@@ -84,7 +87,7 @@ const EngagementAdminPage: React.FC = () => {
const [editItem, setEditItem] = React.useState<AdminRewardItem | null>(null);
const editModal = useDisclosure();
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
const [editMetaJson, setEditMetaJson] = React.useState<string>('');
// Remove raw JSON editing, keep structured metadata only
const [batch, setBatch] = React.useState({
base_url: '',
@@ -97,12 +100,52 @@ const EngagementAdminPage: React.FC = () => {
active: true,
});
const batchModal = useDisclosure();
const [metaJson, setMetaJson] = React.useState<string>('');
// Structured metadata state (used for merch types, coupons, etc.)
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [meta, setMeta] = React.useState<Record<string, any>>({});
const editFileInputRef = React.useRef<HTMLInputElement | null>(null);
const [editMeta, setEditMeta] = React.useState<Record<string, any>>({});
// Users list for dropdowns and labels
const usersQ = useQuery({
queryKey: ['admin-users'],
queryFn: adminListUsers,
staleTime: 30000,
});
const usersById = React.useMemo(() => {
const m = new Map<number, AdminUserListItem>();
(usersQ.data || []).forEach((u) => m.set(u.id, u));
return m;
}, [usersQ.data]);
// Reward template selector instead of many buttons
const [template, setTemplate] = React.useState<string>('avatar_upload_unlock');
const applyTemplate = (tpl: string) => {
setTemplate(tpl);
switch (tpl) {
case 'avatar_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_upload_unlock', cost_points: 250, stock: 0, image_url: '' }));
break;
case 'avatar_animated_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_animated_upload_unlock', cost_points: 150, stock: 0 }));
break;
case 'avatar_static_50':
setForm((prev) => ({ ...prev, type: 'avatar_static', cost_points: 50 }));
break;
case 'merch_coupon_1000':
setForm((prev) => ({ ...prev, type: 'merch_coupon', cost_points: 1000 }));
break;
case 'merch_coupon_2000':
setForm((prev) => ({ ...prev, type: 'merch_coupon', cost_points: 2000 }));
break;
case 'merch_physical_4000':
setForm((prev) => ({ ...prev, type: 'merch_physical', cost_points: 4000, stock: 1 }));
break;
default:
break;
}
};
const handleUpload = async (file?: File) => {
try {
const f = file || fileInputRef.current?.files?.[0];
@@ -138,27 +181,20 @@ const EngagementAdminPage: React.FC = () => {
const setMetaField = (k: string, v: string) => {
const next = { ...meta, [k]: v };
setMeta(next);
setMetaJson(JSON.stringify(next, null, 2));
};
const setEditMetaField = (k: string, v: string) => {
const next = { ...editMeta, [k]: v };
setEditMeta(next);
setEditMetaJson(JSON.stringify(next, null, 2));
};
const createMut = useMutation({
mutationFn: async () => {
let metadata: Record<string, any> | undefined = undefined;
const txt = metaJson.trim();
if (txt) {
try { metadata = JSON.parse(txt); }
catch { throw new Error('Metadata není validní JSON'); }
}
// Auto-generate metadata from structured fields
const metadata = Object.keys(meta).length ? meta : undefined;
return adminCreateReward({ ...form, metadata });
},
onSuccess: async () => {
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: 0, active: true });
setMetaJson('');
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
toast({ status: 'success', title: 'Odměna vytvořena' });
},
@@ -279,15 +315,24 @@ const EngagementAdminPage: React.FC = () => {
<Box>
<Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading>
<VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}>
<HStack spacing={2}>
<Button size="sm" onClick={() => setForm({ ...form, type: 'avatar_static', cost_points: 50 })}>Avatar (50b ~ 5 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'avatar_animated_upload_unlock', cost_points: 150 })}>Odemknout animovaný upload (150b ~ 15 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'avatar_upload_unlock', cost_points: 250 })}>Odemknout upload (250b ~ 25 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_coupon', cost_points: 1000 })}>Kupon (1000b ~ 100 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_coupon', cost_points: 2000 })}>Kupon (2000b ~ 200 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_physical', cost_points: 4000, stock: 1 })}>Fyzická odměna (4000b ~ 400 )</Button>
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
</HStack>
<Wrap spacing={2}>
<WrapItem>
<FormControl>
<FormLabel m={0} fontSize="sm">Šablona odměny</FormLabel>
<Select size="sm" maxW="280px" value={template} onChange={(e)=>applyTemplate(e.target.value)}>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru (250b)</option>
<option value="avatar_animated_upload_unlock">Odemknutí animovaného avataru (150b)</option>
<option value="avatar_static_50">Avatar (statický) 50b</option>
<option value="merch_coupon_1000">Merch kupon (1000b)</option>
<option value="merch_coupon_2000">Merch kupon (2000b)</option>
<option value="merch_physical_4000">Fyzická odměna (4000b)</option>
</Select>
</FormControl>
</WrapItem>
<WrapItem>
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
</WrapItem>
</Wrap>
<HStack align="start" spacing={4}>
<VStack align="stretch" spacing={3} flex={1}>
<FormControl>
@@ -380,11 +425,7 @@ const EngagementAdminPage: React.FC = () => {
</HStack>
</VStack>
)}
<FormControl>
<FormLabel>Metadata (JSON)</FormLabel>
<Textarea placeholder='např. {"coupon_code":"ABC123","note":"vyzvednout na recepci"}' value={metaJson} onChange={(e)=>setMetaJson(e.target.value)} rows={4} />
<FormHelperText>Volitelné. U merch kuponů lze uložit kód, poznámku, apod.</FormHelperText>
</FormControl>
{/* Odstraněno: ruční JSON metadata. Metadata se vyplňují automaticky z polí výše. */}
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
@@ -453,7 +494,7 @@ const EngagementAdminPage: React.FC = () => {
</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => { setEditItem(r); setEditForm(r); setEditMetaJson(JSON.stringify(r.metadata || {}, null, 2)); editModal.onOpen(); }} />
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => { setEditItem(r); setEditForm(r); setEditMeta(r.metadata || {}); editModal.onOpen(); }} />
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
</HStack>
</Td>
@@ -482,7 +523,16 @@ const EngagementAdminPage: React.FC = () => {
{redemptions.map((d: AdminRedemption) => (
<Tr key={d.id}>
<Td>#{d.id}</Td>
<Td>#{d.user_id}</Td>
<Td>
{usersById.get(d.user_id as any)?.name ? (
<HStack spacing={1}>
<Text noOfLines={1}>{usersById.get(d.user_id as any)?.name}</Text>
<Text color="gray.500" fontSize="xs">#{d.user_id}</Text>
</HStack>
) : (
<Text>#{d.user_id}</Text>
)}
</Td>
<Td>
<HStack>
<Text>#{d.reward_id}</Text>
@@ -561,7 +611,7 @@ const EngagementAdminPage: React.FC = () => {
<input ref={editFileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUploadEdit(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => editFileInputRef.current?.click()}>Nahrát obrázek</Button>
</HStack>
{/* Edit metadata helpers */}
{/* Edit metadata helpers (structured) */}
{ (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && (
<VStack align="stretch" spacing={2}>
{editForm.type === 'merch_coupon' && (
@@ -602,10 +652,7 @@ const EngagementAdminPage: React.FC = () => {
)}
</VStack>
)}
<FormControl>
<FormLabel>Metadata (JSON)</FormLabel>
<Textarea value={editMetaJson} onChange={(e)=>setEditMetaJson(e.target.value)} rows={4} />
</FormControl>
{/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} />
@@ -618,13 +665,7 @@ const EngagementAdminPage: React.FC = () => {
<Button onClick={editModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={updateMut.isPending} onClick={async ()=>{
if (!editItem) return;
let metadata: Record<string, any> | undefined = undefined;
const txt = editMetaJson.trim();
if (txt) {
try { metadata = JSON.parse(txt); } catch { toast({ status:'error', title:'Metadata není validní JSON' }); return; }
} else {
metadata = {} as any;
}
const metadata: Record<string, any> | undefined = Object.keys(editMeta || {}).length ? (editMeta as any) : {} as any;
await updateMut.mutateAsync({ id: editItem.id, body: {
name: editForm.name,
type: editForm.type,
@@ -722,6 +763,8 @@ const TransactionsAndAdjust: React.FC = () => {
const [limit, setLimit] = React.useState<number>(100);
const qc = useQueryClient();
const toast = useToast();
// Users for dropdowns
const usersQ = useQuery({ queryKey: ['admin-users'], queryFn: adminListUsers, staleTime: 30000 });
const txQ = useQuery({
queryKey: ['admin-engagement-tx', { userId, reason, limit }],
queryFn: async () => {
@@ -735,19 +778,17 @@ const TransactionsAndAdjust: React.FC = () => {
const [adjUserId, setAdjUserId] = React.useState<string>('');
const [adjDelta, setAdjDelta] = React.useState<string>('');
const [adjReason, setAdjReason] = React.useState<string>('admin_adjust');
const [adjMeta, setAdjMeta] = React.useState<string>('');
const passwordModal = useDisclosure();
const [currentPassword, setCurrentPassword] = React.useState<string>('');
const adjustMut = useMutation({
mutationFn: async () => {
const uid = Number(adjUserId);
const delta = Number(adjDelta);
if (!uid || !delta) throw new Error('Zadejte platné user_id a delta');
let meta: any = undefined;
const t = adjMeta.trim();
if (t) { try { meta = JSON.parse(t); } catch { throw new Error('Metadata není validní JSON'); } }
return adminAdjustPoints({ user_id: uid, delta, reason: adjReason.trim() || 'admin_adjust', meta });
return adminAdjustPoints({ user_id: uid, delta, reason: adjReason.trim() || 'admin_adjust', current_password: currentPassword });
},
onSuccess: async () => {
setAdjDelta(''); setAdjMeta('');
setAdjDelta(''); setCurrentPassword(''); passwordModal.onClose();
await qc.invalidateQueries({ queryKey: ['admin-engagement-tx'] });
toast({ status: 'success', title: 'Upraveno' });
},
@@ -756,9 +797,19 @@ const TransactionsAndAdjust: React.FC = () => {
return (
<VStack align="stretch" spacing={3}>
<HStack>
<Input placeholder="User ID" value={userId} onChange={(e)=>setUserId(e.target.value)} maxW="160px" />
<Input placeholder="Důvod" value={reason} onChange={(e)=>setReason(e.target.value)} maxW="220px" />
<HStack flexWrap="wrap" rowGap={2}>
<Select placeholder="Všichni uživatelé" value={userId} onChange={(e)=>setUserId(e.target.value)} maxW="260px">
{(usersQ.data || []).map(u => (
<option key={u.id} value={String(u.id)}>{u.name || u.email} (#{u.id})</option>
))}
</Select>
<Select placeholder="Důvod" value={reason} onChange={(e)=>setReason(e.target.value)} maxW="220px">
<option value="daily_checkin">daily_checkin</option>
<option value="article_read">article_read</option>
<option value="redeem">redeem</option>
<option value="redeem_refund">redeem_refund</option>
<option value="admin_adjust">admin_adjust</option>
</Select>
<NumberInput value={limit} min={10} max={1000} onChange={(_v,n)=>setLimit(Number.isFinite(n)? n : 100)} maxW="160px">
<NumberInputField />
</NumberInput>
@@ -780,7 +831,16 @@ const TransactionsAndAdjust: React.FC = () => {
{(txQ.data || []).map((t: any) => (
<Tr key={t.id}>
<Td>#{t.id}</Td>
<Td>#{t.user_id}</Td>
<Td>
{ (usersQ.data || []).find((u:any)=>u.id===t.user_id)?.name ? (
<HStack spacing={1}>
<Text noOfLines={1}>{(usersQ.data || []).find((u:any)=>u.id===t.user_id)?.name}</Text>
<Text color="gray.500" fontSize="xs">#{t.user_id}</Text>
</HStack>
) : (
<Text>#{t.user_id}</Text>
)}
</Td>
<Td>{t.delta}</Td>
<Td><Badge>{t.reason}</Badge></Td>
<Td><Text fontSize="xs" noOfLines={1}>{t.meta ? JSON.stringify(t.meta) : '-'}</Text></Td>
@@ -792,13 +852,43 @@ const TransactionsAndAdjust: React.FC = () => {
</Box>
<Heading size="xs" mt={4}>Manuální úprava bodů</Heading>
<VStack align="stretch" spacing={2}>
<HStack>
<Input placeholder="User ID" value={adjUserId} onChange={(e)=>setAdjUserId(e.target.value)} maxW="160px" />
<HStack flexWrap="wrap" rowGap={2}>
<Select placeholder="Vyberte uživatele" value={adjUserId} onChange={(e)=>setAdjUserId(e.target.value)} maxW="260px">
{(usersQ.data || []).map(u => (
<option key={u.id} value={String(u.id)}>{u.name || u.email} (#{u.id})</option>
))}
</Select>
<Input placeholder="Delta (+/-)" value={adjDelta} onChange={(e)=>setAdjDelta(e.target.value)} maxW="160px" />
<Input placeholder="Důvod (admin_adjust)" value={adjReason} onChange={(e)=>setAdjReason(e.target.value)} maxW="240px" />
<Select value={adjReason} onChange={(e)=>setAdjReason(e.target.value)} maxW="240px">
<option value="admin_adjust">admin_adjust</option>
<option value="bonus">bonus</option>
<option value="penalty">penalty</option>
</Select>
</HStack>
<Textarea placeholder='Metadata (JSON)' value={adjMeta} onChange={(e)=>setAdjMeta(e.target.value)} rows={3} />
<Button colorScheme="blue" size="sm" onClick={()=>adjustMut.mutate()} isLoading={adjustMut.isPending}>Upravit body</Button>
<Button colorScheme="blue" size="sm" onClick={()=>passwordModal.onOpen()} isLoading={adjustMut.isPending} isDisabled={!adjUserId || !adjDelta}>Upravit body</Button>
{/* Password confirmation modal */}
<Modal isOpen={passwordModal.isOpen} onClose={passwordModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Potvrzení úpravy bodů</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<Text>Potvrďte akci zadáním vašeho administračního hesla.</Text>
<FormControl>
<FormLabel>Heslo administrátora</FormLabel>
<Input type="password" value={currentPassword} onChange={(e)=>setCurrentPassword(e.target.value)} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={passwordModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" onClick={()=>adjustMut.mutate()} isLoading={adjustMut.isPending} isDisabled={!currentPassword.trim()}>Potvrdit</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
</VStack>
);
@@ -132,17 +132,27 @@ const ADMIN_PAGE_PRESETS = [
{ value: 'activities', label: 'Aktivity', url: '/admin/aktivity' },
{ value: 'players', label: 'Hráči', url: '/admin/hraci' },
{ value: 'articles', label: 'Články', url: '/admin/clanky' },
{ value: 'categories', label: 'Kategorie', url: '/admin/kategorie' },
{ value: 'comments', label: 'Komentáře', url: '/admin/komentare' },
{ value: 'about', label: 'O klubu', url: '/admin/o-klubu' },
{ value: 'videos', label: 'Videa', url: '/admin/videa' },
{ value: 'gallery', label: 'Galerie', url: '/admin/galerie' },
{ value: 'banners', label: 'Bannery', url: '/admin/bannery' },
{ value: 'clothing', label: 'Oblečení', url: '/admin/obleceni' },
{ value: 'sponsors', label: 'Sponzoři', url: '/admin/sponzori' },
{ value: 'messages', label: 'Zprávy', url: '/admin/zpravy' },
{ value: 'contacts', label: 'Kontakty', url: '/admin/kontakty' },
{ value: 'newsletter', label: 'Zpravodaj', url: '/admin/newsletter' },
{ value: 'polls', label: 'Ankety', url: '/admin/ankety' },
{ value: 'sweepstakes', label: 'Soutěže', url: '/admin/sweepstakes' },
{ value: 'engagement', label: 'Odměny & Úspěchy', url: '/admin/engagement' },
{ value: 'navigation', label: 'Navigace', url: '/admin/navigace' },
{ value: 'competition_aliases', label: 'Alias soutěží', url: '/admin/aliasy-soutezi' },
{ value: 'users', label: 'Uživatelé', url: '/admin/uzivatele' },
{ value: 'settings', label: 'Nastavení', url: '/admin/nastaveni' },
{ value: 'files', label: 'Soubory', url: '/admin/soubory' },
{ value: 'scoreboard', label: 'Tabule (Scoreboard)', url: '/admin/scoreboard' },
{ value: 'scoreboard_remote', label: 'Scoreboard Remote', url: '/admin/scoreboard/remote' },
{ value: 'prefetch', label: 'Prefetch', url: '/admin/prefetch' },
{ value: 'docs', label: 'Dokumentace', url: '/admin/docs' },
{ value: 'webmail', label: 'Webmail', url: 'https://webmail.example.com' },
@@ -1126,9 +1136,24 @@ const NavigationAdminPage = () => {
<option value="activities">Aktivity (/admin/aktivity)</option>
<option value="players">Hráči (/admin/hraci)</option>
<option value="articles">Články (/admin/clanky)</option>
<option value="categories">Kategorie (/admin/kategorie)</option>
<option value="comments">Komentáře (/admin/komentare)</option>
<option value="videos">Videa (/admin/videa)</option>
<option value="gallery">Galerie (/admin/galerie)</option>
</optgroup>
<optgroup label="Marketing">
<option value="sponsors">Sponzoři (/admin/sponzori)</option>
<option value="banners">Bannery (/admin/bannery)</option>
<option value="shortlinks">Zkrácené odkazy (/admin/shortlinks)</option>
<option value="polls">Ankety (/admin/ankety)</option>
<option value="sweepstakes">Soutěže (/admin/sweepstakes)</option>
<option value="engagement">Odměny & Úspěchy (/admin/engagement)</option>
</optgroup>
<optgroup label="Nástroje">
<option value="scoreboard">Tabule (Scoreboard) (/admin/scoreboard)</option>
<option value="scoreboard_remote">Scoreboard Remote (/admin/scoreboard/remote)</option>
<option value="clothing">Oblečení (/admin/obleceni)</option>
</optgroup>
<optgroup label="Komunikace">
<option value="messages">Zprávy (/admin/zpravy)</option>
<option value="contacts">Kontakty (/admin/kontakty)</option>
@@ -1136,6 +1161,7 @@ const NavigationAdminPage = () => {
</optgroup>
<optgroup label="Nastavení">
<option value="navigation">Navigace (/admin/navigace)</option>
<option value="competition_aliases">Alias soutěží (/admin/aliasy-soutezi)</option>
<option value="users">Uživatelé (/admin/uzivatele)</option>
<option value="settings">Nastavení (/admin/nastaveni)</option>
<option value="files">Soubory (/admin/soubory)</option>
+13 -1
View File
@@ -230,7 +230,7 @@ const PlayersAdminPage: React.FC = () => {
// 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: '' });
const openCreate = () => { setEditing({ first_name: '', last_name: '', is_active: true, email: '', phone: '' } as any); setDobFromDateStr(''); onOpen(); };
const openCreate = () => { setEditing({ first_name: '', last_name: '', is_active: true, email: '', phone: '', gender: '' } as any); setDobFromDateStr(''); onOpen(); };
const openEdit = (p: Player) => { setEditing({ ...p }); setDobFromDateStr(p.date_of_birth || ''); onOpen(); };
const closeModal = () => { setEditing(null); onClose(); };
@@ -336,6 +336,7 @@ const PlayersAdminPage: React.FC = () => {
payload.weight = editing.weight;
}
if (editing.image_url) payload.image_url = editing.image_url;
if ((editing as any).gender) payload.gender = (editing as any).gender;
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
const email = ((editing as any).email || '').trim();
const phone = ((editing as any).phone || '').trim();
@@ -367,6 +368,7 @@ const PlayersAdminPage: React.FC = () => {
<Th w="80px">Fotka</Th>
<Th>Jméno</Th>
<Th>Pozice</Th>
<Th>Pohlaví</Th>
<Th>Národnost</Th>
<Th w="120px">Číslo</Th>
<Th w="120px">Aktivní</Th>
@@ -388,6 +390,7 @@ const PlayersAdminPage: React.FC = () => {
</Td>
<Td>{p.first_name} {p.last_name}</Td>
<Td>{p.position || '-'}</Td>
<Td>{p.gender ? (String(p.gender).toLowerCase() === 'women' ? 'Žena' : 'Muž') : '-'}</Td>
<Td>
{p.nationality ? (
<HStack spacing={2}>
@@ -461,6 +464,15 @@ const PlayersAdminPage: React.FC = () => {
</Select>
</FormControl>
<FormControl>
<FormLabel>Pohlaví</FormLabel>
<Select value={(editing as any)?.gender || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), gender: e.target.value }))}>
<option value=""> nevybráno </option>
<option value="men">Muži</option>
<option value="women">Ženy</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Číslo dresu</FormLabel>
<NumberInput min={JERSEY_MIN} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) && v >= 0 ? v : undefined }))}>
@@ -201,7 +201,6 @@ const SettingsAdminPage: React.FC = () => {
api_base_url: (settings as any).api_base_url,
// homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any,
storage_quota_mb: (settings as any).storage_quota_mb as any,
storage_warn_threshold: (settings as any).storage_warn_threshold as any,
storage_critical_threshold: (settings as any).storage_critical_threshold as any,
};
@@ -282,15 +281,6 @@ const SettingsAdminPage: React.FC = () => {
<Heading size="sm">Úložiště souborů</Heading>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
<FormControl>
<FormLabel>Kapacita úložiště (MB)</FormLabel>
<Input
type="number"
min={0}
value={(settings as any).storage_quota_mb ?? 15360}
onChange={handleNumChange('storage_quota_mb' as any)}
/>
</FormControl>
<FormControl>
<FormLabel>Varování při (%)</FormLabel>
<Input
@@ -0,0 +1,181 @@
import React from 'react';
import { getBackendOrigin } from '../../utils/url';
const fontLinks: Array<{ rel: string; href: string; crossOrigin?: string }> = [
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: '' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Open+Sans:400,400i,600,700%7CSofia+Sans+Extra+Condensed:800,300i' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Open+Sans:100,100italic,200,200italic,300,300italic,400,400italic,500,500italic,600,600italic,700,700italic,800,800italic,900,900italic|Marcellus|Tangerine&display=auto' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' },
];
const cssList: string[] = [
// Keep order similar to pro/index.html
'/premium-assets/css/swiper.css',
'/premium-assets/css/bootstrap.css',
'/premium-assets/css/bizoni.css',
'/premium-assets/css/overrides.css',
'/premium-assets/css/elementor-icons.min.css',
'/premium-assets/css/custom-frontend.min.css',
'/premium-assets/css/post-13200.css',
'/premium-assets/css/post-32647.css',
'/premium-assets/css/rsvp.min.css',
'/premium-assets/css/magnific-popup.css',
'/premium-assets/css/v4-shims.min.css',
'/premium-assets/css/lte-font-codes.css',
'/premium-assets/css/zoom-slider.css',
'/premium-assets/css/post-36123.css',
'/premium-assets/css/post-36124.css',
'/premium-assets/css/post-35532.css',
'/premium-assets/css/post-36129.css',
'/premium-assets/css/post-36131.css',
'/premium-assets/css/post-20251.css',
'/premium-assets/css/post-29393.css',
// Heavier base styles (append at end to minimize overrides issues)
'/premium-assets/css/style.css',
];
// Optional third-party scripts loaded only when premium pages mount
const headScripts: Array<{src: string; type?: 'module'|'nomodule'}> = [
{ src: 'https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js', type: 'module' },
{ src: 'https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js', type: 'nomodule' },
];
// Theme/vendor scripts (order matters: jQuery -> jQuery plugins -> theme)
const vendorScripts: string[] = [
'/premium-assets/js/jquery.min.js',
'/premium-assets/js/jquery-migrate.min.js',
'/premium-assets/js/jquery.blockUI.min.js',
'/premium-assets/js/jquery.paroller.js',
'/premium-assets/js/modernizr-2.6.2.min.js',
'/premium-assets/js/bootstrap.min.js',
'/premium-assets/js/imagesloaded.min.js',
'/premium-assets/js/jquery.masonry.min.js',
'/premium-assets/js/jquery.nicescroll.js',
'/premium-assets/js/jquery.selectBox.min.js',
'/premium-assets/js/jquery.matchHeight.js',
'/premium-assets/js/jquery.prettyPhoto.min.js',
'/premium-assets/js/scrollreveal.js',
'/premium-assets/js/script.js',
'/premium-assets/js/parallax-js.js',
'/premium-assets/js/scripts.js',
'/premium-assets/js/swiper.min.js',
'/premium-assets/js/frontend.js',
'/premium-assets/js/jquery.zoomslider.js',
'/premium-assets/js/webpack.runtime.min.js',
'/premium-assets/js/frontend-modules.min.js',
'/premium-assets/js/waypoints.min.js',
'/premium-assets/js/core.min.js',
];
const tailScripts: string[] = [
// Social embeds (async, non-blocking)
'https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v23.0',
'https://www.instagram.com/embed.js',
];
function useInjectAssets() {
React.useEffect(() => {
const added: Array<HTMLElement> = [];
const base = getBackendOrigin();
// Fonts (external)
fontLinks.forEach(({ rel, href, crossOrigin }) => {
if (document.querySelector(`link[data-premium="1"][rel="${rel}"][href="${href}"]`)) return;
const link = document.createElement('link');
link.rel = rel as any;
link.href = href;
if (crossOrigin !== undefined) link.crossOrigin = crossOrigin as any;
link.setAttribute('data-premium', '1');
document.head.appendChild(link);
added.push(link);
});
// CSS
cssList.forEach((href) => {
if (document.querySelector(`link[data-premium="1"][href="${base.replace(/\/$/, '') + href}"]`)) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = base.replace(/\/$/, '') + href;
link.setAttribute('data-premium', '1');
document.head.appendChild(link);
added.push(link);
});
// Head scripts
headScripts.forEach(({ src, type }) => {
if (document.querySelector(`script[data-premium="1"][src="${src}"]`)) return;
const s = document.createElement('script');
s.src = src;
if (type === 'module') s.type = 'module';
if (type === 'nomodule') (s as any).noModule = true;
s.async = true;
s.setAttribute('data-premium', '1');
document.head.appendChild(s);
added.push(s);
});
// Vendor/theme scripts in order
vendorScripts.forEach((src) => {
const abs = base.replace(/\/$/, '') + src;
if (document.querySelector(`script[data-premium="1"][src="${abs}"]`)) return;
const s = document.createElement('script');
s.src = abs;
s.async = false; // preserve order
s.defer = true;
s.setAttribute('data-premium', '1');
document.body.appendChild(s);
added.push(s);
});
// Safety stub for missing jQuery plugins used by theme scripts
const stub = document.createElement('script');
stub.type = 'text/javascript';
stub.setAttribute('data-premium', '1');
stub.text = `
(function(){
function patch(){
try {
var w = window; var $ = w.jQuery || w.$;
if (!$) return false;
$.fn = $.fn || {};
if (typeof $.fn.magnificPopup !== 'function') { $.fn.magnificPopup = function(){ return this; }; }
if (typeof $.fn.counterUp !== 'function') { $.fn.counterUp = function(){ return this; }; }
if (typeof $.fn.ripples !== 'function') { $.fn.ripples = function(){ return this; }; }
return true;
} catch(e){ return true; }
}
if (!patch()) {
var tries = 0; var id = setInterval(function(){ tries++; if (patch() || tries > 40) { clearInterval(id); } }, 50);
}
})();
`;
document.body.appendChild(stub);
added.push(stub);
// Tail scripts (append near end of body)
tailScripts.forEach((src) => {
if (document.querySelector(`script[data-premium="1"][src="${src}"]`)) return;
const s = document.createElement('script');
s.src = src;
s.async = true;
s.defer = true;
s.setAttribute('data-premium', '1');
document.body.appendChild(s);
added.push(s);
});
return () => {
// Remove only our injected assets to prevent leaking into normal pages
added.forEach((el) => {
try { el.parentElement?.removeChild(el); } catch {}
});
};
}, []);
}
const PremiumAssetsLoader: React.FC = () => {
useInjectAssets();
return null;
};
export default PremiumAssetsLoader;
@@ -0,0 +1,163 @@
import React from 'react';
import PremiumLayout from './PremiumLayout';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated, getFeaturedArticles } from '../../services/articles';
import { assetUrl } from '../../utils/url';
const PremiumBlogPage: React.FC = () => {
const pageSize = 18;
const featuredQ = useQuery<Paginated<Article>>(
['articles-featured', { page_size: 3 }],
() => getFeaturedArticles({ page_size: 3 }),
{ staleTime: 5 * 60 * 1000 }
);
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<Paginated<Article>>(
['articles-public', { page_size: pageSize, published: true }],
({ pageParam = 1 }) => getArticles({ page: pageParam, page_size: pageSize, published: true }),
{
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((sum, p) => sum + (p?.data?.length || 0), 0);
if (!lastPage) return undefined;
if (loaded < (lastPage.total || 0)) return allPages.length + 1;
return undefined;
},
}
);
const articles = data?.pages?.flatMap((p) => p?.data || []) || [];
const featured = featuredQ.data?.data || [];
const rest = articles.filter(a => !featured.some(f => f.id === a.id));
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (!hasNextPage || !sentinelRef.current) return;
const el = sentinelRef.current;
const io = new IntersectionObserver((entries) => {
const first = entries[0];
if (first.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, { rootMargin: '400px' });
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Re-init theme masonry/parallax in SPA after content renders
React.useEffect(() => {
const w: any = window as any;
const $: any = (w && (w.jQuery || w.$)) || null;
const run = () => {
try {
if ($ && typeof $.fn.imagesLoaded === 'function') {
$('.row.masonry').imagesLoaded(() => {
try { if (typeof w.initMasonry === 'function') w.initMasonry(); } catch {}
try { if (typeof w.initParallax === 'function') w.initParallax(); } catch {}
});
} else {
try { if (typeof w.initMasonry === 'function') w.initMasonry(); } catch {}
try { if (typeof w.initParallax === 'function') w.initParallax(); } catch {}
}
} catch {}
};
const t = setTimeout(run, 50);
return () => clearTimeout(t);
}, [rest.length]);
return (
<PremiumLayout>
<div className="lte-text-page" style={{ paddingTop: 0 }}>
{/* Header */}
<header className="lte-page-header lte-parallax-yes">
<div className="container">
<div className="lte-header-h1-wrapper" style={{ textAlign: 'center' }}>
<h1 className="lte-header long">Blog</h1>
</div>
</div>
</header>
<div className="container main-wrapper">
{/* Featured row: 1 big + 2 small */}
{featured.length > 0 && (
<div className="row row-center" style={{ marginBottom: 24 }}>
<div className="col-xl-8 col-lg-7 col-md-12 col-xs-12">
{(() => {
const a = featured[0];
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
return (
<article className="post format-standard has-post-thumbnail hentry">
<a href={link} className="lte-photo">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'} className="attachment-atleticos-post size-atleticos-post wp-post-image" />
<span className="lte-photo-overlay"></span>
</a>
<div className="lte-description">
<a href={link} className="lte-header"><h3>{a.title}</h3></a>
<div className="lte-excerpt"></div>
</div>
</article>
);
})()}
</div>
<div className="col-xl-4 col-lg-5 col-md-12 col-xs-12">
{(featured.slice(1,3)).map(a => {
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
return (
<article key={a.id} className="post format-standard has-post-thumbnail hentry" style={{ marginBottom: 16 }}>
<a href={link} className="lte-photo">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'} className="attachment-atleticos-post size-atleticos-post wp-post-image" />
<span className="lte-photo-overlay"></span>
</a>
<div className="lte-description">
<a href={link} className="lte-header"><h3>{a.title}</h3></a>
<div className="lte-excerpt"></div>
</div>
</article>
);
})}
</div>
</div>
)}
{/* Masonry list */}
<section className="blog-posts">
<div className="blog lte-blog-sc row centered layout-posts">
<div className="row masonry">
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="col-xl-4 col-lg-6 col-md-6 col-sm-12 col-xs-12 item div-thumbnail">
<div className="lte-skeleton" style={{ height: 260, background: '#eee' }} />
</div>
))}
{!isLoading && rest.map(a => {
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
return (
<div key={a.id} className="col-xl-4 col-lg-6 col-md-6 col-sm-12 col-xs-12 item div-thumbnail">
<article className="post format-standard has-post-thumbnail hentry">
<a href={link} className="lte-photo">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'} className="attachment-atleticos-blog size-atleticos-blog wp-post-image" />
<span className="lte-photo-overlay"></span>
</a>
<div className="lte-description">
<a href={link} className="lte-header"><h3>{a.title}</h3></a>
</div>
</article>
</div>
);
})}
</div>
<div ref={sentinelRef as any} style={{ height: 1 }} />
</div>
</section>
</div>
</div>
</PremiumLayout>
);
};
export default PremiumBlogPage;
@@ -0,0 +1,685 @@
import React from 'react';
import PremiumLayout from './PremiumLayout';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getFeaturedArticles, getArticles, Article } from '../../services/articles';
import { getSponsors, Sponsor as ApiSponsor } from '../../services/sponsors';
import { api as axiosApi } from '../../services/api';
import { assetUrl } from '../../utils/url';
import { getPlayers, Player as ApiPlayer } from '../../services/players';
import { getClothing, ClothingItem } from '../../services/clothing';
const PremiumHomePage: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubName = settings?.club_name || 'Fotbal Club';
// Build zoom slider images from featured/news
const [heroImages, setHeroImages] = React.useState<string[]>([]);
const [sponsors, setSponsors] = React.useState<ApiSponsor[]>([]);
const [merch, setMerch] = React.useState<ClothingItem[]>([]);
React.useEffect(() => {
let active = true;
(async () => {
try {
const [featured, latest] = await Promise.all([
getFeaturedArticles({ page_size: 3 }).catch(() => ({ data: [] as Article[] })),
getArticles({ page: 1, page_size: 8, published: true }).catch(() => ({ data: [] as Article[] })),
]);
const imgs = ([] as (string | undefined)[])
.concat((featured?.data || []).map(a => a.image_url))
.concat((latest?.data || []).map(a => a.image_url))
.slice(0, 5)
.map(u => assetUrl(u))
.filter((u): u is string => typeof u === 'string');
if (active) setHeroImages(imgs as string[]);
} catch {}
try {
const s = await getSponsors();
if (active) setSponsors(s || []);
} catch {}
try {
const m = await getClothing();
if (active) setMerch(m || []);
} catch {}
})();
return () => { active = false; };
}, []);
// Trigger social embed parsing after mount and when social URLs change (SPA)
React.useEffect(() => {
const w: any = window as any;
try { if (w.FB && w.FB.XFBML && typeof w.FB.XFBML.parse === 'function') w.FB.XFBML.parse(); } catch {}
try { if (w.instgrm && w.instgrm.Embeds && typeof w.instgrm.Embeds.process === 'function') w.instgrm.Embeds.process(); } catch {}
}, [settings?.facebook_url, settings?.instagram_url]);
// Initialize zoom slider and theme widgets if present
React.useEffect(() => {
const timer = setTimeout(() => {
try {
const w: any = window as any;
if (w && w.jQuery && typeof w.jQuery === 'function') {
const $ = w.jQuery;
if ($ && typeof ($ as any).fn?.zoomslider === 'function') {
$('.lte-slider-zoom').each((_i: any, el: any) => {
try { ($ as any)(el).zoomslider(); } catch {}
});
}
}
if (typeof (w as any).initSwiperWrappers === 'function') {
(w as any).initSwiperWrappers();
}
} catch {}
}, 50);
return () => clearTimeout(timer);
}, [heroImages]);
// Re-init swiper when merch arrives
React.useEffect(() => {
if (!merch.length) return;
const w: any = window as any;
try { if (typeof w.initSwiperWrappers === 'function') w.initSwiperWrappers(); } catch {}
}, [merch]);
// Populate dynamic sections similar to pro/js/* in TS
React.useEffect(() => {
let cancelled = false;
function h(tag: string, attrs: Record<string, any> = {}, children: (HTMLElement | string)[] = []) {
const el = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
if (k === 'class') el.className = v;
else if (k === 'html') el.innerHTML = v;
else el.setAttribute(k, String(v));
});
children.forEach(c => {
if (typeof c === 'string') el.appendChild(document.createTextNode(c));
else el.appendChild(c);
});
return el;
}
// Helpers
const fetchJSON = async (url: string) => {
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return null;
return await res.json();
} catch { return null; }
};
// Blog latest (primary 4)
async function renderLatestBlog() {
const mount = document.getElementById('latest-blog-items');
if (!mount) return;
mount.innerHTML = '<div style="width:100%;text-align:center;padding:12px;color:#888;">Načítání…</div>';
try {
const resp = await getArticles({ page: 1, page_size: 12, published: true });
if (cancelled) return;
const items = (resp?.data || []).slice(0, 4);
const frag = document.createDocumentFragment();
items.forEach((a) => {
const col = h('div', { class: 'items col-xl-6 col-lg-6 col-md-6 col-sm-6 col-ms-6 col-xs-12' });
const art = h('article', { class: 'post type-post has-post-thumbnail hentry' });
const url = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
const aPhoto = h('a', { href: url, class: 'lte-photo' });
const img = h('img', { src: assetUrl(a.image_url) || '/images/news/placeholder.jpg', width: '500', height: '300', decoding: 'async', class: 'attachment-atleticos-blog size-atleticos-blog wp-post-image', alt: '' });
aPhoto.appendChild(img);
aPhoto.appendChild(h('span', { class: 'lte-photo-overlay' }));
const descr = h('div', { class: 'lte-description' });
const aHeader = h('a', { href: url, class: 'lte-header' });
aHeader.appendChild(h('h3', { html: a.title }));
descr.appendChild(aHeader);
art.appendChild(aPhoto); art.appendChild(descr); col.appendChild(art);
frag.appendChild(col);
});
mount.innerHTML = '';
mount.appendChild(frag);
} catch {
mount.innerHTML = '<div style="width:100%;text-align:center;padding:12px;color:#c00;">Nepodařilo se načíst novinky.</div>';
}
}
// Videos latest using backend YouTube cache
async function renderLatestVideos() {
const featureMount = document.getElementById('latest-video-feature');
const gridMount = document.getElementById('latest-videos-grid');
if (!featureMount && !gridMount) return;
if (featureMount) featureMount.innerHTML = '<div style="width:100%;text-align:center;padding:12px;color:#888;">Načítání…</div>';
try {
const res = await axiosApi.get('/youtube/videos');
if (cancelled) return;
const items = (res?.data?.videos || []) as Array<{ video_id: string; title: string; thumbnail_url: string; published_text?: string; }>
if (featureMount) featureMount.innerHTML = '';
if (!items.length) return;
const first = items[0];
if (featureMount && first) {
const container = h('div', { class: 'items col-xl-12 col-lg-12 col-md-12 col-sm-12 col-ms-12 col-xs-12' });
const article = h('article', { class: 'post format-video has-post-thumbnail hentry' });
const wrap = h('div', { class: 'lte-wrapper' });
const aEl = h('a', { href: `https://www.youtube.com/watch?v=${first.video_id}`, target: '_blank', class: 'lte-photo lte-video-popup swipebox' });
aEl.appendChild(h('img', { loading: 'lazy', decoding: 'async', width: '1600', height: '969', src: first.thumbnail_url, class: 'attachment-full size-full wp-post-image', alt: '' }));
const iconWrap = h('span', { class: 'lte-icon-video' });
iconWrap.appendChild(h('span', { html: '' }));
aEl.appendChild(iconWrap);
wrap.appendChild(aEl);
const descr = h('div', { class: 'lte-description' });
const headerA = h('a', { href: `https://www.youtube.com/watch?v=${first.video_id}`, class: 'lte-header', target: '_blank' });
headerA.appendChild(h('h3', { html: first.title || '' }));
descr.appendChild(headerA);
descr.appendChild(h('div', { class: 'lte-excerpt' }));
article.appendChild(wrap); article.appendChild(descr); container.appendChild(article);
featureMount.appendChild(container);
}
if (gridMount) {
const frag = document.createDocumentFragment();
items.slice(1, 5).forEach(v => {
const col = h('div', { class: 'items col-xl-6 col-lg-6 col-md-6 col-sm-6 col-ms-12 col-xs-12' });
const article = h('article', { class: 'post format-video has-post-thumbnail hentry' });
const wrap = h('div', { class: 'lte-wrapper' });
const aEl = h('a', { href: `https://www.youtube.com/watch?v=${v.video_id}`, target: '_blank', class: 'lte-photo lte-video-popup swipebox' });
aEl.appendChild(h('img', { loading: 'lazy', decoding: 'async', width: '1600', height: '969', src: v.thumbnail_url, class: 'attachment-full size-full wp-post-image', alt: '' }));
const iconWrap = h('span', { class: 'lte-icon-video' });
iconWrap.appendChild(h('span', { html: '' }));
aEl.appendChild(iconWrap);
wrap.appendChild(aEl);
const descr = h('div', { class: 'lte-description' });
const headerA = h('a', { href: `https://www.youtube.com/watch?v=${v.video_id}`, class: 'lte-header', target: '_blank' });
headerA.appendChild(h('h3', { html: v.title || '' }));
descr.appendChild(headerA);
descr.appendChild(h('div', { class: 'lte-excerpt' }));
article.appendChild(wrap); article.appendChild(descr); col.appendChild(article);
frag.appendChild(col);
});
gridMount.appendChild(frag);
}
} catch {}
}
// FACR: upcoming + table from prefetch cache
async function renderFACR() {
const root = document.getElementById('facr-upcoming');
const tbody = document.getElementById('facr-table-body');
const tabs = document.getElementById('facr-comp-tabs');
if (!root && !tbody) return;
const clubInfo = await fetchJSON('/cache/prefetch/facr_club_info.json');
const tables = await fetchJSON('/cache/prefetch/facr_tables.json');
if (!clubInfo && !tables) return;
// Upcoming
try {
const comps: any[] = Array.isArray(clubInfo?.competitions) ? clubInfo.competitions : [];
const parseCZ = (s: string) => {
try { const [d, t] = String(s||'').split(' '); const [day, month, year] = d.split('.').map(Number); const [hh, mm] = (t||'').split(':').map(Number); return new Date(year, (month||1)-1, day||1, hh||0, mm||0); } catch { return null; }
};
const candidates: Array<{ comp: any; match: any; dt: Date }>[] = comps.map((c: any) => (c.matches||[]).map((m:any)=>({ comp:c, match:m, dt: parseCZ(m.date_time) as Date})).filter((x: { dt: Date }) => x.dt instanceof Date));
const now = Date.now();
const threeD = 3*24*60*60*1000;
const flat = candidates.map(list=> list.sort((a,b)=>a.dt.getTime()-b.dt.getTime()));
const picks: Array<{ comp:any; match:any; dt:Date }> = [];
flat.forEach(list => {
if (!list.length) return;
let pick = list.filter((x: { dt: Date }) => x.dt.getTime() <= now && now - x.dt.getTime() <= threeD).slice(-1)[0];
if (!pick) pick = list.find((x: { dt: Date }) => x.dt.getTime() >= now) || list[list.length-1];
if (pick) picks.push(pick);
});
picks.sort((a,b)=> a.dt.getTime()-b.dt.getTime());
const sel = picks[0];
if (root && sel) {
const m = sel.match, c = sel.comp;
const homeLogo = m.home_logo_url || '/dist/img/logo-club-empty.svg';
const awayLogo = m.away_logo_url || '/dist/img/logo-club-empty.svg';
const dateOnly = (()=>{ const d=sel.dt; return `${d.getDate()}. ${d.getMonth()+1}. ${d.getFullYear()}`; })();
const timeToken = (String(m.date_time||'').split(' ')[1]||'').slice(0,5);
const score = String(m.score||'');
const s1 = score.includes(':') ? score.split(':')[0] : '';
const s2 = score.includes(':') ? score.split(':')[1] : '';
const midText = (sel.dt.getTime()>now) ? 'Začátek' : (score||'-');
root.innerHTML = `
<div class="lte-football-upcoming">
<div class="facr-comp-title lte-football-date" style="text-align:center; margin-bottom:6px;">${c.name || c.code || 'Soutěž'}</div>
<div class="lte-teams">
<span class="lte-team-name lte-team-1 lte-header" title="${m.home}">
<span class="lte-team-logo"><img decoding="async" src="${homeLogo}"></span>${m.home}
${s1 ? `<span class="lte-team-count-mob">${s1}</span>`: ''}
</span>
<span class="lte-team-count">
<span id="facr-mid" style="font-size:32px; font-weight:700; display:inline-block; min-width:120px; text-align:center;">${midText}</span>
${s1 && s2 ? `<span class="facr-mob-center-score">${s1}<span>:</span>${s2}</span>` : ''}
</span>
<span class="lte-team-name lte-team-2 lte-header" title="${m.away}">
${s2 ? `<span class=\"lte-team-count-mob\">${s2}</span>`: ''}${m.away}<span class="lte-team-logo"><img decoding="async" src="${awayLogo}"></span>
</span>
</div>
<span class="lte-football-date" style="text-align:center;">${dateOnly}${m.venue?`, ${m.venue}`:''}</span>
${timeToken ? `<span class="lte-football-time" style="display:block; text-align:center;">${timeToken}</span>`:''}
</div>`;
}
} catch {}
// Table
try {
const compsTbl: any[] = Array.isArray(tables?.competitions) ? tables.competitions : [];
if (tabs) {
tabs.innerHTML = compsTbl.map((c: any, i: number) => `<button class="facr-tab ${i===0?'active':''}" data-idx="${i}">${c.name || c.code || 'Soutěž'}</button>`).join('');
tabs.querySelectorAll('button').forEach((btn) => {
btn.addEventListener('click', () => {
tabs.querySelectorAll('button').forEach(b=> b.classList.remove('active'));
btn.classList.add('active');
const idx = Number((btn as HTMLButtonElement).dataset.idx||'0');
const comp = compsTbl[Math.max(0, Math.min(idx, compsTbl.length-1))];
const rows = comp?.table?.overall || [];
if (tbody) {
tbody.innerHTML = rows.map((r:any)=> `
<tr>
<td class="lte-row"><span>${r.rank||''}</span></td>
<td class="lte-club-logo"><img decoding="async" src="${r.team_logo_url || '/dist/img/logo-club-empty.svg'}"></td>
<td class="lte-name">${r.team||''}</td>
<td class="lte-rate">${r.played||''}</td>
<td class="lte-rate">${r.wins||''}</td>
<td class="lte-rate">${r.draws||''}</td>
<td class="lte-rate">${r.losses||''}</td>
<td class="lte-rate">${r.score||''}</td>
<td class="lte-summary">${r.points||''}</td>
</tr>`).join('');
}
});
});
}
// initialize first
const firstBtn = tabs?.querySelector('button') as HTMLButtonElement | null;
if (firstBtn) firstBtn.click();
} catch {}
}
// Team slider using Players API
async function renderTeamSlider() {
const wrapperEl = document.getElementById('team-swiper-wrapper-1') as HTMLElement | null;
if (!wrapperEl) return;
try {
const list = await getPlayers();
// Load teams to infer gender buckets from team names
let rawTeams: any[] = [];
try {
const tRes = await axiosApi.get('/teams');
rawTeams = Array.isArray(tRes.data) ? tRes.data : (Array.isArray((tRes.data as any)?.data) ? (tRes.data as any).data : []);
} catch {}
const genderByTeamId: Record<number, 'men' | 'women'> = {};
rawTeams.forEach((t: any) => {
const id = (t && (t.id ?? t.ID)) as number | undefined;
const nm = String(t?.name ?? t?.Name ?? '').toLowerCase();
if (!id) return;
// Normalize accents for Czech keywords
const norm = nm.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const isWomen = /(zeny|zena|zene|zen|women|girl)/i.test(norm);
genderByTeamId[id] = isWomen ? 'women' : 'men';
});
let current: 'men' | 'women' = 'men';
const switcher = document.getElementById('gender-switcher');
function buildSlidesForGender(g: 'men'|'women') {
const filtered = (list || []).filter((p: any) => {
const pg = String(p?.gender ?? p?.Gender ?? '').toLowerCase();
if (pg === 'men' || pg === 'women') return pg === g;
const tid = p?.team_id ?? p?.TeamID;
const gval = (typeof tid === 'number' && genderByTeamId[tid]) ? genderByTeamId[tid] : 'men';
return gval === g;
});
const finalList: ApiPlayer[] = (filtered.length ? filtered : (list || [])).slice(0, 12);
const html = finalList.map((p: ApiPlayer) => {
const img = assetUrl(p.image_url) || '/dist/img/logo-club-empty.svg';
const num = (p as any).jersey_number || '';
const name = [p.first_name, p.last_name].filter(Boolean).join(' ');
const role = (p as any).position || '';
return `
<div class="lte-item swiper-slide">
<div class="lte-team-item">
<a class="lte-image" style="background-image: url()">
<img loading="lazy" decoding="async" width="800" height="1200" src="${img}" class="attachment-full size-full" />
</a>
<div class="lte-descr">
<div class="lte-num">${num||''}</div>
<a href="${img}" target="_blank"><h4 class="lte-header">${name}</h4></a>
<p class="lte-subheader"><span>${role}</span></p>
</div>
</div>
</div>`;
}).join('');
const wEl = document.getElementById('team-swiper-wrapper-1') as HTMLElement | null;
if (!wEl) return;
wEl.innerHTML = html;
const w: any = window as any;
if (typeof (w as any).initSwiperWrappers === 'function') {
try { (w as any).initSwiperWrappers(); } catch {}
}
}
// Bind UI switcher to update active button and slides
if (switcher && !(switcher as any).__boundTS) {
switcher.addEventListener('click', (ev: any) => {
const target = (ev.target as HTMLElement);
const btn = target && (target.closest ? target.closest('button[data-gender]') : null) as HTMLButtonElement | null;
if (!btn) return;
const g = (btn.dataset.gender === 'women') ? 'women' : 'men';
current = g;
Array.from(switcher.querySelectorAll('button[data-gender]')).forEach((b) => {
const bb = b as HTMLButtonElement;
bb.classList.toggle('active', (bb.dataset.gender === g));
});
buildSlidesForGender(current);
});
(switcher as any).__boundTS = true;
}
// Initial render
buildSlidesForGender(current);
} catch {}
}
renderLatestBlog();
renderLatestVideos();
renderFACR();
renderTeamSlider();
return () => { cancelled = true; };
}, []);
// Sponsors grid render (simple)
const sponsorsGrid = (
<div id="sponzori" className="elementor-element elementor-element-031d23b e-flex e-con-boxed e-con e-parent" data-core-v316-plus="true">
<div className="e-con-inner">
<div className="elementor-element elementor-widget elementor-widget-lte-partners">
<div className="elementor-widget-container">
<div className="container-fluid lte-partners-sc has-lte-divider lte-hover-effect-opacity">
<div className="row centered">
{(sponsors || []).slice(0, 24).map((s) => (
<div key={s.id} className="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div className="partners-item item center-flex">
<a href={s.website_url || '#'} target="_blank" rel="noreferrer">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={assetUrl(s.logo_url) || '/images/sponsors/placeholder.png'} className="image" />
</a>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
const zsAttr = heroImages.length ? JSON.stringify(heroImages) : JSON.stringify(['/dist/img/logo-club-empty.svg']);
return (
<PremiumLayout>
<div className="lte-text-page margin-disabled">
{/* Zoom slider */}
<section className="elementor-section elementor-top-section elementor-section-full_width">
<div className="elementor-container elementor-column-gap-no">
<div className="elementor-column elementor-col-100 elementor-top-column">
<div className="elementor-widget-wrap elementor-element-populated">
<div className="elementor-widget elementor-widget-lte-zoomslider">
<div className="elementor-widget-container">
<div
className="lte-slider-zoom zoom-default zoom-origin-center-center lte-zs-overlay-black bullets-bottom"
data-zs-overlay="black"
data-zs-initzoom="1.2"
data-zs-speed="20000"
data-zs-interval="4500"
data-zs-switchSpeed="7000"
data-zs-arrows="false"
data-zs-bullets="bottom"
data-zs-src={zsAttr}
>
<div className="container lte-zs-slider-wrapper">
<div className="lte-zs-slider-inner visible">
<div className="lte-heading lte-size-lg lte-style-subheader-italic lte-uppercase lte-color-white">
<div className="lte-heading-content">
<h2 className="lte-header">{clubName} <span> {new Date().getFullYear()}/{(new Date().getFullYear()+1).toString().slice(2)} </span></h2>
</div>
</div>
<div className="lte-btn-wrap" style={{ marginTop: 12 }}>
<a href="/blog" className="lte-btn btn-lg color-hover-white"><span className="lte-btn-inner"><span className="lte-btn-before"></span>Zjistit více</span></a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Upcoming matches + table wrappers (content is rendered by TS above matching facr-frontend behavior) */}
<div className="elementor-element lte-background-black e-flex e-con-boxed e-con e-parent" data-core-v316-plus="true">
<div className="e-con-inner">
<div className="elementor-widget elementor-widget-football-upcoming">
<div className="elementor-widget-container">
<div id="facr-upcoming" className="lte-football-upcoming"></div>
</div>
</div>
</div>
</div>
{/* Blog latest */}
<div className="elementor-element e-flex e-con-boxed e-con e-parent" data-core-v316-plus="true">
<div className="e-con-inner">
<div className="elementor-element lte-heading-style-header-subheader elementor-widget elementor-widget-lte-header">
<div className="elementor-widget-container">
<div className="lte-heading lte-style-header-subheader lte-uppercase lte-subcolor-main has-subheader heading-tag-h3 heading-subtag-h6">
<div className="lte-heading-content">
<h6 className="lte-subheader">Náš Blog</h6>
<h3 className="lte-header">Aktuální zprávy z klubu</h3>
</div>
</div>
</div>
</div>
<div className="elementor-element elementor-widget elementor-widget-lte-blog">
<div className="elementor-widget-container">
<div className="blog lte-blog-sc row centered layout-posts">
<div id="latest-blog-items" className="row" aria-live="polite"></div>
</div>
</div>
</div>
<div className="elementor-element elementor-widget elementor-widget-lte-button" style={{ marginTop: 12 }}>
<div className="elementor-widget-container">
<div className="lte-btn-wrap">
<a href="/blog" className="lte-btn btn-xs btn-transparent color-hover-default"><span className="lte-btn-inner"><span className="lte-btn-before"></span>Více novinek</span></a>
</div>
</div>
</div>
</div>
</div>
{/* Table */}
<div className="elementor-element e-flex e-con-boxed e-con e-parent" data-core-v316-plus="true">
<div className="e-con-inner">
<div className="elementor-element lte-heading-style-header-subheader elementor-widget elementor-widget-lte-header">
<div className="elementor-widget-container">
<div className="lte-heading lte-style-header-subheader lte-uppercase lte-subcolor-main has-subheader heading-tag-h3 heading-subtag-h6">
<div className="lte-heading-content">
<p className="Badge u-mb-8" id="facr-comp-badge" style={{ marginBottom: 0 }}>Soutěž</p><br />
<h6 className="lte-subheader"><span> Sezóna </span> {new Date().getFullYear()}-{(new Date().getFullYear()+1).toString().slice(2)}</h6>
<h3 className="lte-header">Tabulka bodů</h3>
</div>
</div>
</div>
</div>
<div id="facr-comp-tabs" className="u-mb-8" style={{ marginBottom: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}></div>
<div className="elementor-element elementor-widget elementor-widget-football-table">
<div className="elementor-widget-container">
<table className="lte-football-table">
<thead>
<tr>
<th>#</th><th></th><th className="lte-name">Klub</th><th>Z</th><th>V</th><th>R</th><th>P</th><th>Skóre</th><th>B</th>
</tr>
</thead>
<tbody id="facr-table-body"></tbody>
</table>
</div>
</div>
</div>
</div>
{/* Videos */}
<div className="elementor-element lte-background-gray e-flex e-con-boxed e-con e-parent" data-core-v316-plus="true">
<div className="e-con-inner">
<div className="elementor-element lte-heading-align-center elementor-widget elementor-widget-lte-header">
<div className="elementor-widget-container">
<div className="lte-heading lte-style-default lte-uppercase lte-subcolor-white has-watermark heading-tag-h3 heading-subtag-h6">
<div className="lte-heading-content">
<span className="lte-watermark">Sestřihy zápasů</span>
</div>
</div>
</div>
</div>
<div className="elementor-element e-flex e-con-boxed e-con e-child">
<div className="e-con-inner">
<div className="elementor-element e-con-full e-flex e-con e-child">
<div className="elementor-element elementor-widget elementor-widget-lte-blog">
<div className="elementor-widget-container">
<div className="blog lte-blog-sc row centered layout-posts-large hideLastOdd lte-grid-bg">
<div id="latest-video-feature" className="items col-xl-12 col-lg-12 col-md-12 col-sm-12 col-ms-12 col-xs-12" aria-live="polite"></div>
</div>
<div className="clearfix"></div>
</div>
</div>
</div>
<div className="elementor-element e-con-full e-flex e-con e-child">
<div className="elementor-element elementor-widget elementor-widget-lte-blog">
<div className="elementor-widget-container">
<div className="blog lte-blog-sc row centered layout-posts lte-grid-bg">
<div id="latest-videos-grid" className="row" aria-live="polite"></div>
</div>
<div className="clearfix"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Social embeds */}
<div id="social" className="elementor-element e-flex e-con-boxed e-con e-parent" aria-labelledby="social-heading">
<div className="e-con-inner">
<div className="elementor-element lte-heading-align-center elementor-widget elementor-widget-lte-header">
<div className="elementor-widget-container">
<div className="lte-heading lte-style-default lte-uppercase heading-tag-h3">
<div className="lte-heading-content">
<h3 id="social-heading" className="lte-header">Sledujte nás</h3>
</div>
</div>
</div>
</div>
<div className="row centered" style={{ marginTop: 20 }}>
<div className="col-xl-6 col-lg-6 col-md-12 col-sm-12 col-xs-12">
<div id="fb-root"></div>
<div className="fb-page"
data-href={settings?.facebook_url || 'https://www.facebook.com/'}
data-height="500"
data-small-header="false"
data-adapt-container-width="true"
data-hide-cover="false"
data-show-facepile="true"
data-tabs="timeline"
data-width="600">
<blockquote className="fb-xfbml-parse-ignore">
<a href={settings?.facebook_url || '#'}>{settings?.club_name || 'Klub'}</a>
</blockquote>
</div>
</div>
<div className="col-xl-6 col-lg-6 col-md-12 col-sm-12 col-xs-12">
<blockquote className="instagram-media" data-instgrm-permalink={settings?.instagram_url || 'https://www.instagram.com/'} data-instgrm-version="14" style={{ background: '#FFF', border: 0, borderRadius: 3, boxShadow: '0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15)', margin: 1, maxWidth: 658, minWidth: 326, padding: 0, width: '99.375%' }}></blockquote>
</div>
</div>
</div>
</div>
{/* Sponsors grid */}
{sponsorsGrid}
{/* Team section */}
<div id="tym" className="elementor-element elementor-element-ed06b06 e-con-full e-flex e-con e-parent" data-core-v316-plus="true">
<div className="elementor-element lte-heading-style-header-subheader lte-heading-align-center lte-watermark-offset-1 elementor-widget elementor-widget-lte-header">
<div className="elementor-widget-container">
<div className="lte-heading lte-style-header-subheader lte-uppercase lte-subcolor-main has-subheader has-watermark heading-tag-h3 heading-subtag-h6">
<div className="lte-heading-content">
<h6 className="lte-subheader">náš tým</h6>
<h3 className="lte-header">prohlédněte si náš tým</h3>
<span className="lte-watermark">náš tým</span>
</div>
</div>
</div>
</div>
<div id="gender-switcher" aria-label="Přepínač pohlaví týmu" style={{ display:'flex', gap:8, justifyContent:'center', margin:'10px 0' }}>
<button type="button" className="switch-btn active" data-gender="men">Muži</button>
<button type="button" className="switch-btn" data-gender="women">Ženy</button>
</div>
</div>
<div id="team-section-1" className="elementor-element e-con-full lte-background-black e-flex e-con e-parent" data-core-v316-plus="true">
<div className="elementor-widget elementor-widget-lte-team">
<div className="elementor-widget-container">
<div className="team-preloader" id="team-preloader-1" aria-hidden="true" style={{ display:'none', alignItems:'center', justifyContent:'center', gap:6, color:'#fff', padding:'8px 0' }}>
<div className="spinner" style={{ width:16, height:16, border:'2px solid rgba(255,255,255,.3)', borderTopColor:'#fff', borderRadius:'50%' }} />
<div className="loading-text">Načítání</div>
</div>
<div className="lte-swiper-slider-wrapper">
<div className="lte-swiper-slider swiper-container lte-team-list lte-team-layout-default" data-space-between="30" data-arrows="sides-outside" data-autoplay="0" data-loop="" data-speed="1000" data-effect="coverflow" data-slides-per-group="-1" data-touch-move="0.2" data-breakpoints="5;4;4;3;3;1">
<div className="swiper-wrapper" id="team-swiper-wrapper-1"></div>
</div>
</div>
</div>
</div>
</div>
{/* Merch section */}
<div id="merch" className="elementor-element elementor-element-merch e-flex e-con-boxed e-con e-parent" data-core-v316-plus="true">
<div className="e-con-inner">
<div className="elementor-element lte-heading-style-header-subheader elementor-widget elementor-widget-lte-header">
<div className="elementor-widget-container">
<div className="lte-heading lte-style-header-subheader lte-uppercase lte-subcolor-main has-subheader heading-tag-h3 heading-subtag-h6">
<div className="lte-heading-content">
<h6 className="lte-subheader">Fanshop</h6>
<h3 className="lte-header">Klubové oblečení</h3>
</div>
</div>
</div>
</div>
<div className="elementor-element elementor-widget elementor-widget-lte-products">
<div className="elementor-widget-container">
<div className="lte-swiper-slider-wrapper">
<div className="lte-swiper-slider swiper-container lte-products lte-team-list"
data-space-between="30" data-arrows="sides-outside" data-autoplay="0" data-loop="" data-speed="1000"
data-effect="coverflow" data-slides-per-group="-1" data-touch-move="0.2" data-breakpoints="4;3;3;2;2;1">
<div className="swiper-wrapper">
{(merch || []).slice(0, 12).map((it) => (
<div key={it.id} className="lte-item swiper-slide">
<div className="lte-team-item">
<a className="lte-image" href={it.url || '#'} target="_blank" rel="noreferrer">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img loading="lazy" decoding="async" width={800} height={800} src={assetUrl(it.image_url) || '/dist/img/logo-club-empty.svg'} className="attachment-full size-full" />
</a>
<div className="lte-descr">
<a href={it.url || '#'} target="_blank" rel="noreferrer"><h4 className="lte-header">{it.title}</h4></a>
<p className="lte-subheader"><span>{typeof it.price === 'number' ? `${it.price} ${it.currency || 'Kč'}` : ''}</span></p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</PremiumLayout>
);
};
export default PremiumHomePage;
@@ -0,0 +1,181 @@
import React from 'react';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import PremiumAssetsLoader from './PremiumAssetsLoader';
import { assetUrl } from '../../utils/url';
import { useAuth } from '../../contexts/AuthContext';
const PremiumLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { data: s } = usePublicSettings();
const clubLogo = s?.club_logo_url || '/dist/img/logo-club-empty.svg';
const clubName = s?.club_name || 'Fotbal Club';
const galleryUrl = s?.gallery_url || s?.zonerama_url || undefined;
const { isAuthenticated, user, logout } = useAuth();
const role = String(user?.role || '').toLowerCase();
const accountHref = role === 'admin' || role === 'editor' ? '/admin' : '/semiadmin';
return (
<div className="lte-content-wrapper lte-layout-transparent-full">
<PremiumAssetsLoader />
<div className="lte-header-wrapper header-h1 header-parallax lte-header-overlay lte-layout-transparent-full lte-pageheader-disabled">
<div id="lte-nav-wrapper" className="lte-layout-transparent-full lte-nav-color-white">
<nav className="lte-navbar affix" data-spy="affix" data-offset-top="0">
<div className="container">
{/* Logo */}
<div className="lte-navbar-logo">
<a className="lte-logo" href="/">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={assetUrl(clubLogo)} />
</a>
</div>
{/* Navigation Items */}
<div className="lte-navbar-items navbar-mobile-black navbar-collapse collapse" id="navbar" data-mobile-screen-width="1198">
<div className="toggle-wrap">
<a className="lte-logo" href="/">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={assetUrl(clubLogo)} />
</a>
<button type="button" className="lte-navbar-toggle collapsed" id="close-button">
<span className="close">&times;</span>
</button>
<div className="clearfix"></div>
</div>
{/* Navigation Menu */}
<ul id="menu-main-menu" className="lte-ul-nav">
<li className="menu-item menu-item-type-custom current-menu-ancestor current-menu-parent">
<a href="/"><span>Domů</span></a>
</li>
<li className="menu-item menu-item-type-post_type menu-item-object-page">
<a href="/o-klubu"><span>O nás</span></a>
</li>
<li className="menu-item menu-item-type-custom">
<a href="/blog"><span>Blog</span></a>
</li>
<li className="menu-item menu-item-type-post_type menu-item-object-page">
<a href="/kontakt"><span>Kontakt</span></a>
</li>
<li className="menu-item menu-item-type-custom current-menu-ancestor current-menu-parent">
<a href="/#tym"><span>Tým</span></a>
</li>
<li className="menu-item menu-item-type-custom current-menu-ancestor current-menu-parent">
<a href="/#sponzori"><span>Sponzoři</span></a>
</li>
{!!galleryUrl && (
<li className="menu-item menu-item-type-custom current-menu-ancestor current-menu-parent">
<a target="_blank" href={galleryUrl}><span>Fotogalerie</span></a>
</li>
)}
{/* Search toggle entry (theme script hooks on .lte-nav-search .lte-header) */}
<li className="menu-item lte-nav-search">
<a href="#" className="lte-header" onClick={(e) => e.preventDefault()}><span>Hledat</span></a>
</li>
{/* Auth links */}
{!isAuthenticated ? (
<>
<li className="menu-item menu-item-type-custom"><a href="/login"><span>Přihlásit</span></a></li>
<li className="menu-item menu-item-type-custom"><a href="/register"><span>Registrovat</span></a></li>
</>
) : (
<>
<li className="menu-item menu-item-type-custom"><a href={accountHref}><span>Můj účet</span></a></li>
<li className="menu-item menu-item-type-custom">
<a href="#" onClick={(e) => { e.preventDefault(); try { logout(); } catch {} window.location.href = '/'; }}><span>Odhlásit</span></a>
</li>
</>
)}
</ul>
{/* Premium search wrapper (theme scripts control visibility) */}
<div className="lte-top-search-wrapper" data-base-href="/hledat" data-source="site">
<a href="#" className="lte-top-search-ico" onClick={(e) => e.preventDefault()}></a>
<a href="#" className="lte-top-search-ico-close" onClick={(e) => e.preventDefault()}></a>
<div className="lte-top-search-field">
<input type="text" placeholder="Hledat…" />
<a href="#" id="lte-top-search-ico-mobile" onClick={(e) => e.preventDefault()}></a>
</div>
</div>
</div>
{/* Mobile Menu Toggle */}
<button type="button" className="lte-navbar-toggle" id="open-button">
<span className="icon-bar top-bar"></span>
<span className="icon-bar middle-bar"></span>
<span className="icon-bar bottom-bar"></span>
</button>
</div>
</nav>
</div>
</div>
{/* Content */}
{children}
{/* Footer */}
<div className="lte-footer-wrapper lte-footer-layout-default">
<div className="footer-wrapper">
<div className="lte-container">
<div className="footer-block lte-footer-widget-area">
<div className="elementor elementor-29393">
<div className="elementor-element lte-background-black e-flex e-con-boxed e-con e-parent" data-settings='{"background_background":"classic"}'>
<div className="e-con-inner" style={{ paddingBottom: '92px' }}>
<div className="e-con-full e-flex e-con e-child">
<div className="elementor-widget elementor-widget-shortcode">
<div className="elementor-widget-container">
<div className="elementor-shortcode">
<a className="lte-logo" href="/">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={assetUrl(clubLogo)} style={{ filter: 'drop-shadow(9px -1px 23px black)' }} />
</a>
</div>
</div>
</div>
<div className="elementor-widget elementor-widget-text-editor">
<div className="elementor-widget-container">
<p>
<span className="text-sm">
{clubName}
</span>
</p>
</div>
</div>
<div className="elementor-widget elementor-widget-lte-elements">
<div className="elementor-widget-container">
<div className="lte-social lte-nav-second lte-type-">
<ul>
{!!s?.facebook_url && (
<li><a href={s.facebook_url} target="_blank" rel="noreferrer"><ion-icon name="logo-facebook" style={{ height: 22, width: 22 }}></ion-icon></a></li>
)}
{!!s?.instagram_url && (
<li><a href={s.instagram_url} target="_blank" rel="noreferrer"><ion-icon name="logo-instagram" style={{ height: 22, width: 22 }}></ion-icon></a></li>
)}
{!!s?.youtube_url && (
<li><a href={s.youtube_url} target="_blank" rel="noreferrer"><ion-icon name="logo-youtube" style={{ height: 22, width: 22 }}></ion-icon></a></li>
)}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<footer className="copyright-block copyright-layout-copyright-transparent">
<div className="container">
<p>
<a href="https://tdvorak.dev" target="_blank" rel="noreferrer">TDvorak</a> &copy; Všechna práva vyhrazena - {new Date().getFullYear()}
</p>
</div>
</footer>
</div>
<a href="#" className="lte-go-top floating lte-go-top-icon">
<span className="go-top-icon-v2 icon">
<ion-icon name="football-outline" style={{ paddingRight: 2 }}></ion-icon>
</span>
<span className="go-top-header">Nahoru</span>
</a>
</div>
);
};
export default PremiumLayout;
@@ -0,0 +1,38 @@
import React from 'react';
import PremiumLayout from './PremiumLayout';
const PremiumNotFound: React.FC = () => {
return (
<PremiumLayout>
<div className="lte-text-page" style={{ paddingTop: 0 }}>
<header className="lte-page-header lte-parallax-yes">
<div className="container">
<div className="lte-header-h1-wrapper">
<h1 className="lte-header">404</h1>
</div>
</div>
</header>
<div className="container main-wrapper" style={{ marginBottom: 56 }}>
<section className="page-404 page-404-default">
<div className="container">
<div className="center">
<div className="heading heading-large color-main">
<h4>Oops! Stránka nebyla nalezena.</h4>
</div>
<p className="center-404">Stránka kterou hledáte byla smazána nebo změněna!</p>
<div className="lte-empty-space"></div>
<a href="/" className="lte-btn btn-lg btn-main color-hover-black align-center">
<span className="lte-btn-inner">
<span className="lte-btn-before"></span>Domů
</span>
</a>
</div>
</div>
</section>
</div>
</div>
</PremiumLayout>
);
};
export default PremiumNotFound;
+15 -1
View File
@@ -88,7 +88,7 @@ export async function adminListTransactions(params?: { user_id?: number|string;
return (res.data?.items || []) as AdminPointsTx[];
}
export async function adminAdjustPoints(body: { user_id: number; delta: number; reason?: string; meta?: Record<string, any> }): Promise<{ ok: boolean }>{
export async function adminAdjustPoints(body: { user_id: number; delta: number; reason?: string; meta?: Record<string, any>; current_password?: string }): Promise<{ ok: boolean }>{
const res = await api.post('/admin/engagement/adjust', body);
return res.data as { ok: boolean };
}
@@ -112,3 +112,17 @@ export async function adminGetUserProfile(user_id: number | string): Promise<Adm
const res = await api.get(`/admin/engagement/profile/${user_id}`);
return res.data as AdminUserProfile;
}
export type AdminUserListItem = {
id: number;
email: string;
name: string;
role: string;
isActive: boolean;
createdAt: string;
};
export async function adminListUsers(): Promise<AdminUserListItem[]> {
const res = await api.get('/admin/users');
return (res.data || []) as AdminUserListItem[];
}
+2
View File
@@ -12,6 +12,7 @@ export interface Player {
height?: number;
weight?: number;
image_url?: string;
gender?: string;
is_active: boolean;
created_at?: string;
email?: string;
@@ -35,6 +36,7 @@ function normalize(p: any): Player {
height: p.height ?? p.Height ?? undefined,
weight: p.weight ?? p.Weight ?? undefined,
image_url: p.image_url ?? p.ImageURL ?? undefined,
gender: p.gender ?? p.Gender ?? undefined,
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
created_at: p.created_at ?? p.CreatedAt ?? undefined,
email: p.email ?? p.Email ?? undefined,
+1
View File
@@ -17,6 +17,7 @@ export type PublicSettings = {
frontpage_layout?: string;
frontpage_style?: string;
hero_style?: 'grid' | 'scroller' | 'swiper' | 'swiper_full';
premium?: boolean;
club_id?: string;
club_type?: 'football' | 'futsal';
club_name?: string; // preferred display name
+5
View File
@@ -0,0 +1,5 @@
declare namespace JSX {
interface IntrinsicElements {
'ion-icon': any;
}
}