mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #81
This commit is contained in:
@@ -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()}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 Kč)</Button>
|
||||
<Button size="sm" onClick={() => setForm({ ...form, type: 'avatar_animated_upload_unlock', cost_points: 150 })}>Odemknout animovaný upload (150b ~ 15 Kč)</Button>
|
||||
<Button size="sm" onClick={() => setForm({ ...form, type: 'avatar_upload_unlock', cost_points: 250 })}>Odemknout upload (250b ~ 25 Kč)</Button>
|
||||
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_coupon', cost_points: 1000 })}>Kupon (1000b ~ 100 Kč)</Button>
|
||||
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_coupon', cost_points: 2000 })}>Kupon (2000b ~ 200 Kč)</Button>
|
||||
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_physical', cost_points: 4000, stock: 1 })}>Fyzická odměna (4000b ~ 400 Kč)</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>
|
||||
|
||||
@@ -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">×</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> © 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;
|
||||
Reference in New Issue
Block a user