mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #80
This commit is contained in:
@@ -2,6 +2,7 @@ import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Li
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
|
||||
import { articleRead } from '../services/engagement';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
@@ -68,6 +69,24 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Award engagement for article read after 15s dwell (once per article per device)
|
||||
React.useEffect(() => {
|
||||
const aid = (data as any)?.id;
|
||||
if (!aid) return;
|
||||
let timer: any;
|
||||
const key = `fc_ar_read_${aid}`;
|
||||
const already = (() => { try { return localStorage.getItem(key) === '1'; } catch { return false; } })();
|
||||
if (!already) {
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
await articleRead(Number(aid));
|
||||
try { localStorage.setItem(key, '1'); } catch {}
|
||||
} catch {}
|
||||
}, 15000);
|
||||
}
|
||||
return () => { if (timer) clearTimeout(timer); };
|
||||
}, [(data as any)?.id]);
|
||||
|
||||
// Delegated click tracking for normal links inside content
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -38,6 +38,7 @@ import NextMatch from '../components/pack/NextMatch';
|
||||
const MatchesSlider = React.lazy(() => import('../components/pack/MatchesSlider'));
|
||||
import ActivitiesList from '../components/pack/ActivitiesList';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SweepstakeWidget from '../components/sweepstakes/SweepstakeWidget';
|
||||
|
||||
// Types for real API-driven data
|
||||
type NewsItem = {
|
||||
@@ -1544,6 +1545,9 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Sweepstakes / Lottery widget (visible around matches section) */}
|
||||
<SweepstakeWidget />
|
||||
|
||||
{/* (Removed) Full-bleed top banner (homepage_top) */}
|
||||
|
||||
{/* Matches slider with scores by competition (moved after news+tables) */}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Box, Center, Spinner, SimpleGrid, Image, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { listSponsorsPublic } from '@/services/scoreboard';
|
||||
const OverlaySponsorsPage: React.FC = () => {
|
||||
const bg = useColorModeValue('transparent', 'transparent');
|
||||
const { data, isLoading } = useQuery<string[]>({
|
||||
queryKey: ['public-sponsors-list'],
|
||||
queryFn: listSponsorsPublic,
|
||||
refetchInterval: 10000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bg} display="flex" alignItems="center" justifyContent="center" p={6}>
|
||||
{isLoading ? (
|
||||
<Center><Spinner /></Center>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 5 }} spacing={{ base: 6, md: 10 }}>
|
||||
{(data || []).map((src, i) => (
|
||||
<Box key={`${src}-${i}`} p={3} bg="rgba(255,255,255,0.0)" borderRadius="md">
|
||||
<Image src={src} alt={`sponsor-${i}`} maxH="64px" mx="auto" objectFit="contain"/>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverlaySponsorsPage;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -17,9 +17,20 @@ import {
|
||||
VStack,
|
||||
HStack,
|
||||
Link as ChakraLink,
|
||||
Select,
|
||||
Avatar,
|
||||
Badge,
|
||||
Progress,
|
||||
useColorModeValue,
|
||||
SimpleGrid,
|
||||
Image,
|
||||
IconButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import api from '../services/api';
|
||||
import { getLeaderboard, LeaderboardItem, getProfile, EngagementProfile, getRewards, RewardItem, redeemReward, patchAvatar, patchProfile, getMyTransactions, PointsTx, getAchievements } from '../services/engagement';
|
||||
import { getMyWinnings } from '../services/sweepstakes';
|
||||
import { Upload, RefreshCw, Pencil, Gift } from 'lucide-react';
|
||||
|
||||
const SemiAdminPage: React.FC = () => {
|
||||
const { user, updateUser } = useAuth();
|
||||
@@ -52,6 +63,40 @@ const SemiAdminPage: React.FC = () => {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
setLoadingProf(true);
|
||||
try {
|
||||
const p = await getProfile();
|
||||
setProf(p);
|
||||
setUsernameEdit(p.username || '');
|
||||
} finally {
|
||||
setLoadingProf(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRewards = async () => {
|
||||
setLoadingRewards(true);
|
||||
try { setRewards(await getRewards()); } finally { setLoadingRewards(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadProfile(); loadRewards(); }, []);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setTxLoading(true);
|
||||
try { const items = await getMyTransactions({ limit: 100 }); setTxItems(items); } finally { setTxLoading(false); }
|
||||
})();
|
||||
(async () => {
|
||||
setAchLoading(true);
|
||||
try { const res = await getAchievements(); setAchItems(res.achievements || []); } finally { setAchLoading(false); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onRefresh = () => { loadProfile().catch(()=>{}); };
|
||||
window.addEventListener('engagement:refresh', onRefresh as any);
|
||||
return () => window.removeEventListener('engagement:refresh', onRefresh as any);
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
@@ -71,14 +116,217 @@ const SemiAdminPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const prefsUrl = prefsToken ? `/newsletter/preferences?token=${encodeURIComponent(prefsToken)}` : '';
|
||||
const [metric, setMetric] = useState<'points'|'level'|'xp'>('points');
|
||||
const [leaders, setLeaders] = useState<LeaderboardItem[]>([]);
|
||||
const [loadingLb, setLoadingLb] = useState<boolean>(false);
|
||||
const [txLoading, setTxLoading] = useState<boolean>(false);
|
||||
const [txItems, setTxItems] = useState<PointsTx[]>([]);
|
||||
const [achLoading, setAchLoading] = useState<boolean>(false);
|
||||
const [achItems, setAchItems] = useState<any[]>([]);
|
||||
const [winsLoading, setWinsLoading] = useState<boolean>(false);
|
||||
const [wins, setWins] = useState<Array<{ id:number; prize_name?: string; claim_status: string; created_at?: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setWinsLoading(true);
|
||||
const res = await getMyWinnings();
|
||||
setWins((res.items || []).map((w:any) => ({ id: w.id, prize_name: w.prize_name, claim_status: w.claim_status, created_at: w.created_at })));
|
||||
} catch {
|
||||
setWins([]);
|
||||
} finally {
|
||||
setWinsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Engagement profile
|
||||
const [prof, setProf] = useState<EngagementProfile | null>(null);
|
||||
const [loadingProf, setLoadingProf] = useState<boolean>(true);
|
||||
const [rewards, setRewards] = useState<RewardItem[]>([]);
|
||||
const [loadingRewards, setLoadingRewards] = useState<boolean>(false);
|
||||
const [usernameEdit, setUsernameEdit] = useState<string>('');
|
||||
const [usernameEditing, setUsernameEditing] = useState<boolean>(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const border = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
setLoadingLb(true);
|
||||
const res = await getLeaderboard(metric, 20);
|
||||
if (mounted) setLeaders(res.items || []);
|
||||
} catch {
|
||||
if (mounted) setLeaders([]);
|
||||
} finally {
|
||||
if (mounted) setLoadingLb(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [metric]);
|
||||
|
||||
// XP thresholds helpers
|
||||
const levelInfo = useMemo(() => {
|
||||
if (!prof) return { level: 1, xp: 0, currentBase: 0, nextBase: 100, pct: 0 };
|
||||
const L = Math.max(1, Number(prof.level || 1));
|
||||
const xp = Number(prof.xp || 0);
|
||||
// total needed to reach level L: 100 + 200 + ... + 100*(L-1) = 50*(L-1)*L
|
||||
const totalToL = 50 * (L - 1) * L;
|
||||
const nextInc = 100 * L;
|
||||
const totalToNext = totalToL + nextInc;
|
||||
const inLevel = Math.max(0, xp - totalToL);
|
||||
const pct = Math.max(0, Math.min(100, Math.floor((inLevel / Math.max(1, nextInc)) * 100)));
|
||||
return { level: L, xp, currentBase: totalToL, nextBase: totalToNext, pct, inLevel, nextInc };
|
||||
}, [prof]);
|
||||
|
||||
const baseNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const nameColor = useMemo(() => {
|
||||
const L = levelInfo.level;
|
||||
if (L >= 20) return 'yellow.400'; // gold
|
||||
if (L >= 15) return 'purple.400'; // epic
|
||||
if (L >= 10) return 'blue.400'; // rare
|
||||
if (L >= 5) return 'teal.400'; // uncommon
|
||||
return baseNameColor;
|
||||
}, [levelInfo.level, baseNameColor]);
|
||||
|
||||
const triggerUpload = () => {
|
||||
if (!prof?.avatar_upload_unlocked) {
|
||||
toast({ status: 'info', title: 'Odemkněte nahrání avataru', description: 'V obchodě níže můžete odemknout možnost nahrát vlastní profilový obrázek.', duration: 3500 });
|
||||
const el = document.getElementById('rewards-store'); if (el) el.scrollIntoView({ behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
fileRef.current?.click();
|
||||
};
|
||||
|
||||
const onFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const res = await api.post('/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
const url = res.data?.url || res.data?.absolute_url;
|
||||
if (!url) throw new Error('Upload selhal');
|
||||
await patchAvatar({ avatar_url: url });
|
||||
toast({ status: 'success', title: 'Avatar aktualizován' });
|
||||
await loadProfile();
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error || 'Nahrání selhalo';
|
||||
toast({ status: 'error', title: 'Chyba', description: msg });
|
||||
} finally {
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const randomizeAvatar = async () => {
|
||||
const seed = Math.random().toString(36).slice(2, 10);
|
||||
const url = `https://api.dicebear.com/7.x/pixel-art/svg?radius=50&seed=${encodeURIComponent(seed)}`;
|
||||
await patchAvatar({ avatar_url: url });
|
||||
await loadProfile();
|
||||
toast({ status: 'success', title: 'Náhodný avatar nastaven' });
|
||||
};
|
||||
|
||||
const saveUsername = async () => {
|
||||
const v = usernameEdit.trim();
|
||||
if (!v) { toast({ status: 'warning', title: 'Uživatelské jméno je prázdné' }); return; }
|
||||
try {
|
||||
setUsernameEditing(true);
|
||||
await patchProfile({ username: v });
|
||||
toast({ status: 'success', title: 'Uživatelské jméno uloženo' });
|
||||
await loadProfile();
|
||||
} catch (e: any) {
|
||||
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Nelze uložit' });
|
||||
} finally { setUsernameEditing(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxW="5xl" py={8}>
|
||||
<Heading size="lg" mb={6}>Fan zóna</Heading>
|
||||
|
||||
{/* Profile header */}
|
||||
<Box borderWidth="1px" borderColor={border} bg={cardBg} borderRadius="lg" p={5} mb={6} textAlign="center">
|
||||
<VStack spacing={3} align="center">
|
||||
<Box position="relative" display="inline-block">
|
||||
<Avatar size="2xl" name={user?.name || prof?.username || 'Uživatel'} src={prof?.animated_avatar_url || prof?.avatar_url || undefined} />
|
||||
{/* Upload icon (left) */}
|
||||
<IconButton aria-label="Nahrát avatar" icon={<Upload size={16} />} size="sm" variant="solid" colorScheme="blue" position="absolute" left="-10px" top="50%" transform="translateY(-50%)" onClick={triggerUpload} />
|
||||
{/* Level badge (right) */}
|
||||
<Badge position="absolute" right="-10px" top="50%" transform="translateY(-50%)" colorScheme="yellow" fontSize="0.8rem" p={2} borderRadius="md">Lv {levelInfo.level}</Badge>
|
||||
{/* Randomize (bottom) */}
|
||||
<IconButton aria-label="Náhodný avatar" icon={<RefreshCw size={16} />} size="xs" variant="ghost" position="absolute" bottom="-6px" right="50%" transform="translateX(50%)" onClick={randomizeAvatar} />
|
||||
<input ref={fileRef} type="file" accept="image/*,image/gif" style={{ display: 'none' }} onChange={onFileSelected} />
|
||||
</Box>
|
||||
{/* Username */}
|
||||
<HStack spacing={2}>
|
||||
{!usernameEditing && (
|
||||
<Text fontSize="xl" fontWeight="700" color={nameColor}>
|
||||
{(prof?.username || '').trim() || 'Nastavte uživatelské jméno'}
|
||||
</Text>
|
||||
)}
|
||||
<IconButton aria-label="Upravit" size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => setUsernameEditing((v)=>!v)} />
|
||||
</HStack>
|
||||
{usernameEditing && (
|
||||
<HStack spacing={2}>
|
||||
<Input value={usernameEdit} onChange={(e)=>setUsernameEdit(e.target.value)} placeholder="uživatelské-jméno" maxW="260px" />
|
||||
<Button colorScheme="blue" size="sm" isLoading={usernameEditing} onClick={saveUsername}>Uložit</Button>
|
||||
</HStack>
|
||||
)}
|
||||
{/* Full name */}
|
||||
<Text color={useColorModeValue('gray.600','gray.400')}>{`${firstName || ''} ${lastName || ''}`.trim() || '—'}</Text>
|
||||
{/* XP progress */}
|
||||
<HStack w="100%" maxW="lg" spacing={3} align="center">
|
||||
<Box flex={1}>
|
||||
<Progress value={levelInfo.pct} size="md" borderRadius="full" colorScheme="blue" />
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600','gray.400')} mt={1}>{levelInfo.inLevel || 0} / {levelInfo.nextInc} XP do další úrovně</Text>
|
||||
</Box>
|
||||
<Badge colorScheme="yellow">Lv {levelInfo.level}</Badge>
|
||||
</HStack>
|
||||
{/* Points */}
|
||||
<Text>Aktuální body: <Text as="span" fontWeight="700">{prof?.points ?? 0}</Text></Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Store */}
|
||||
<Box id="rewards-store" borderWidth="1px" borderColor={border} bg={cardBg} borderRadius="lg" p={5} mb={8}>
|
||||
<Heading size="md" mb={3}>Obchod s odměnami</Heading>
|
||||
{loadingRewards ? (
|
||||
<Text>Načítám…</Text>
|
||||
) : (
|
||||
<SimpleGrid minChildWidth="220px" spacing={4}>
|
||||
{rewards.map((r) => (
|
||||
<Box key={r.id} borderWidth="1px" borderColor={border} borderRadius="md" p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontWeight="700">{r.name}</Text>
|
||||
{r.image_url && <Image src={r.image_url} alt={r.name} borderRadius="md" />}
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600','gray.400')}>Cena: {r.cost_points} bodů</Text>
|
||||
<Button size="sm" colorScheme="blue" onClick={async ()=>{
|
||||
try { const res = await redeemReward(r.id); toast({ status:'success', title: 'Odměna uplatněna', description: res.status }); await loadProfile(); }
|
||||
catch(e:any){ toast({ status:'error', title:'Chyba', description: e?.response?.data?.error || 'Nelze uplatnit odměnu' }); }
|
||||
}}>Uplatnit</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<Box mt={4}>
|
||||
<Heading size="sm" mb={2}>Jak získat body</Heading>
|
||||
<VStack align="start" spacing={1} fontSize="sm" color={useColorModeValue('gray.700','gray.300')}>
|
||||
<Text>• Napište smysluplný komentář (+5)</Text>
|
||||
<Text>• Hlasujte v anketě (+3, 1× denně)</Text>
|
||||
<Text>• Přihlaste se k newsletteru (+12)</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
<Tabs colorScheme="blue" isFitted variant="enclosed">
|
||||
<TabList>
|
||||
<Tab>Osobní údaje</Tab>
|
||||
<Tab>Newsletter</Tab>
|
||||
<Tab>Žebříčky</Tab>
|
||||
<Tab>Historie bodů</Tab>
|
||||
<Tab>Úspěchy</Tab>
|
||||
<Tab>Výhry</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
@@ -108,6 +356,108 @@ const SemiAdminPage: React.FC = () => {
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">Žebříčky</Heading>
|
||||
<HStack>
|
||||
<Select size="sm" value={metric} onChange={(e)=>setMetric(e.target.value as any)} maxW="180px">
|
||||
<option value="points">Body</option>
|
||||
<option value="level">Úroveň</option>
|
||||
<option value="xp">XP</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Box borderWidth="1px" borderColor={border} borderRadius="md" bg={cardBg} p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{loadingLb && <Text>Načítám…</Text>}
|
||||
{!loadingLb && leaders.length === 0 && (
|
||||
<Text>Žádná data k zobrazení.</Text>
|
||||
)}
|
||||
{!loadingLb && leaders.map((it) => {
|
||||
const value = metric === 'points' ? it.points : (metric === 'level' ? it.level : it.xp);
|
||||
const max = Math.max(...leaders.map(l => metric === 'points' ? l.points : (metric === 'level' ? l.level : l.xp)), 1);
|
||||
const pct = Math.max(2, Math.floor((Number(value) / Number(max)) * 100));
|
||||
const name = (it.username || '').trim() || `${it.first_name || ''} ${it.last_name || ''}`.trim() || `#${it.user_id}`;
|
||||
return (
|
||||
<HStack key={`${metric}-${it.user_id}`} spacing={3}>
|
||||
<Badge colorScheme="blue">{it.rank}</Badge>
|
||||
<Avatar size="sm" name={name} src={it.animated_avatar_url || it.avatar_url || undefined} />
|
||||
<Box flex={1}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="600" noOfLines={1}>{name}</Text>
|
||||
<Text fontSize="sm">{value}</Text>
|
||||
</HStack>
|
||||
<Progress value={pct} size="sm" colorScheme="blue" borderRadius="full" mt={1} />
|
||||
</Box>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{txLoading ? (
|
||||
<Text>Načítám…</Text>
|
||||
) : (
|
||||
<Box borderWidth="1px" borderColor={border} borderRadius="md" overflowX="auto">
|
||||
<Box as="table" w="100%" style={{ borderCollapse: 'collapse' }}>
|
||||
<Box as="thead" bg={useColorModeValue('gray.50','gray.700')}>
|
||||
<Box as="tr">
|
||||
<Box as="th" p={2} textAlign="left">Čas</Box>
|
||||
<Box as="th" p={2} textAlign="left">Delta</Box>
|
||||
<Box as="th" p={2} textAlign="left">Důvod</Box>
|
||||
<Box as="th" p={2} textAlign="left">Meta</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box as="tbody">
|
||||
{txItems.map((t) => (
|
||||
<Box as="tr" key={t.id} borderTopWidth="1px" borderColor={border}>
|
||||
<Box as="td" p={2}>{t.created_at ? new Date(t.created_at).toLocaleString() : '-'}</Box>
|
||||
<Box as="td" p={2}><Badge colorScheme={t.delta >= 0 ? 'green' : 'red'}>{t.delta >= 0 ? `+${t.delta}` : t.delta}</Badge></Box>
|
||||
<Box as="td" p={2}><Badge>{t.reason}</Badge></Box>
|
||||
<Box as="td" p={2}><Text fontSize="xs" noOfLines={1}>{t.meta ? JSON.stringify(t.meta) : '-'}</Text></Box>
|
||||
</Box>
|
||||
))}
|
||||
{txItems.length === 0 && (
|
||||
<Box as="tr"><Box as="td" p={3} colSpan={4}><Text color={useColorModeValue('gray.600','gray.400')}>Žádné transakce.</Text></Box></Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{achLoading ? (
|
||||
<Text>Načítám…</Text>
|
||||
) : (
|
||||
<SimpleGrid minChildWidth="220px" spacing={4}>
|
||||
{achItems.map((a: any) => (
|
||||
<Box key={a.id} borderWidth="1px" borderColor={border} borderRadius="md" p={3} bg={cardBg}>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="600">{a.title}</Text>
|
||||
{a.achieved ? <Badge colorScheme="green">Splněno</Badge> : <Badge colorScheme="gray">Nesplněno</Badge>}
|
||||
</HStack>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600','gray.400')}>{a.description}</Text>
|
||||
<HStack>
|
||||
<Badge>{a.points} bodů</Badge>
|
||||
{a.achieved_at && <Text fontSize="xs" color={useColorModeValue('gray.500','gray.400')}>{new Date(a.achieved_at).toLocaleString()}</Text>}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
{achItems.length === 0 && (
|
||||
<Text color={useColorModeValue('gray.600','gray.400')}>Žádné úspěchy k zobrazení.</Text>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Container>
|
||||
|
||||
@@ -91,7 +91,17 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||
const label = m
|
||||
? `${String(m.home || m.home_team || '')} ${String(scoreText)} ${String(m.away || m.away_team || '')}`
|
||||
: `ID: ${String(mid)}`;
|
||||
const linkHref = (m && (m.facr_link || m.report_url)) ? String(m.facr_link || m.report_url) : '';
|
||||
const linkHrefRaw = (m && (m.facr_link || m.report_url)) ? String(m.facr_link || m.report_url) : '';
|
||||
const normalizeFacrLink = (href: string): string => {
|
||||
try {
|
||||
const u = new URL(href, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000');
|
||||
if (u.hostname === 'is.fotbal.cz') {
|
||||
u.hostname = 'www.fotbal.cz';
|
||||
}
|
||||
return u.toString();
|
||||
} catch { return href; }
|
||||
};
|
||||
const linkHref = linkHrefRaw ? normalizeFacrLink(linkHrefRaw) : '';
|
||||
return (
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme={color as any} title={m?.competitionName ? String(m.competitionName) : undefined}>Zápas: {label}</Badge>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField } from '@chakra-ui/react';
|
||||
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch } from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
|
||||
import { deleteComment } from '../../services/comments';
|
||||
@@ -12,6 +12,7 @@ const CommentsAdminPage: React.FC = () => {
|
||||
const [targetId, setTargetId] = React.useState<string>('');
|
||||
const [userId, setUserId] = React.useState<string>('');
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [reportedOnly, setReportedOnly] = React.useState<boolean>(false);
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -49,7 +50,11 @@ const CommentsAdminPage: React.FC = () => {
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
|
||||
});
|
||||
|
||||
const items = listQ.data?.items || [];
|
||||
const itemsAll = listQ.data?.items || [];
|
||||
const items = React.useMemo(() => {
|
||||
if (!reportedOnly) return itemsAll;
|
||||
return itemsAll.filter((c: any) => (c as any).reports && (c as any).reports > 0);
|
||||
}, [itemsAll, reportedOnly]);
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
@@ -69,6 +74,10 @@ const CommentsAdminPage: React.FC = () => {
|
||||
</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" />
|
||||
<HStack>
|
||||
<Text fontSize="sm" color="gray.500">Jen nahlášené</Text>
|
||||
<Switch isChecked={reportedOnly} onChange={(e)=>setReportedOnly(e.target.checked)} />
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
@@ -81,6 +90,7 @@ const CommentsAdminPage: React.FC = () => {
|
||||
<Th>Cíl</Th>
|
||||
<Th>Obsah</Th>
|
||||
<Th>Spam</Th>
|
||||
<Th>Hlášení</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
@@ -93,6 +103,7 @@ const CommentsAdminPage: React.FC = () => {
|
||||
<Td><Badge>{c.target_type}</Badge> <Text as="span">{c.target_id}</Text></Td>
|
||||
<Td maxW="420px"><Text noOfLines={2}>{c.content}</Text></Td>
|
||||
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
|
||||
<Td>{(c as any).reports ? <Badge colorScheme={(c as any).reports > 2 ? 'red' : 'yellow'}>{(c as any).reports}</Badge> : '-'}</Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Button size="xs" variant={c.status === 'visible' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'visible' })}>Viditelné</Button>
|
||||
@@ -111,6 +122,14 @@ const CommentsAdminPage: React.FC = () => {
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<HStack mt={3} justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">Stránka {page} • {listQ.data?.total || 0} komentářů</Text>
|
||||
<HStack>
|
||||
<Button size="sm" variant="outline" onClick={() => setPage(p => Math.max(1, p - 1))} isDisabled={page <= 1}>Předchozí</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setPage(p => p + 1)} isDisabled={(itemsAll.length === 0) || ((itemsAll.length < 50) && (listQ.data?.total || 0) <= (page * 50))}>Další</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Heading size="sm" mt={6} mb={2}>Žádosti o odblokování</Heading>
|
||||
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
@@ -160,6 +179,12 @@ const CommentsAdminPage: React.FC = () => {
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color="gray.500">Rychlá volba:</Text>
|
||||
<Button size="xs" variant="outline" onClick={()=>setBanHours(24)}>24h</Button>
|
||||
<Button size="xs" variant="outline" onClick={()=>setBanHours(24*7)}>7 dní</Button>
|
||||
<Button size="xs" variant="outline" onClick={()=>setBanHours(0)}>Trvale</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -23,6 +23,21 @@ import {
|
||||
NumberInputField,
|
||||
Image,
|
||||
Divider,
|
||||
Avatar,
|
||||
Progress,
|
||||
useColorModeValue,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
useDisclosure,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
Textarea,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -32,18 +47,25 @@ import {
|
||||
adminDeleteReward,
|
||||
adminListRedemptions,
|
||||
adminUpdateRedemptionStatus,
|
||||
adminGetLeaderboard,
|
||||
adminListTransactions,
|
||||
adminAdjustPoints,
|
||||
AdminRewardItem,
|
||||
AdminRedemption,
|
||||
} from '../../services/admin/engagement';
|
||||
import { FiTrash2 } from 'react-icons/fi';
|
||||
import { FiTrash2, FiEdit2 } from 'react-icons/fi';
|
||||
import api from '../../services/api';
|
||||
|
||||
const EngagementAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const border = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
const [rewardFilter, setRewardFilter] = React.useState<'all'|'active'|'inactive'>('all');
|
||||
const rewardsQ = useQuery({
|
||||
queryKey: ['admin-engagement-rewards'],
|
||||
queryFn: () => adminListRewards(),
|
||||
queryKey: ['admin-engagement-rewards', rewardFilter],
|
||||
queryFn: () => rewardFilter === 'all' ? adminListRewards() : adminListRewards({ active: rewardFilter === 'active' }),
|
||||
});
|
||||
const redemptionsQ = useQuery({
|
||||
queryKey: ['admin-engagement-redemptions'],
|
||||
@@ -59,14 +81,88 @@ const EngagementAdminPage: React.FC = () => {
|
||||
active: true,
|
||||
});
|
||||
|
||||
const [editItem, setEditItem] = React.useState<AdminRewardItem | null>(null);
|
||||
const editModal = useDisclosure();
|
||||
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
|
||||
const [editMetaJson, setEditMetaJson] = React.useState<string>('');
|
||||
|
||||
const [batch, setBatch] = React.useState({
|
||||
base_url: '',
|
||||
name_prefix: 'Avatar',
|
||||
count: 5,
|
||||
start_index: 1,
|
||||
type: 'avatar_static' as string,
|
||||
cost_points: 50,
|
||||
stock: 0,
|
||||
active: true,
|
||||
});
|
||||
const batchModal = useDisclosure();
|
||||
const [metaJson, setMetaJson] = React.useState<string>('');
|
||||
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>>({});
|
||||
|
||||
const handleUpload = async (file?: File) => {
|
||||
try {
|
||||
const f = file || fileInputRef.current?.files?.[0];
|
||||
if (!f) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const res = await api.post('/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
const url = (res.data?.url || '').trim();
|
||||
if (url) setForm(prev => ({ ...prev, image_url: url }));
|
||||
} catch (e: any) {
|
||||
toast({ status: 'error', title: e?.response?.data?.error || 'Nahrání souboru selhalo' });
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadEdit = async (file?: File) => {
|
||||
try {
|
||||
const f = file || editFileInputRef.current?.files?.[0];
|
||||
if (!f) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const res = await api.post('/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
const url = (res.data?.url || '').trim();
|
||||
if (url) setEditForm(prev => ({ ...prev, image_url: url }));
|
||||
} catch (e: any) {
|
||||
toast({ status: 'error', title: e?.response?.data?.error || 'Nahrání souboru selhalo' });
|
||||
} finally {
|
||||
if (editFileInputRef.current) editFileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
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: () => adminCreateReward(form),
|
||||
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'); }
|
||||
}
|
||||
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' });
|
||||
},
|
||||
onError: (e: any) => toast({ status: 'error', title: e?.response?.data?.error || 'Chyba při vytváření odměny' }),
|
||||
onError: (e: any) => toast({ status: 'error', title: e?.message || e?.response?.data?.error || 'Chyba při vytváření odměny' }),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
@@ -84,37 +180,227 @@ const EngagementAdminPage: React.FC = () => {
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-redemptions'] }); toast({ status: 'success', title: 'Status aktualizován' }); },
|
||||
});
|
||||
|
||||
const batchMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const total = Math.max(0, Number(batch.count) || 0);
|
||||
const start = Math.max(0, Number(batch.start_index) || 0);
|
||||
if (!batch.base_url.trim() || total <= 0) throw new Error('Zadejte prosím základní URL a počet.');
|
||||
for (let i = 0; i < total; i++) {
|
||||
const idx = start + i;
|
||||
const image_url = batch.base_url.replace('{i}', String(idx));
|
||||
const name = `${batch.name_prefix} ${idx}`.trim();
|
||||
await adminCreateReward({
|
||||
name,
|
||||
type: batch.type,
|
||||
cost_points: batch.cost_points,
|
||||
image_url,
|
||||
stock: batch.stock,
|
||||
active: batch.active,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
|
||||
batchModal.onClose();
|
||||
toast({ status: 'success', title: 'Dávka vytvořena' });
|
||||
},
|
||||
onError: (e: any) => toast({ status: 'error', title: e?.message || 'Chyba při dávkovém vytváření' }),
|
||||
});
|
||||
|
||||
const rewards = rewardsQ.data || [];
|
||||
const redemptions = redemptionsQ.data || [];
|
||||
const [metric, setMetric] = React.useState<'points'|'level'|'xp'>('points');
|
||||
const [leaders, setLeaders] = React.useState<any[]>([]);
|
||||
const [loadingLb, setLoadingLb] = React.useState(false);
|
||||
|
||||
const rewardById = React.useMemo(() => {
|
||||
const m = new Map<number, AdminRewardItem>();
|
||||
for (const r of rewards) m.set(r.id as any, r);
|
||||
return m;
|
||||
}, [rewards]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
setLoadingLb(true);
|
||||
const res = await adminGetLeaderboard(metric, 50);
|
||||
if (mounted) setLeaders(res.items || []);
|
||||
} catch {
|
||||
if (mounted) setLeaders([]);
|
||||
} finally {
|
||||
if (mounted) setLoadingLb(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [metric]);
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<Heading size="md" mb={4}>Odměny & Úspěchy</Heading>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Žebříčky</Heading>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontSize="sm" color="gray.500">Top uživatelé podle zvoleného metrického ukazatele</Text>
|
||||
<Select size="sm" value={metric} onChange={(e)=>setMetric(e.target.value as any)} maxW="180px">
|
||||
<option value="points">Body</option>
|
||||
<option value="level">Úroveň</option>
|
||||
<option value="xp">XP</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
<Box borderWidth="1px" borderRadius="md" borderColor={border} bg={cardBg} p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{loadingLb && <Text>Načítám…</Text>}
|
||||
{!loadingLb && leaders.length === 0 && <Text>Žádná data k zobrazení.</Text>}
|
||||
{!loadingLb && leaders.map((it: any) => {
|
||||
const value = metric === 'points' ? it.points : (metric === 'level' ? it.level : it.xp);
|
||||
const max = Math.max(...leaders.map((l: any) => metric === 'points' ? l.points : (metric === 'level' ? l.level : l.xp)), 1);
|
||||
const pct = Math.max(2, Math.floor((Number(value) / Number(max)) * 100));
|
||||
const name = `${it.first_name || ''} ${it.last_name || ''}`.trim() || it.email || `#${it.user_id}`;
|
||||
return (
|
||||
<HStack key={`lb-${metric}-${it.user_id}`} spacing={3}>
|
||||
<Badge colorScheme="blue">{it.rank}</Badge>
|
||||
<Avatar size="sm" name={name} src={it.animated_avatar_url || it.avatar_url || undefined} />
|
||||
<Box flex={1}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="600" noOfLines={1}>{name}</Text>
|
||||
<Text fontSize="sm">{value}</Text>
|
||||
</HStack>
|
||||
<Progress value={pct} size="sm" colorScheme="blue" borderRadius="full" mt={1} />
|
||||
</Box>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading>
|
||||
<VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}>
|
||||
<HStack>
|
||||
<Input placeholder="Název" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} maxW="280px" />
|
||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} maxW="220px">
|
||||
<option value="avatar_static">Avatar (statický)</option>
|
||||
<option value="avatar_animated">Avatar (animovaný)</option>
|
||||
<option value="merch_coupon">Merch kupon</option>
|
||||
<option value="custom">Vlastní</option>
|
||||
</Select>
|
||||
<NumberInput value={form.cost_points} min={0} maxW="180px" onChange={(v) => setForm({ ...form, cost_points: Number(v) || 0 })}>
|
||||
<NumberInputField placeholder="Body" />
|
||||
</NumberInput>
|
||||
<NumberInput value={form.stock} min={0} maxW="160px" onChange={(v) => setForm({ ...form, stock: Number(v) || 0 })}>
|
||||
<NumberInputField placeholder="Sklad" />
|
||||
</NumberInput>
|
||||
<Input placeholder="Obrázek URL" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
|
||||
<HStack>
|
||||
<Text>Aktivní</Text>
|
||||
<Switch isChecked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
|
||||
</HStack>
|
||||
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
|
||||
<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>
|
||||
<HStack align="start" spacing={4}>
|
||||
<VStack align="stretch" spacing={3} flex={1}>
|
||||
<FormControl>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input placeholder="Např. Modrý avatar #1" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Typ odměny</FormLabel>
|
||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="avatar_static">Avatar (statický)</option>
|
||||
<option value="avatar_animated">Avatar (animovaný)</option>
|
||||
<option value="avatar_animated_upload_unlock">Odemknutí animovaného avataru (upload)</option>
|
||||
<option value="avatar_upload_unlock">Odemknutí vlastního avataru</option>
|
||||
<option value="merch_coupon">Merch kupon</option>
|
||||
<option value="merch_physical">Merch (fyzický)</option>
|
||||
<option value="merch_digital">Merch (digitální)</option>
|
||||
<option value="custom">Vlastní</option>
|
||||
</Select>
|
||||
<FormHelperText>Ovlivňuje chování po uplatnění (např. nastavení avataru).</FormHelperText>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<NumberInput value={form.cost_points} min={0} onChange={(_v, n) => setForm({ ...form, cost_points: Number.isFinite(n) ? n : 0 })}>
|
||||
<NumberInputField placeholder="Počet bodů" />
|
||||
</NumberInput>
|
||||
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} Kč</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Sklad</FormLabel>
|
||||
<NumberInput value={form.stock} min={0} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : 0 })}>
|
||||
<NumberInputField placeholder="Ks (0 = neomezeně)" />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek URL</FormLabel>
|
||||
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
|
||||
<FormHelperText>Pro avatar uveďte URL obrázku. Pro odemknutí uploadu není třeba.</FormHelperText>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
|
||||
</HStack>
|
||||
{/* Metadata helpers */}
|
||||
{form.type === 'merch_coupon' && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<FormControl>
|
||||
<FormLabel>Kód kuponu</FormLabel>
|
||||
<Input value={meta.coupon_code || ''} onChange={(e)=>setMetaField('coupon_code', e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Platnost do (ISO nebo datum)</FormLabel>
|
||||
<Input value={meta.expires_at || ''} onChange={(e)=>setMetaField('expires_at', e.target.value)} placeholder="2025-12-31" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Poznámka</FormLabel>
|
||||
<Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
)}
|
||||
{form.type === 'merch_physical' && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<FormControl><FormLabel>SKU</FormLabel><Input value={meta.sku || ''} onChange={(e)=>setMetaField('sku', e.target.value)} /></FormControl>
|
||||
<HStack>
|
||||
<FormControl><FormLabel>Velikost</FormLabel><Input value={meta.size || ''} onChange={(e)=>setMetaField('size', e.target.value)} placeholder="M / L / XL" /></FormControl>
|
||||
<FormControl><FormLabel>Barva</FormLabel><Input value={meta.color || ''} onChange={(e)=>setMetaField('color', e.target.value)} /></FormControl>
|
||||
</HStack>
|
||||
<FormControl><FormLabel>Poznámka</FormLabel><Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} /></FormControl>
|
||||
</VStack>
|
||||
)}
|
||||
{form.type === 'merch_digital' && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<FormControl><FormLabel>Licenční klíč</FormLabel><Input value={meta.license_key || ''} onChange={(e)=>setMetaField('license_key', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input value={meta.download_url || ''} onChange={(e)=>setMetaField('download_url', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Poznámka</FormLabel><Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} /></FormControl>
|
||||
</VStack>
|
||||
)}
|
||||
{form.type === 'custom' && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack>
|
||||
<Input placeholder="klíč" id="kv-key" />
|
||||
<Input placeholder="hodnota" id="kv-value" />
|
||||
<Button size="sm" onClick={()=>{
|
||||
const k = (document.getElementById('kv-key') as HTMLInputElement)?.value?.trim();
|
||||
const v = (document.getElementById('kv-value') as HTMLInputElement)?.value?.trim();
|
||||
if (!k) return;
|
||||
setMetaField(k, v || '');
|
||||
}}>Přidat</Button>
|
||||
</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>
|
||||
<HStack>
|
||||
<Text>Aktivní</Text>
|
||||
<Switch isChecked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
|
||||
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Box>
|
||||
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
|
||||
<Box borderWidth="1px" borderRadius="md" p={2}>
|
||||
{form.image_url ? (
|
||||
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
|
||||
) : (
|
||||
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
@@ -123,6 +409,14 @@ const EngagementAdminPage: React.FC = () => {
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Odměny</Heading>
|
||||
<HStack mb={2}>
|
||||
<Text fontSize="sm" color="gray.500">Filtrovat:</Text>
|
||||
<Select size="sm" value={rewardFilter} onChange={(e)=>setRewardFilter(e.target.value as any)} maxW="200px">
|
||||
<option value="all">Vše</option>
|
||||
<option value="active">Pouze aktivní</option>
|
||||
<option value="inactive">Pouze neaktivní</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
@@ -144,12 +438,12 @@ const EngagementAdminPage: React.FC = () => {
|
||||
<Td>{r.name}</Td>
|
||||
<Td><Badge>{r.type}</Badge></Td>
|
||||
<Td>
|
||||
<NumberInput size="sm" value={r.cost_points} min={0} maxW="120px" onChange={(v) => updateMut.mutate({ id: r.id, body: { cost_points: Number(v) || 0 } })}>
|
||||
<NumberInput size="sm" value={r.cost_points} min={0} maxW="120px" onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { cost_points: Number.isFinite(n) ? n : 0 } })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</Td>
|
||||
<Td>
|
||||
<NumberInput size="sm" value={r.stock || 0} min={0} maxW="100px" onChange={(v) => updateMut.mutate({ id: r.id, body: { stock: Number(v) || 0 } })}>
|
||||
<NumberInput size="sm" value={r.stock || 0} min={0} maxW="100px" onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { stock: Number.isFinite(n) ? n : 0 } })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</Td>
|
||||
@@ -158,7 +452,10 @@ const EngagementAdminPage: React.FC = () => {
|
||||
<Switch isChecked={!!r.active} onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })} />
|
||||
</Td>
|
||||
<Td>
|
||||
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
|
||||
<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="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
@@ -176,6 +473,7 @@ const EngagementAdminPage: React.FC = () => {
|
||||
<Th>ID</Th>
|
||||
<Th>Uživatel</Th>
|
||||
<Th>Odměna</Th>
|
||||
<Th>Vytvořeno</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
@@ -185,8 +483,15 @@ const EngagementAdminPage: React.FC = () => {
|
||||
<Tr key={d.id}>
|
||||
<Td>#{d.id}</Td>
|
||||
<Td>#{d.user_id}</Td>
|
||||
<Td>#{d.reward_id}</Td>
|
||||
<Td><Badge>{d.status}</Badge></Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Text>#{d.reward_id}</Text>
|
||||
{rewardById.get(d.reward_id as any)?.name && <Text as="span">– {rewardById.get(d.reward_id as any)?.name}</Text>}
|
||||
{rewardById.get(d.reward_id as any)?.type && <Badge>{rewardById.get(d.reward_id as any)?.type}</Badge>}
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>{d.created_at ? new Date(d.created_at as any).toLocaleString() : '-'}</Td>
|
||||
<Td><Badge colorScheme={d.status === 'approved' ? 'blue' : d.status === 'fulfilled' ? 'green' : d.status === 'rejected' ? 'red' : 'gray'}>{d.status}</Badge></Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Button size="xs" variant="outline" onClick={() => redStatusMut.mutate({ id: d.id, action: 'approve' })}>Schválit</Button>
|
||||
@@ -200,10 +505,303 @@ const EngagementAdminPage: React.FC = () => {
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Transactions & Adjust */}
|
||||
<Box>
|
||||
<Heading size="sm" mt={6} mb={2}>Transakce bodů & Úpravy</Heading>
|
||||
<TransactionsAndAdjust />
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Edit reward modal */}
|
||||
<Modal isOpen={editModal.isOpen} onClose={editModal.onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Upravit odměnu #{editItem?.id}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })}>
|
||||
<option value="avatar_static">Avatar (statický)</option>
|
||||
<option value="avatar_animated">Avatar (animovaný)</option>
|
||||
<option value="avatar_upload_unlock">Odemknutí vlastního avataru</option>
|
||||
<option value="merch_coupon">Merch kupon</option>
|
||||
<option value="merch_physical">Merch (fyzický)</option>
|
||||
<option value="merch_digital">Merch (digitální)</option>
|
||||
<option value="custom">Vlastní</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<NumberInput value={Number(editForm.cost_points || 0)} min={0} onChange={(_v, n)=>setEditForm({ ...editForm, cost_points: Number.isFinite(n)? n : 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
<FormHelperText>~ {Math.round(Number(editForm.cost_points || 0) * 0.1)} Kč</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Sklad</FormLabel>
|
||||
<NumberInput value={Number(editForm.stock || 0)} min={0} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek URL</FormLabel>
|
||||
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} />
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<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 */}
|
||||
{ (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{editForm.type === 'merch_coupon' && (
|
||||
<>
|
||||
<FormControl><FormLabel>Kód kuponu</FormLabel><Input value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Platnost do</FormLabel><Input value={(editMeta as any).expires_at || ''} onChange={(e)=>setEditMetaField('expires_at', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
|
||||
</>
|
||||
)}
|
||||
{editForm.type === 'merch_physical' && (
|
||||
<>
|
||||
<FormControl><FormLabel>SKU</FormLabel><Input value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} /></FormControl>
|
||||
<HStack>
|
||||
<FormControl><FormLabel>Velikost</FormLabel><Input value={(editMeta as any).size || ''} onChange={(e)=>setEditMetaField('size', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Barva</FormLabel><Input value={(editMeta as any).color || ''} onChange={(e)=>setEditMetaField('color', e.target.value)} /></FormControl>
|
||||
</HStack>
|
||||
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
|
||||
</>
|
||||
)}
|
||||
{editForm.type === 'merch_digital' && (
|
||||
<>
|
||||
<FormControl><FormLabel>Licenční klíč</FormLabel><Input value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
|
||||
</>
|
||||
)}
|
||||
{editForm.type === 'custom' && (
|
||||
<HStack>
|
||||
<Input placeholder="klíč" id="edit-kv-key" />
|
||||
<Input placeholder="hodnota" id="edit-kv-value" />
|
||||
<Button size="sm" onClick={()=>{
|
||||
const k = (document.getElementById('edit-kv-key') as HTMLInputElement)?.value?.trim();
|
||||
const v = (document.getElementById('edit-kv-value') as HTMLInputElement)?.value?.trim();
|
||||
if (!k) return;
|
||||
setEditMetaField(k, v || '');
|
||||
}}>Přidat</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
<FormControl>
|
||||
<FormLabel>Metadata (JSON)</FormLabel>
|
||||
<Textarea value={editMetaJson} onChange={(e)=>setEditMetaJson(e.target.value)} rows={4} />
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<Text>Aktivní</Text>
|
||||
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} />
|
||||
{editForm.image_url ? <Image src={editForm.image_url} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<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;
|
||||
}
|
||||
await updateMut.mutateAsync({ id: editItem.id, body: {
|
||||
name: editForm.name,
|
||||
type: editForm.type,
|
||||
cost_points: editForm.cost_points as any,
|
||||
stock: editForm.stock as any,
|
||||
image_url: editForm.image_url,
|
||||
active: editForm.active as any,
|
||||
metadata: metadata as any,
|
||||
} as any });
|
||||
editModal.onClose();
|
||||
}}>Uložit</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Batch create modal */}
|
||||
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
|
||||
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
|
||||
<FormHelperText>Příklad: avatar-{`{i}`}.png → avatar-1.png, avatar-2.png…</FormHelperText>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Počet</FormLabel>
|
||||
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Počáteční index</FormLabel>
|
||||
<NumberInput min={0} value={batch.start_index} onChange={(_v,n)=>setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Předpona názvu</FormLabel>
|
||||
<Input value={batch.name_prefix} onChange={(e)=>setBatch({ ...batch, name_prefix: e.target.value })} />
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select value={batch.type} onChange={(e)=>setBatch({ ...batch, type: e.target.value })}>
|
||||
<option value="avatar_static">Avatar (statický)</option>
|
||||
<option value="avatar_animated">Avatar (animovaný)</option>
|
||||
<option value="merch_coupon">Merch kupon</option>
|
||||
<option value="custom">Vlastní</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<NumberInput min={0} value={batch.cost_points} onChange={(_v,n)=>setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Sklad</FormLabel>
|
||||
<NumberInput min={0} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<Text>Aktivní</Text>
|
||||
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button onClick={batchModal.onClose}>Zrušit</Button>
|
||||
<Button colorScheme="blue" isLoading={batchMut.isPending} onClick={()=>batchMut.mutate()}>Vytvořit dávku</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
// Inline component: Transactions viewer and Adjust points panel
|
||||
const TransactionsAndAdjust: React.FC = () => {
|
||||
const [userId, setUserId] = React.useState<string>('');
|
||||
const [reason, setReason] = React.useState<string>('');
|
||||
const [limit, setLimit] = React.useState<number>(100);
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const txQ = useQuery({
|
||||
queryKey: ['admin-engagement-tx', { userId, reason, limit }],
|
||||
queryFn: async () => {
|
||||
const params: any = {};
|
||||
if (userId.trim()) params.user_id = userId.trim();
|
||||
if (reason.trim()) params.reason = reason.trim();
|
||||
if (limit) params.limit = limit;
|
||||
return adminListTransactions(params);
|
||||
}
|
||||
});
|
||||
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 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 });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setAdjDelta(''); setAdjMeta('');
|
||||
await qc.invalidateQueries({ queryKey: ['admin-engagement-tx'] });
|
||||
toast({ status: 'success', title: 'Upraveno' });
|
||||
},
|
||||
onError: (e: any) => toast({ status: 'error', title: e?.message || 'Chyba při úpravě bodů' })
|
||||
});
|
||||
|
||||
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" />
|
||||
<NumberInput value={limit} min={10} max={1000} onChange={(_v,n)=>setLimit(Number.isFinite(n)? n : 100)} maxW="160px">
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
<Button size="sm" variant="outline" onClick={()=>qc.invalidateQueries({ queryKey: ['admin-engagement-tx'] })}>Obnovit</Button>
|
||||
</HStack>
|
||||
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Uživatel</Th>
|
||||
<Th>Delta</Th>
|
||||
<Th>Důvod</Th>
|
||||
<Th>Meta</Th>
|
||||
<Th>Čas</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{(txQ.data || []).map((t: any) => (
|
||||
<Tr key={t.id}>
|
||||
<Td>#{t.id}</Td>
|
||||
<Td>#{t.user_id}</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>
|
||||
<Td>{t.created_at ? new Date(t.created_at).toLocaleString() : '-'}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</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" />
|
||||
<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" />
|
||||
</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>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EngagementAdminPage;
|
||||
|
||||
@@ -73,6 +73,11 @@ const MobileScoreboardControlPage: React.FC = () => {
|
||||
<Button size="lg" onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}>−</Button>
|
||||
<Button size="lg" colorScheme="green" onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+</Button>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Button size="sm" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) - 1)) })}>− Faul</Button>
|
||||
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.homeFouls || 0))}</Text>
|
||||
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) + 1)) })}>+ Faul</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<VStack spacing={2}>
|
||||
<Text fontSize="5xl" fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
|
||||
@@ -89,6 +94,11 @@ const MobileScoreboardControlPage: React.FC = () => {
|
||||
<Button size="lg" onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}>−</Button>
|
||||
<Button size="lg" colorScheme="green" onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+</Button>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Button size="sm" onClick={() => setPartial({ awayFouls: Math.max(0, Math.min(5, (state.awayFouls || 0) - 1)) })}>− Faul</Button>
|
||||
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.awayFouls || 0))}</Text>
|
||||
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ awayFouls: Math.max(0, Math.min(5, (state.awayFouls || 0) + 1)) })}>+ Faul</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
Collapse,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
AddIcon,
|
||||
@@ -456,6 +457,38 @@ const NavigationAdminPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = async (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const { source, destination } = result;
|
||||
if (source.droppableId === 'frontend-nav') {
|
||||
const items = Array.from(navItems);
|
||||
const [moved] = items.splice(source.index, 1);
|
||||
items.splice(destination.index, 0, moved);
|
||||
setNavItems(items);
|
||||
const orders = items.map((item, idx) => ({ id: item.id!, display_order: idx }));
|
||||
try {
|
||||
await reorderNavigationItems(orders);
|
||||
toast({ title: 'Pořadí aktualizováno', status: 'success', duration: 2000 });
|
||||
} catch (error) {
|
||||
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
|
||||
loadData();
|
||||
}
|
||||
} else if (source.droppableId === 'admin-nav') {
|
||||
const items = Array.from(adminNavItems);
|
||||
const [moved] = items.splice(source.index, 1);
|
||||
items.splice(destination.index, 0, moved);
|
||||
setAdminNavItems(items);
|
||||
const orders = items.map((item, idx) => ({ id: item.id!, display_order: idx }));
|
||||
try {
|
||||
await reorderNavigationItems(orders);
|
||||
toast({ title: 'Pořadí aktualizováno', status: 'success', duration: 2000 });
|
||||
} catch (error) {
|
||||
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const moveChildNavItem = async (parentId: number, index: number, direction: 'up' | 'down') => {
|
||||
const moveWithin = async (
|
||||
list: NavigationItem[],
|
||||
@@ -821,6 +854,7 @@ const NavigationAdminPage = () => {
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Webová navigace</Tab>
|
||||
@@ -880,26 +914,38 @@ const NavigationAdminPage = () => {
|
||||
</Box>
|
||||
</Alert>
|
||||
) : (
|
||||
navItems.map((item, index) => (
|
||||
<NavItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
total={navItems.length}
|
||||
onMoveUp={() => moveNavItem(index, 'up')}
|
||||
onMoveDown={() => moveNavItem(index, 'down')}
|
||||
onEdit={() => openNavModal(item)}
|
||||
onDelete={() => deleteNav(item.id!)}
|
||||
onAddChild={() => openNavModal(undefined, item.id)}
|
||||
isExpanded={expandedItems.has(item.id!)}
|
||||
onToggleExpand={() => toggleExpand(item.id!)}
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
hoverBg={hoverBg}
|
||||
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
||||
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
||||
/>
|
||||
))
|
||||
<Droppable droppableId="frontend-nav">
|
||||
{(provided) => (
|
||||
<Box ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{navItems.map((item, index) => (
|
||||
<Draggable key={String(item.id)} draggableId={`nav-${item.id}`} index={index}>
|
||||
{(dragProvided) => (
|
||||
<Box ref={dragProvided.innerRef} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps}>
|
||||
<NavItemCard
|
||||
item={item}
|
||||
index={index}
|
||||
total={navItems.length}
|
||||
onMoveUp={() => moveNavItem(index, 'up')}
|
||||
onMoveDown={() => moveNavItem(index, 'down')}
|
||||
onEdit={() => openNavModal(item)}
|
||||
onDelete={() => deleteNav(item.id!)}
|
||||
onAddChild={() => openNavModal(undefined, item.id)}
|
||||
isExpanded={expandedItems.has(item.id!)}
|
||||
onToggleExpand={() => toggleExpand(item.id!)}
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
hoverBg={hoverBg}
|
||||
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
||||
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Box>
|
||||
)}
|
||||
</Droppable>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
@@ -931,27 +977,38 @@ const NavigationAdminPage = () => {
|
||||
</Alert>
|
||||
|
||||
<VStack spacing={2} align="stretch">
|
||||
{adminNavItems.map((item, index) => (
|
||||
<NavItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
total={adminNavItems.length}
|
||||
onMoveUp={() => moveAdminNavItem(index, 'up')}
|
||||
onMoveDown={() => moveAdminNavItem(index, 'down')}
|
||||
onEdit={() => openNavModal(item, undefined, true)}
|
||||
onDelete={() => deleteNav(item.id!)}
|
||||
onAddChild={() => openNavModal(undefined, item.id, true)}
|
||||
isExpanded={expandedItems.has(item.id!)}
|
||||
onToggleExpand={() => toggleExpand(item.id!)}
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
hoverBg={hoverBg}
|
||||
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
||||
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Droppable droppableId="admin-nav">
|
||||
{(provided) => (
|
||||
<Box ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{adminNavItems.map((item, index) => (
|
||||
<Draggable key={String(item.id)} draggableId={`admin-${item.id}`} index={index}>
|
||||
{(dragProvided) => (
|
||||
<Box ref={dragProvided.innerRef} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps}>
|
||||
<NavItemCard
|
||||
item={item}
|
||||
index={index}
|
||||
total={adminNavItems.length}
|
||||
onMoveUp={() => moveAdminNavItem(index, 'up')}
|
||||
onMoveDown={() => moveAdminNavItem(index, 'down')}
|
||||
onEdit={() => openNavModal(item, undefined, true)}
|
||||
onDelete={() => deleteNav(item.id!)}
|
||||
onAddChild={() => openNavModal(undefined, item.id, true)}
|
||||
isExpanded={expandedItems.has(item.id!)}
|
||||
onToggleExpand={() => toggleExpand(item.id!)}
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
hoverBg={hoverBg}
|
||||
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
||||
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Box>
|
||||
)}
|
||||
</Droppable>
|
||||
{adminNavItems.length === 0 && (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
@@ -959,13 +1016,14 @@ const NavigationAdminPage = () => {
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</DragDropContext>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* Navigation Item Modal */}
|
||||
<Modal isOpen={isNavModalOpen} onClose={onNavModalClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
@@ -979,7 +1037,7 @@ const NavigationAdminPage = () => {
|
||||
{isAdminNav && !editingNav?.id && (
|
||||
<Alert status="info" fontSize="sm">
|
||||
<AlertIcon />
|
||||
Vytvářte položku pro boční menu v administraci. Můžete vybrat přednastavené stránky nebo přidat vlastní odkazy.
|
||||
Vytvářejte položku pro boční menu v administraci. Můžete vybrat přednastavené stránky nebo přidat vlastní odkazy.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -988,7 +1046,7 @@ const NavigationAdminPage = () => {
|
||||
<Input
|
||||
value={editingNav?.label || ''}
|
||||
onChange={(e) => setEditingNav({ ...editingNav!, label: e.target.value })}
|
||||
placeholder={isAdminNav ? "Např. Nástěnka, Webmail" : "Např. Domů, O klubu"}
|
||||
placeholder={isAdminNav ? 'Např. Nástěnka, Webmail' : 'Např. Domů, O klubu'}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -996,9 +1054,7 @@ const NavigationAdminPage = () => {
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select
|
||||
value={editingNav?.type || (isAdminNav ? 'internal' : 'page')}
|
||||
onChange={(e) =>
|
||||
setEditingNav({ ...editingNav!, type: e.target.value as any })
|
||||
}
|
||||
onChange={(e) => setEditingNav({ ...editingNav!, type: e.target.value as any })}
|
||||
>
|
||||
{isAdminNav ? (
|
||||
<>
|
||||
@@ -1024,8 +1080,8 @@ const NavigationAdminPage = () => {
|
||||
value={editingNav?.page_type || ''}
|
||||
onChange={(e) => {
|
||||
const selected = PAGE_TYPE_OPTIONS.find(opt => opt.value === e.target.value);
|
||||
setEditingNav({
|
||||
...editingNav!,
|
||||
setEditingNav({
|
||||
...editingNav!,
|
||||
page_type: e.target.value,
|
||||
url: selected?.url || '',
|
||||
label: editingNav?.label || selected?.label || ''
|
||||
@@ -1050,8 +1106,8 @@ const NavigationAdminPage = () => {
|
||||
onChange={(e) => {
|
||||
const selected = ADMIN_PAGE_PRESETS.find(opt => opt.value === e.target.value);
|
||||
const isExternal = selected?.url?.startsWith('http');
|
||||
setEditingNav({
|
||||
...editingNav!,
|
||||
setEditingNav({
|
||||
...editingNav!,
|
||||
page_type: e.target.value,
|
||||
url: selected?.url || '',
|
||||
label: editingNav?.label || selected?.label || '',
|
||||
@@ -1106,11 +1162,7 @@ const NavigationAdminPage = () => {
|
||||
<Input
|
||||
value={editingNav?.url || ''}
|
||||
onChange={(e) => setEditingNav({ ...editingNav!, url: e.target.value })}
|
||||
placeholder={
|
||||
editingNav?.type === 'external'
|
||||
? 'https://example.com'
|
||||
: '/vlastni-stranka'
|
||||
}
|
||||
placeholder={editingNav?.type === 'external' ? 'https://example.com' : '/vlastni-stranka'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -1151,16 +1203,12 @@ const NavigationAdminPage = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
||||
|
||||
{editingNav?.type === 'external' && (
|
||||
<FormControl>
|
||||
<FormLabel>Target</FormLabel>
|
||||
<Select
|
||||
value={editingNav?.target || '_self'}
|
||||
onChange={(e) =>
|
||||
setEditingNav({ ...editingNav!, target: e.target.value as any })
|
||||
}
|
||||
onChange={(e) => setEditingNav({ ...editingNav!, target: e.target.value as any })}
|
||||
>
|
||||
<option value="_self">Stejné okno</option>
|
||||
<option value="_blank">Nové okno</option>
|
||||
@@ -1172,24 +1220,18 @@ const NavigationAdminPage = () => {
|
||||
<FormLabel mb="0">Viditelné</FormLabel>
|
||||
<Switch
|
||||
isChecked={editingNav?.visible ?? true}
|
||||
onChange={(e) =>
|
||||
setEditingNav({ ...editingNav!, visible: e.target.checked })
|
||||
}
|
||||
onChange={(e) => setEditingNav({ ...editingNav!, visible: e.target.checked })}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onNavModalClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={saveNavItem}>
|
||||
Uložit
|
||||
</Button>
|
||||
<Button variant="ghost" mr={3} onClick={onNavModalClose}>Zrušit</Button>
|
||||
<Button colorScheme="blue" onClick={saveNavItem}>Uložit</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Container>
|
||||
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,8 +39,13 @@ import {
|
||||
startTimer,
|
||||
pauseTimer,
|
||||
resetTimer,
|
||||
swapSides,
|
||||
startSecondHalf,
|
||||
listPresets,
|
||||
savePreset,
|
||||
loadPreset,
|
||||
listSponsorsAdmin,
|
||||
uploadSponsors,
|
||||
deleteSponsor,
|
||||
} from '@/services/scoreboard';
|
||||
import { useFacrApi } from '@/hooks/useFacrApi';
|
||||
import { SearchResult } from '@/services/facr/types';
|
||||
@@ -69,6 +74,11 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
const [activeTab, setActiveTab] = useState<number>(0); // 0 upcoming, 1 recent
|
||||
// Presets & sponsors state
|
||||
const [presets, setPresets] = useState<string[]>([]);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
const [sponsors, setSponsors] = useState<string[]>([]);
|
||||
const [sUploadBusy, setSUploadBusy] = useState(false);
|
||||
|
||||
// Club search inline (home/away target)
|
||||
const [clubQuery, setClubQuery] = useState('');
|
||||
@@ -80,6 +90,9 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
setLoading(false);
|
||||
// load presets & sponsors lists
|
||||
try { setPresets(await listPresets()); } catch {}
|
||||
try { setSponsors(await listSponsorsAdmin()); } catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -462,10 +475,6 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb={0}>Přehodit strany (vizuálně)</FormLabel>
|
||||
<Switch isChecked={!!state.sidesFlipped} onChange={async (e) => setPartial({ sidesFlipped: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Poločas</FormLabel>
|
||||
<Select value={String(state.half || 1)} onChange={async (e) => setPartial({ half: parseInt(e.target.value, 10) || 1 })}>
|
||||
@@ -565,11 +574,6 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
}}>Reset</Button>
|
||||
<Button onClick={async () => {
|
||||
await swapSides();
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
}}>Přehodit strany</Button>
|
||||
<Button colorScheme="purple" onClick={async () => {
|
||||
await startSecondHalf();
|
||||
const s = await getScoreboardState();
|
||||
@@ -595,6 +599,24 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
|
||||
<Heading size="md" mb={3}>Presety</Heading>
|
||||
<HStack spacing={3} align="center" flexWrap="wrap" mb={3}>
|
||||
<Input placeholder="Název presetu (např. derby-2025)"
|
||||
value={presetName}
|
||||
onChange={(e)=>setPresetName(e.target.value)}
|
||||
maxW="260px" />
|
||||
<Button onClick={async ()=>{ try { await savePreset(presetName); setPresets(await listPresets()); setPresetName(''); toast({ title: 'Preset uložen', status: 'success' }); } catch (e:any){ toast({ title: 'Uložení selhalo', description: e?.message, status: 'error' }); } }}>Uložit preset</Button>
|
||||
</HStack>
|
||||
<HStack spacing={3} align="center" flexWrap="wrap">
|
||||
<Select placeholder="Vyberte preset" maxW="260px" onChange={(e)=>setPresetName(e.target.value)} value={presetName}>
|
||||
{presets.map((p)=> (<option key={p} value={p}>{p}</option>))}
|
||||
</Select>
|
||||
<Button variant="outline" onClick={async ()=>{ if (!presetName) { toast({ title: 'Vyberte preset', status: 'warning' }); return; } try { await loadPreset(presetName); setState(await getScoreboardState()); toast({ title: 'Preset načten', status: 'success' }); } catch (e:any){ toast({ title: 'Načtení selhalo', description: e?.message, status: 'error' }); } }}>Načíst preset</Button>
|
||||
<Button variant="ghost" onClick={async ()=>{ try { setPresets(await listPresets()); toast({ title: 'Seznam aktualizován', status: 'info' }); } catch {} }}>Obnovit</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Heading size="md" mb={3}>Import / Export</Heading>
|
||||
<HStack spacing={4} align="center" flexWrap="wrap">
|
||||
<Button
|
||||
|
||||
@@ -201,6 +201,9 @@ 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,
|
||||
};
|
||||
const saved = await updateAdminSettings(payload);
|
||||
setSettings((prev) => ({ ...prev, ...saved }));
|
||||
@@ -276,6 +279,39 @@ const SettingsAdminPage: React.FC = () => {
|
||||
<FormLabel>Název klubu</FormLabel>
|
||||
<Input value={settings.club_name || ''} onChange={handleChange('club_name')} />
|
||||
</FormControl>
|
||||
|
||||
<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
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={(settings as any).storage_warn_threshold ?? 80}
|
||||
onChange={handleNumChange('storage_warn_threshold' as any)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Kritické při (%)</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={(settings as any).storage_critical_threshold ?? 95}
|
||||
onChange={handleNumChange('storage_critical_threshold' as any)}
|
||||
/>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<FormControl>
|
||||
<FormLabel>Logo klubu</FormLabel>
|
||||
<HStack align="center" spacing={3}>
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
HStack,
|
||||
Heading,
|
||||
Select,
|
||||
Spinner,
|
||||
Text,
|
||||
VStack,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { adminGetVisualData, VisualData, adminUpdateWinner, adminListPrizes, adminSetWinnerPrize, SweepstakePrize } from '../../services/sweepstakes';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
const SweepstakeVisualPage: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const toast = useToast();
|
||||
const [data, setData] = useState<VisualData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [variant, setVariant] = useState<'cycler' | 'wheel'>('cycler');
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
const [confettiOn, setConfettiOn] = useState<boolean>(true);
|
||||
const [soundOn, setSoundOn] = useState<boolean>(true);
|
||||
const [revealIndex, setRevealIndex] = useState(0); // which winner we are revealing next
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentIdx, setCurrentIdx] = useState(0); // cycler index
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const wheelRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const wheelAngleRef = useRef<number>(0);
|
||||
const [wheelAngle, setWheelAngle] = useState<number>(0);
|
||||
const imgCacheRef = useRef<Record<number, HTMLImageElement>>({});
|
||||
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
|
||||
|
||||
const entries = data?.entries || [];
|
||||
const winners = data?.winners || [];
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const clubLogo = publicSettings?.club_logo_url || '';
|
||||
const primary = (publicSettings?.primary_color || '#1e3a8a').trim();
|
||||
|
||||
const targetUserId = winners[revealIndex]?.user_id;
|
||||
const targetIndex = useMemo(() => entries.findIndex(e => e.user_id === targetUserId), [entries, targetUserId]);
|
||||
|
||||
// Simple beep
|
||||
const beep = () => {
|
||||
if (!soundOn) return;
|
||||
try {
|
||||
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.type = 'triangle'; o.frequency.value = 880;
|
||||
g.gain.value = 0.001; // soft
|
||||
o.start();
|
||||
g.gain.exponentialRampToValueAtTime(0.5, ctx.currentTime + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.18);
|
||||
o.stop(ctx.currentTime + 0.2);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const fireConfetti = () => {
|
||||
if (!confettiOn) return;
|
||||
const host = document.getElementById('visual-host');
|
||||
if (!host) return;
|
||||
const N = 80;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'confetti';
|
||||
const size = 6 + Math.random() * 6;
|
||||
d.style.position = 'absolute';
|
||||
d.style.left = (10 + Math.random() * 80) + '%';
|
||||
d.style.top = '0%';
|
||||
d.style.width = `${size}px`;
|
||||
d.style.height = `${size * (0.5 + Math.random())}px`;
|
||||
d.style.background = `hsl(${Math.floor(Math.random() * 360)}, 80%, 60%)`;
|
||||
d.style.opacity = '0.9';
|
||||
d.style.transform = `translate(-50%,-50%) rotate(${Math.random() * 360}deg)`;
|
||||
d.style.borderRadius = '1px';
|
||||
d.style.pointerEvents = 'none';
|
||||
d.style.animation = `fall ${1.5 + Math.random() * 1.5}s ease-out forwards`;
|
||||
host.appendChild(d);
|
||||
setTimeout(() => { if (d.parentNode) d.parentNode.removeChild(d); }, 3500);
|
||||
}
|
||||
};
|
||||
|
||||
const hexToRgb = (hex: string): {r:number; g:number; b:number} | null => {
|
||||
const h = hex.replace('#','').trim();
|
||||
if (![3,6].includes(h.length)) return null;
|
||||
const n = h.length === 3 ? h.split('').map(c=>c+c).join('') : h;
|
||||
const r = parseInt(n.slice(0,2),16), g = parseInt(n.slice(2,4),16), b = parseInt(n.slice(4,6),16);
|
||||
if ([r,g,b].some(x=>Number.isNaN(x))) return null; return { r,g,b };
|
||||
};
|
||||
const rgbToHsl = (r:number,g:number,b:number): [number,number,number] => {
|
||||
r/=255; g/=255; b/=255; const max=Math.max(r,g,b), min=Math.min(r,g,b); let h=0,s=0,l=(max+min)/2;
|
||||
if (max!==min){ const d=max-min; s=l>0.5? d/(2-max-min) : d/(max+min);
|
||||
switch(max){ case r: h=(g-b)/d+(g<b?6:0); break; case g: h=(b-r)/d+2; break; case b: h=(r-g)/d+4; break; }
|
||||
h/=6;
|
||||
}
|
||||
return [h,s,l];
|
||||
};
|
||||
const hslToCss = (h:number,s:number,l:number) => `hsl(${Math.round(h*360)}, ${Math.round(s*100)}%, ${Math.round(l*100)}%)`;
|
||||
|
||||
const startCycler = () => {
|
||||
if (!entries.length || revealIndex >= winners.length) return;
|
||||
setPlaying(true);
|
||||
let speed = 50; // ms
|
||||
let steps = 0;
|
||||
const maxWarmup = 40;
|
||||
const decelStart = maxWarmup + 40;
|
||||
const slowMax = decelStart + 80;
|
||||
const loop = () => {
|
||||
setCurrentIdx((idx) => (idx + 1) % entries.length);
|
||||
steps++;
|
||||
// warmup constant speed, then decelerate
|
||||
if (steps < maxWarmup) {
|
||||
timerRef.current = window.setTimeout(loop, speed);
|
||||
} else if (steps < decelStart) {
|
||||
speed += 5; // slight slow
|
||||
timerRef.current = window.setTimeout(loop, speed);
|
||||
} else if (steps < slowMax) {
|
||||
speed += 15;
|
||||
timerRef.current = window.setTimeout(loop, speed);
|
||||
} else {
|
||||
// Try to land on target
|
||||
const idx = (currentIdx + 1) % entries.length;
|
||||
setCurrentIdx(idx);
|
||||
const landing = idx === targetIndex;
|
||||
if (!landing) {
|
||||
speed += 30;
|
||||
timerRef.current = window.setTimeout(loop, speed);
|
||||
} else {
|
||||
// reveal done
|
||||
setPlaying(false);
|
||||
setRevealIndex((i) => i + 1);
|
||||
beep(); fireConfetti();
|
||||
}
|
||||
}
|
||||
};
|
||||
loop();
|
||||
};
|
||||
|
||||
// Draw segmented wheel
|
||||
const drawWheel = () => {
|
||||
const canvas = canvasRef.current; if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d'); if (!ctx) return;
|
||||
const W = canvas.width = 440; const H = canvas.height = 440; // fixed size
|
||||
const cx = W/2, cy = H/2, r = Math.min(W, H)/2 - 8;
|
||||
ctx.clearRect(0,0,W,H);
|
||||
const n = Math.max(entries.length, 1);
|
||||
const angle = (Math.PI*2)/n;
|
||||
const base = hexToRgb(primary) || { r: 30, g: 58, b: 138 };
|
||||
const [bh, bs, bl] = rgbToHsl(base.r, base.g, base.b);
|
||||
for (let i=0;i<n;i++){
|
||||
const a0 = i*angle, a1 = a0 + angle;
|
||||
ctx.beginPath(); ctx.moveTo(cx,cy);
|
||||
ctx.arc(cx,cy,r,a0,a1,false); ctx.closePath();
|
||||
const l = theme==='dark' ? (0.30 + 0.15 * ((i%2)?1:0)) : (0.55 + 0.10 * ((i%2)?1:0));
|
||||
const s = Math.min(0.9, bs + 0.1);
|
||||
const h = (bh + (i/n)*0.08) % 1; // slight hue drift for variety
|
||||
ctx.fillStyle = hslToCss(h, s, l);
|
||||
ctx.fill();
|
||||
// border
|
||||
ctx.strokeStyle = theme==='dark' ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)';
|
||||
ctx.lineWidth = 2; ctx.stroke();
|
||||
// label
|
||||
const label = (entries[i]?.display_name || '').trim();
|
||||
if (label){
|
||||
ctx.save();
|
||||
ctx.translate(cx,cy);
|
||||
ctx.rotate(a0 + angle/2);
|
||||
ctx.textAlign = 'right'; ctx.fillStyle = 'white'; ctx.font = '700 13px system-ui, sans-serif';
|
||||
const text = label.length>18? (label.slice(0,17)+'…') : label;
|
||||
ctx.fillText(text, r - 10, 5);
|
||||
ctx.restore();
|
||||
}
|
||||
// avatar (small circle near rim)
|
||||
const avatarUrl = entries[i]?.avatar_url;
|
||||
if (avatarUrl) {
|
||||
let img = imgCacheRef.current[i];
|
||||
const drawImg = (im: HTMLImageElement) => {
|
||||
ctx.save();
|
||||
const mid = a0 + angle/2;
|
||||
const ar = r - 36;
|
||||
const ax = cx + Math.cos(mid) * ar;
|
||||
const ay = cy + Math.sin(mid) * ar;
|
||||
const sz = 26;
|
||||
ctx.beginPath(); ctx.arc(ax, ay, sz/2, 0, Math.PI*2); ctx.closePath(); ctx.clip();
|
||||
ctx.drawImage(im, ax - sz/2, ay - sz/2, sz, sz);
|
||||
ctx.restore();
|
||||
};
|
||||
if (img && img.complete) drawImg(img);
|
||||
else {
|
||||
img = new Image(); img.crossOrigin = 'anonymous'; img.src = avatarUrl; img.onload = () => { drawImg(img!); };
|
||||
imgCacheRef.current[i] = img;
|
||||
}
|
||||
}
|
||||
}
|
||||
// center circle
|
||||
ctx.beginPath(); ctx.arc(cx,cy,20,0,Math.PI*2); ctx.fillStyle = theme==='dark'? '#111':'#eee'; ctx.fill();
|
||||
};
|
||||
|
||||
const startWheel = () => {
|
||||
if (!entries.length || revealIndex >= winners.length) return;
|
||||
const n = entries.length; if (!n) return;
|
||||
const target = targetIndex;
|
||||
if (target < 0) { startCycler(); return; }
|
||||
setPlaying(true);
|
||||
// Compute final angle so pointer at top hits target center
|
||||
const per = 360 / n;
|
||||
const center = target * per + per/2;
|
||||
const spins = 4 + Math.floor(Math.random()*3); // 4-6 spins
|
||||
const final = spins*360 + (360 - center);
|
||||
wheelAngleRef.current = final;
|
||||
setWheelAngle(final);
|
||||
// After transition ends (~4s), reveal winner
|
||||
const duration = 4200;
|
||||
window.setTimeout(() => {
|
||||
setPlaying(false);
|
||||
setRevealIndex(i=>i+1);
|
||||
beep(); fireConfetti();
|
||||
}, duration);
|
||||
};
|
||||
|
||||
const onStart = () => {
|
||||
if (variant === 'cycler') startCycler();
|
||||
else startWheel();
|
||||
};
|
||||
|
||||
// Reveal All logic
|
||||
const [revealAll, setRevealAll] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!revealAll) return;
|
||||
if (!playing && revealIndex < winners.length) {
|
||||
const t = window.setTimeout(() => onStart(), 400);
|
||||
return () => window.clearTimeout(t);
|
||||
}
|
||||
if (revealIndex >= winners.length) {
|
||||
setRevealAll(false);
|
||||
}
|
||||
}, [revealAll, playing, revealIndex, winners.length, variant]);
|
||||
|
||||
const onFullscreen = () => {
|
||||
const el = document.getElementById('visual-host');
|
||||
if (el && el.requestFullscreen) el.requestFullscreen().catch(()=>{});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await adminGetVisualData(Number(id));
|
||||
if (!active) return;
|
||||
setData(res);
|
||||
const def = (res.sweepstake as any)?.picker_style;
|
||||
if (def === 'wheel' || def === 'cycler') setVariant(def);
|
||||
try { const list = await adminListPrizes(Number(id)); if (active) setPrizes(list); } catch {}
|
||||
} catch (e: any) {
|
||||
toast({ status: 'error', title: 'Nelze načíst data vizualizace' });
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; if (timerRef.current) window.clearTimeout(timerRef.current); };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { if (variant === 'wheel') drawWheel(); }, [variant, data, theme]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="6xl" py={8}><Spinner /></Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="6xl" py={8}><Text>Žádná data</Text></Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const shownWinners = winners.slice(0, revealIndex);
|
||||
const current = entries[currentIdx];
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="6xl" py={6}>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="lg">Vizualizace – {data.sweepstake.title}</Heading>
|
||||
<HStack>
|
||||
<Button as={RouterLink} to="/admin/sweepstakes" variant="outline">Zpět</Button>
|
||||
<Button onClick={onFullscreen} variant="outline">Fullscreen</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack mb={4} spacing={4}>
|
||||
<Select value={variant} onChange={(e)=>setVariant(e.target.value as any)} maxW="220px">
|
||||
<option value="cycler">Náhodný přepínač</option>
|
||||
<option value="wheel">Kolo štěstí (základní)</option>
|
||||
</Select>
|
||||
<Select value={theme} onChange={(e)=>setTheme(e.target.value as any)} maxW="200px">
|
||||
<option value="dark">Tmavé pozadí</option>
|
||||
<option value="light">Světlé pozadí</option>
|
||||
</Select>
|
||||
<HStack>
|
||||
<Button size="sm" variant={confettiOn? 'solid':'outline'} onClick={()=>setConfettiOn(v=>!v)}>{confettiOn? 'Konfety: Zap' : 'Konfety: Vyp'}</Button>
|
||||
<Button size="sm" variant={soundOn? 'solid':'outline'} onClick={()=>setSoundOn(v=>!v)}>{soundOn? 'Zvuk: Zap' : 'Zvuk: Vyp'}</Button>
|
||||
</HStack>
|
||||
<Button colorScheme="blue" onClick={onStart} isDisabled={playing || revealIndex >= winners.length}>
|
||||
{revealIndex >= winners.length ? 'Všichni výherci odhaleni' : (playing ? 'Probíhá…' : 'Start')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={()=>setRevealAll(true)} isDisabled={playing || revealIndex >= winners.length}>Odhalit všechny</Button>
|
||||
<Button variant="outline" onClick={()=>{
|
||||
// CSV export: user_id, name, prize_name
|
||||
const rows = winners.map((w:any)=>{
|
||||
const e = entries.find(x=>x.user_id===w.user_id);
|
||||
return [w.user_id, (e?.display_name||'').replaceAll('"','""'), (w.prize_name||'').replaceAll('"','""')];
|
||||
});
|
||||
const csv = ['user_id,name,prize'].concat(rows.map(r=>`${r[0]},"${r[1]}","${r[2]}"`)).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href=url; a.download=`soutez_${id}_vitezove.csv`; a.click(); URL.revokeObjectURL(url);
|
||||
}}>Export CSV</Button>
|
||||
<Text color="gray.500">Výherci: {revealIndex}/{winners.length}</Text>
|
||||
</HStack>
|
||||
|
||||
<Box id="visual-host" borderWidth="1px" borderRadius="md" p={4} minH="400px" position="relative" overflow="hidden" bg={theme==='dark' ? 'black' : 'white'} color={theme==='dark' ? 'white' : 'black'}>
|
||||
{variant === 'cycler' ? (
|
||||
<Center h="380px" flexDir="column">
|
||||
<Text fontSize="sm" opacity={0.7} mb={2}>Losuji…</Text>
|
||||
<Text fontSize="5xl" fontWeight="800" textAlign="center">{(current?.display_name || '').trim() || '—'}</Text>
|
||||
{current?.avatar_url && (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<img src={current.avatar_url} style={{ width: 120, height: 120, borderRadius: '50%', marginTop: 16, objectFit: 'cover' }} />
|
||||
)}
|
||||
</Center>
|
||||
) : (
|
||||
<Center h="380px" flexDir="column">
|
||||
<Box position="relative" w="440px" h="440px">
|
||||
<Box position="absolute" left="50%" top="-2px" transform="translateX(-50%)" zIndex={2}
|
||||
borderLeft="10px solid transparent" borderRight="10px solid transparent" borderBottom={`18px solid ${theme==='dark'?'#e2e8f0':'#1a202c'}`} />
|
||||
<Box ref={wheelRef} position="absolute" inset={0} style={{ transform: `rotate(${wheelAngle}deg)`, transition: playing ? 'transform 4.2s cubic-bezier(.2,.8,.2,1)' : undefined }}>
|
||||
<canvas ref={canvasRef} width={440} height={440} style={{ width: 440, height: 440 }} />
|
||||
</Box>
|
||||
{clubLogo && (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<img src={clubLogo} style={{ position:'absolute', left:'50%', top:'50%', transform:'translate(-50%,-50%)', width: 96, height: 96, objectFit:'contain', borderRadius: '50%', boxShadow: theme==='dark'? '0 0 0 4px rgba(255,255,255,0.9)':'0 0 0 4px rgba(0,0,0,0.6)' }} />
|
||||
)}
|
||||
</Box>
|
||||
<Text mt={4} opacity={0.8}>Kolo štěstí</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<VStack align="stretch" mt={6} spacing={2}>
|
||||
<Heading size="md">Odhalení</Heading>
|
||||
{shownWinners.length === 0 && <Text color="gray.500">Zatím žádný výherce</Text>}
|
||||
{shownWinners.map((w, idx) => {
|
||||
const e = entries.find(x => x.user_id === w.user_id);
|
||||
const [status, setStatus] = [w.claim_status || 'pending', undefined];
|
||||
return (
|
||||
<HStack key={`${w.user_id}-${idx}`} spacing={8} borderWidth="1px" borderRadius="md" p={3} align="center">
|
||||
<HStack spacing={3} flex={1}>
|
||||
{e?.avatar_url && (<img src={e.avatar_url} alt="avatar" style={{ width: 36, height: 36, borderRadius: '50%' }} />)}
|
||||
<Text fontWeight="700">{e?.display_name || `Uživatel #${w.user_id}`}</Text>
|
||||
{w.prize_name && <Text color="gray.500">— {w.prize_name}</Text>}
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Select size="sm" value={w.claim_status || 'pending'} onChange={async (ev)=>{
|
||||
const val = ev.target.value as 'pending'|'claimed'|'delivered';
|
||||
try {
|
||||
if (w.id) await adminUpdateWinner(Number(id), w.id, { claim_status: val });
|
||||
// update local state without refetch
|
||||
setData((prev)=> prev ? ({ ...prev, winners: prev.winners.map((x,i)=> i===idx ? { ...x, claim_status: val } : x) }) : prev);
|
||||
} catch { toast({ status: 'error', title: 'Nelze uložit stav' }); }
|
||||
}} maxW="160px">
|
||||
<option value="pending">čeká</option>
|
||||
<option value="claimed">vyzvednuto</option>
|
||||
<option value="delivered">předáno</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Container>
|
||||
<style>{`
|
||||
@keyframes fall {
|
||||
0% { transform: translate(-50%,-50%) rotate(0deg); top: 0%; opacity: 1 }
|
||||
100% { transform: translate(-50%, 520px) rotate(360deg); top: 100%; opacity: 0.2 }
|
||||
}
|
||||
`}</style>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SweepstakeVisualPage;
|
||||
@@ -0,0 +1,415 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
HStack,
|
||||
VStack,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Badge,
|
||||
useDisclosure,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
adminListSweepstakes,
|
||||
adminCreateSweepstake,
|
||||
adminUpdateSweepstake,
|
||||
adminDeleteSweepstake,
|
||||
adminListEntries,
|
||||
adminListWinners,
|
||||
adminFinalizeSweepstake,
|
||||
Sweepstake,
|
||||
adminListPrizes,
|
||||
adminCreatePrize,
|
||||
adminUpdatePrize,
|
||||
adminDeletePrize,
|
||||
adminReorderPrizes,
|
||||
SweepstakePrize,
|
||||
} from '../../services/sweepstakes';
|
||||
import { AddIcon, ArrowUpIcon, ArrowDownIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons';
|
||||
|
||||
const fmt = (iso?: string | null) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return isNaN(d.getTime()) ? '' : d.toLocaleString('cs-CZ');
|
||||
};
|
||||
|
||||
const defaultForm = {
|
||||
title: '',
|
||||
description: '',
|
||||
image_url: '',
|
||||
rules_url: '',
|
||||
start_at: '',
|
||||
end_at: '',
|
||||
picker_style: 'wheel',
|
||||
total_prizes: 1,
|
||||
prize_summary: '',
|
||||
};
|
||||
|
||||
const SweepstakesAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState<Sweepstake[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [form, setForm] = useState<any>(defaultForm);
|
||||
const [editing, setEditing] = useState<Sweepstake | null>(null);
|
||||
|
||||
// Prizes modal state
|
||||
const prizesDisc = useDisclosure();
|
||||
const [prizeSweep, setPrizeSweep] = useState<Sweepstake | null>(null);
|
||||
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
|
||||
const [prizeForm, setPrizeForm] = useState<{ name: string; quantity: number; value?: string; image_url?: string; kind?: 'physical'|'points'|'xp'|'points_xp'; points?: number; xp?: number }>(() => ({ name: '', quantity: 1, value: '', image_url: '', kind: 'physical', points: 0, xp: 0 }));
|
||||
const [savingPrize, setSavingPrize] = useState<boolean>(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminListSweepstakes(status ? { status } : undefined);
|
||||
setItems(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openPrizes = async (it: Sweepstake) => {
|
||||
try {
|
||||
setPrizeSweep(it);
|
||||
prizesDisc.onOpen();
|
||||
const list = await adminListPrizes(it.id);
|
||||
setPrizes(list);
|
||||
} catch {
|
||||
setPrizes([]);
|
||||
}
|
||||
};
|
||||
|
||||
const addPrize = async () => {
|
||||
if (!prizeSweep) return;
|
||||
if (!prizeForm.name.trim()) { toast({ status: 'error', title: 'Název výhry je povinný' }); return; }
|
||||
try {
|
||||
setSavingPrize(true);
|
||||
await adminCreatePrize(prizeSweep.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
|
||||
setPrizeForm({ name: '', quantity: 1, value: '', image_url: '' });
|
||||
setPrizes(await adminListPrizes(prizeSweep.id));
|
||||
} catch (e:any) {
|
||||
toast({ status: 'error', title: 'Nelze uložit výhru' });
|
||||
} finally {
|
||||
setSavingPrize(false);
|
||||
}
|
||||
};
|
||||
|
||||
const delPrize = async (p: SweepstakePrize) => {
|
||||
if (!prizeSweep) return;
|
||||
if (!window.confirm('Smazat výhru?')) return;
|
||||
await adminDeletePrize(prizeSweep.id, p.id as any);
|
||||
setPrizes(await adminListPrizes(prizeSweep.id));
|
||||
};
|
||||
|
||||
const movePrize = async (idx: number, dir: -1 | 1) => {
|
||||
if (!prizeSweep) return;
|
||||
const arr = [...prizes];
|
||||
const ni = idx + dir;
|
||||
if (ni < 0 || ni >= arr.length) return;
|
||||
const tmp = arr[idx];
|
||||
arr[idx] = arr[ni];
|
||||
arr[ni] = tmp;
|
||||
setPrizes(arr);
|
||||
await adminReorderPrizes(prizeSweep.id, arr.map(p => p.id as any));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [status]);
|
||||
|
||||
const openCreate = () => { setEditing(null); setForm(defaultForm); onOpen(); };
|
||||
const openEdit = (it: Sweepstake) => {
|
||||
setEditing(it);
|
||||
setForm({
|
||||
title: it.title,
|
||||
description: it.description || '',
|
||||
image_url: (it as any).image_url || '',
|
||||
rules_url: (it as any).rules_url || '',
|
||||
start_at: (it as any).start_at ? String((it as any).start_at).slice(0, 16) : '',
|
||||
end_at: (it as any).end_at ? String((it as any).end_at).slice(0, 16) : '',
|
||||
picker_style: (it as any).picker_style || 'wheel',
|
||||
total_prizes: (it as any).total_prizes || 1,
|
||||
prize_summary: (it as any).prize_summary || '',
|
||||
});
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
if (!form.title || !form.start_at || !form.end_at) {
|
||||
toast({ status: 'error', title: 'Vyplňte název a datumy' });
|
||||
return;
|
||||
}
|
||||
if (editing) {
|
||||
await adminUpdateSweepstake(editing.id, form);
|
||||
toast({ status: 'success', title: 'Uloženo' });
|
||||
} else {
|
||||
await adminCreateSweepstake(form);
|
||||
toast({ status: 'success', title: 'Vytvořeno' });
|
||||
}
|
||||
onClose();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Operace selhala' });
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = async (it: Sweepstake) => {
|
||||
if (!window.confirm('Spustit losování a vybrat výherce?')) return;
|
||||
try { await adminFinalizeSweepstake(it.id); toast({ status: 'success', title: 'Losování dokončeno' }); await load(); }
|
||||
catch (e: any) { toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Nelze dokončit' }); }
|
||||
};
|
||||
|
||||
const remove = async (it: Sweepstake) => {
|
||||
if (!window.confirm('Smazat soutěž?')) return;
|
||||
try { await adminDeleteSweepstake(it.id); toast({ status: 'success', title: 'Smazáno' }); await load(); }
|
||||
catch (e: any) { toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Nelze smazat' }); }
|
||||
};
|
||||
|
||||
const statusBadge = (s: string) => {
|
||||
const map: any = { draft: 'gray', scheduled: 'purple', active: 'green', locked: 'orange', finalized: 'blue', archived: 'red' };
|
||||
return <Badge colorScheme={map[s] || 'gray'}>{s}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Soutěže</Heading>
|
||||
<HStack>
|
||||
<Select value={status} onChange={(e)=>setStatus(e.target.value)} size="sm" maxW="220px">
|
||||
<option value="">Všechny</option>
|
||||
<option value="draft">Koncepty</option>
|
||||
<option value="scheduled">Naplánované</option>
|
||||
<option value="active">Aktivní</option>
|
||||
<option value="finalized">Dokončené</option>
|
||||
<option value="archived">Archiv</option>
|
||||
</Select>
|
||||
<Button colorScheme="blue" onClick={openCreate}>Nová soutěž</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{loading ? (
|
||||
<Text>Načítám…</Text>
|
||||
) : (
|
||||
<Box overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Název</Th>
|
||||
<Th>Období</Th>
|
||||
<Th>Stav</Th>
|
||||
<Th>Výhry</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{items.map((it) => (
|
||||
<Tr key={it.id}>
|
||||
<Td>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">{it.title}</Text>
|
||||
{it.prize_summary && <Text fontSize="xs" opacity={0.8}>{it.prize_summary}</Text>}
|
||||
</VStack>
|
||||
</Td>
|
||||
<Td>{fmt((it as any).start_at)} – {fmt((it as any).end_at)}</Td>
|
||||
<Td>{statusBadge(it.status)}</Td>
|
||||
<Td>{(it as any).total_prizes || '-'}</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Button size="xs" variant="outline" onClick={()=>openEdit(it)}>Upravit</Button>
|
||||
<Button size="xs" variant="outline" onClick={()=>openPrizes(it)}>Výhry</Button>
|
||||
<Button size="xs" as={RouterLink} to={`/admin/sweepstakes/${it.id}/visual`} variant="outline">Vizualizace</Button>
|
||||
<Button size="xs" variant="outline" onClick={()=>finalize(it)} isDisabled={it.status === 'finalized'}>Losovat</Button>
|
||||
<Button size="xs" colorScheme="red" variant="outline" onClick={()=>remove(it)}>Smazat</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis</FormLabel>
|
||||
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
|
||||
</FormControl>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Začátek</FormLabel>
|
||||
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Konec</FormLabel>
|
||||
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Styl vizualizace</FormLabel>
|
||||
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
|
||||
<option value="wheel">Kolo štěstí</option>
|
||||
<option value="cycler">Náhodný přepínač</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Počet výher</FormLabel>
|
||||
<Input type="number" value={form.total_prizes} onChange={(e)=>setForm({ ...form, total_prizes: Number(e.target.value) || 1 })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<FormControl>
|
||||
<FormLabel>Souhrn výher</FormLabel>
|
||||
<Input value={form.prize_summary} onChange={(e)=>setForm({ ...form, prize_summary: e.target.value })} />
|
||||
</FormControl>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek (URL)</FormLabel>
|
||||
<Input value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Pravidla (URL)</FormLabel>
|
||||
<Input value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button onClick={onClose} variant="ghost">Zavřít</Button>
|
||||
<Button colorScheme="blue" onClick={save}>Uložit</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Prizes Modal */}
|
||||
<Modal isOpen={prizesDisc.isOpen} onClose={prizesDisc.onClose} size="2xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Výhry – {prizeSweep?.title}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{prizes.length === 0 && <Text color="gray.500">Zatím žádné výhry</Text>}
|
||||
{prizes.map((p, i) => (
|
||||
<HStack key={p.id} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
|
||||
<IconButton aria-label="Nahoru" size="xs" icon={<ArrowUpIcon />} onClick={()=>movePrize(i,-1)} />
|
||||
<IconButton aria-label="Dolů" size="xs" icon={<ArrowDownIcon />} onClick={()=>movePrize(i,1)} />
|
||||
<Text flex={1} fontWeight="600">{p.name}</Text>
|
||||
<Text>×{p.quantity}</Text>
|
||||
{p.kind && (
|
||||
<Text fontSize="xs" px={2} py={0.5} borderRadius="md" borderWidth="1px" color="gray.600">
|
||||
{p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
|
||||
</Text>
|
||||
)}
|
||||
<Text color="gray.500">{p.value}</Text>
|
||||
<IconButton aria-label="Smazat" size="xs" colorScheme="red" icon={<DeleteIcon />} onClick={()=>delPrize(p)} />
|
||||
</HStack>
|
||||
))}
|
||||
<Divider />
|
||||
<Heading size="sm">Přidat výhru</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={2} alignItems="end">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={prizeForm.name} onChange={(e)=>setPrizeForm({ ...prizeForm, name: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Počet</FormLabel>
|
||||
<NumberInput min={1} value={prizeForm.quantity} onChange={(v)=>setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Hodnota</FormLabel>
|
||||
<Input value={prizeForm.value} onChange={(e)=>setPrizeForm({ ...prizeForm, value: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek URL</FormLabel>
|
||||
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} alignItems="end">
|
||||
<FormControl>
|
||||
<FormLabel>Typ výhry</FormLabel>
|
||||
<Select value={prizeForm.kind || 'physical'} onChange={(e)=>setPrizeForm({ ...prizeForm, kind: e.target.value as any })}>
|
||||
<option value="physical">Fyzická výhra</option>
|
||||
<option value="points">Body</option>
|
||||
<option value="xp">XP</option>
|
||||
<option value="points_xp">Body + XP</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
|
||||
<FormControl>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<NumberInput min={0} value={Number(prizeForm.points)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
{(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
|
||||
<FormControl>
|
||||
<FormLabel>XP</FormLabel>
|
||||
<NumberInput min={0} value={Number(prizeForm.xp)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<HStack justify="flex-end">
|
||||
<Button leftIcon={<AddIcon />} colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={prizesDisc.onClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SweepstakesAdminPage;
|
||||
Reference in New Issue
Block a user