This commit is contained in:
Tomas Dvorak
2025-11-02 21:31:00 +01:00
parent b9cea0cd77
commit 087f30e82c
130 changed files with 20104 additions and 34330 deletions
+12
View File
@@ -54,6 +54,7 @@ const ContactPage = lazy(() => import('./pages/ContactPage'));
const GalleryPage = lazy(() => import('./pages/GalleryPage'));
const AlbumDetailPage = lazy(() => import('./pages/AlbumDetailPage'));
const AuthPage = lazy(() => import('./pages/AuthPage'));
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'));
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'));
const ActivitiesCalendarPage = lazy(() => import('./pages/ActivitiesCalendarPage'));
@@ -67,6 +68,7 @@ const SearchPage = lazy(() => import('./pages/SearchPage'));
const ClothingPage = lazy(() => import('./pages/ClothingPage'));
const PollsPage = lazy(() => import('./pages/PollsPage'));
const OverlayScoreboardPage = lazy(() => import('./pages/OverlayScoreboardPage'));
const OverlaySponsorsPage = lazy(() => import('./pages/OverlaySponsorsPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const ForbiddenPage = lazy(() => import('./pages/ForbiddenPage'));
@@ -102,9 +104,13 @@ const FilesAdminPage = lazy(() => import('./pages/admin/FilesAdminPage'));
const ContactsAdminPage = lazy(() => import('./pages/admin/ContactsAdminPage'));
const NavigationAdminPage = lazy(() => import('./pages/admin/NavigationAdminPage'));
const PollsAdminPage = lazy(() => import('./pages/admin/PollsAdminPage'));
const CommentsAdminPage = lazy(() => import('./pages/admin/CommentsAdminPage'));
const AdminDocsPage = lazy(() => import('./pages/admin/AdminDocsPage'));
const ScoreboardAdminPage = lazy(() => import('./pages/admin/ScoreboardAdminPage'));
const MobileScoreboardControlPage = lazy(() => import('./pages/admin/MobileScoreboardControlPage'));
const ShortlinksAdminPage = lazy(() => import('./pages/admin/ShortlinksAdminPage'));
const EngagementAdminPage = lazy(() => import('./pages/admin/EngagementAdminPage'));
const SemiAdminPage = lazy(() => import('./pages/SemiAdminPage'));
// Analytics and font loader
const AnalyticsInitializer: React.FC = () => {
@@ -176,6 +182,7 @@ const AppLazy: React.FC = () => {
<Route path="/hledat" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
<Route path="/overlay/sponsors" element={<OverlaySponsorsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/klub" element={<ClubPage />} />
<Route path="/o-klubu" element={<AboutPage />} />
@@ -218,11 +225,13 @@ const AppLazy: React.FC = () => {
{/* Auth */}
<Route path="/login" element={<PublicRoute><AuthPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
<Route path="/403" element={<ForbiddenPage />} />
<Route path="/semiadmin" element={<ProtectedRoute><SemiAdminPage /></ProtectedRoute>} />
{/* Admin routes */}
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
@@ -255,6 +264,9 @@ const AppLazy: React.FC = () => {
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
<Route path="/admin/komentare" element={<CommentsAdminPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
</Route>
{/* Legacy admin routes */}
+33
View File
@@ -54,6 +54,8 @@ import NavigationAdminPage from './pages/admin/NavigationAdminPage';
import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
import CommentsAdminPage from './pages/admin/CommentsAdminPage';
import EngagementAdminPage from './pages/admin/EngagementAdminPage';
import SweepstakesAdminPage from './pages/admin/SweepstakesAdminPage';
import SweepstakeVisualPage from './pages/admin/SweepstakeVisualPage';
import SemiAdminPage from './pages/SemiAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
@@ -69,6 +71,7 @@ import NewsletterPreferencesPage from './pages/NewsletterPreferencesPage';
import { ClubThemeProvider } from './contexts/ClubThemeContext';
import CookiePolicyPage from './pages/legal/CookiePolicyPage';
import OverlayScoreboardPage from './pages/OverlayScoreboardPage';
import OverlaySponsorsPage from './pages/OverlaySponsorsPage';
import CookieBanner from './components/CookieBanner';
import DefaultSEO from './components/seo/DefaultSEO';
import ProtectedRoute from './components/ProtectedRoute';
@@ -82,6 +85,7 @@ import ShortRedirectPage from './pages/ShortRedirectPage';
import ClothingPage from './pages/ClothingPage';
import PollsPage from './pages/PollsPage';
import { useUmami } from './hooks/useUmami';
import { checkin } from './services/engagement';
import { useFontLoader } from './hooks/useFontLoader';
// Create a client with better cache configuration
@@ -262,6 +266,31 @@ const FontLoader: React.FC = () => {
return null;
};
// Component to trigger daily check-in for authenticated users (once per day per device)
const CheckinInitializer: React.FC = () => {
const { isAuthenticated } = useAuth();
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const todayKey = (() => {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `fc_checkin_${y}-${m}-${day}`;
})();
try {
if (localStorage.getItem(todayKey) === '1') return;
} catch {}
// Fire and forget; backend caps ensure idempotence server-side
(async () => {
try { await checkin(); if (!cancelled) { try { localStorage.setItem(todayKey, '1'); } catch {} } } catch {}
})();
return () => { cancelled = true; };
}, [isAuthenticated]);
return null;
};
// Redirect /news -> /blog while preserving query parameters
const NewsRedirect: React.FC = () => {
const loc = useLocation();
@@ -333,6 +362,7 @@ const App: React.FC = () => {
<ClubThemeProvider>
<AnalyticsInitializer />
<FontLoader />
<CheckinInitializer />
<DefaultSEO />
<Routes>
{/* Public routes */}
@@ -340,6 +370,7 @@ const App: React.FC = () => {
<Route path="/hledat" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
<Route path="/overlay/sponsors" element={<OverlaySponsorsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/klub" element={<ClubPage />} />
<Route path="/o-klubu" element={<AboutPage />} />
@@ -458,6 +489,8 @@ const App: React.FC = () => {
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
<Route path="/admin/komentare" element={<CommentsAdminPage />} />
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
</Route>
{/* Remaining protected routes that don't use AdminLayout */}
+30 -1
View File
@@ -32,7 +32,8 @@ import {
FaUserShield,
FaFileAlt,
FaLink,
FaComments
FaComments,
FaGift
} from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
@@ -151,6 +152,7 @@ const getIconForPageType = (pageType?: string): any => {
docs: FaBook,
shortlinks: FaLink,
engagement: FaAward,
sweepstakes: FaGift,
};
return iconMap[pageType || ''] || FaFileAlt;
};
@@ -186,6 +188,12 @@ const AdminSidebar = ({
const hasEngagement = useMemo(() => {
return navItems.some(it => (it.page_type === 'engagement') || (it.url === '/admin/engagement'));
}, [navItems]);
const hasComments = useMemo(() => {
return navItems.some(it => (it.page_type === 'comments') || (it.url === '/admin/komentare'));
}, [navItems]);
const hasSweepstakes = useMemo(() => {
return navItems.some(it => (it.page_type === 'sweepstakes') || (it.url === '/admin/sweepstakes'));
}, [navItems]);
// Restore scroll on mount
useEffect(() => {
@@ -387,6 +395,27 @@ const AdminSidebar = ({
Odměny & Úspěchy
</NavItem>
)}
{/* Ensure Comments moderation is present even if not configured in dynamic nav */}
{!hasComments && (
<NavItem
icon={FaComments}
to="/admin/komentare"
onClick={onClose}
>
Komentáře
</NavItem>
)}
{/* Ensure Sweepstakes is present even if not configured in dynamic nav */}
{!hasSweepstakes && (
<NavItem
icon={FaGift}
to="/admin/sweepstakes"
onClick={onClose}
>
Soutěže
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
@@ -15,14 +15,17 @@ const PAGE_SIZE = 20;
const displayName = (u?: CommentItem['user']) => {
if (!u) return 'Anonym';
const uname = (u.username || '').trim();
if (uname) return uname;
const name = `${u.first_name || ''} ${u.last_name || ''}`.trim();
return name || (u.email || 'Uživatel');
return name || 'Uživatel';
};
const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
const muted = useColorModeValue('gray.600', 'gray.400');
const appealBg = useColorModeValue('gray.50','gray.700');
const queryClient = useQueryClient();
const { isAuthenticated, user } = useAuth();
@@ -44,6 +47,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const [replyTo, setReplyTo] = React.useState<number | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [canRequestUnban, setCanRequestUnban] = React.useState<boolean>(false);
const [unbanMessage, setUnbanMessage] = React.useState<string>('Prosím o odblokování komentářů. Děkuji.');
const createMut = useMutation({
mutationFn: (body: { content: string; parent_id?: number | null }) => createComment({ target_type: targetType, target_id: targetId, content: body.content, parent_id: body.parent_id }),
@@ -84,6 +88,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
mutationFn: (args: { id: number; type: string }) => reactComment(args.id, args.type),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
});
@@ -91,6 +96,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
mutationFn: (id: number) => unreactComment(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
});
@@ -99,6 +105,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
onSuccess: () => {
setCanRequestUnban(false);
setErrorMsg('Žádost o odblokování odeslána.');
setUnbanMessage('Prosím o odblokování komentářů. Děkuji.');
}
});
@@ -234,9 +241,18 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
<Text fontSize="sm" color={muted}>Respektujte prosím pravidla slušné diskuse.</Text>
</HStack>
{canRequestUnban && (
<HStack>
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate('Prosím o odblokování komentářů. Děkuji.')}>Požádat o odblokování</Button>
</HStack>
<VStack align="stretch" spacing={2} borderWidth="1px" borderColor={border} borderRadius="md" p={3} bg={appealBg}>
<Text fontSize="sm" color={muted}>Váš účet je dočasně zablokován pro komentování. Můžete odeslat žádost o odblokování s krátkým vysvětlením.</Text>
<Textarea
placeholder="Vaše zpráva pro administrátory…"
value={unbanMessage}
onChange={(e) => setUnbanMessage(e.target.value)}
rows={3}
/>
<HStack>
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate(unbanMessage.trim() || 'Prosím o odblokování komentářů. Děkuji.')} isLoading={unbanMut.isPending}>Odeslat žádost o odblokování</Button>
</HStack>
</VStack>
)}
</VStack>
) : (
@@ -0,0 +1,196 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
const fmtDate = (iso?: string | null) => {
if (!iso) return '';
const d = new Date(iso);
return isNaN(d.getTime()) ? '' : d.toLocaleString('cs-CZ');
};
const SweepstakeWidget: React.FC = () => {
const { user } = useAuth();
const [data, setData] = useState<CurrentSweepstakeResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [joining, setJoining] = useState<boolean>(false);
const [playing, setPlaying] = useState<boolean>(false);
const playedRef = useRef(false);
const load = async () => {
setLoading(true);
try {
const res = await getCurrentSweepstake();
setData(res);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const s = data?.sweepstake;
const prizes = data?.prizes || [];
const winners = data?.winners || [];
const state = data?.state || 'upcoming';
const isLogged = !!user;
const isAdmin = String((user as any)?.role || '').toLowerCase() === 'admin';
const iWon = useMemo(() => {
if (!isLogged || !winners?.length) return false;
const myId = (user as any)?.id;
return winners.some(w => w.user_id === myId);
}, [winners, isLogged, user]);
useEffect(() => {
// Autoplay visualization once for logged users within 3-day window, non-admins
if (!s || !isLogged || !winners?.length) return;
if (state !== 'finalized') return;
if (data?.visual_played_at) return;
if (playing || playedRef.current) return;
if (isAdmin) return; // admin can trigger manually from admin page; avoid auto here
setPlaying(true);
playedRef.current = true;
const t = setTimeout(async () => {
try { await markSweepstakeVisualPlayed(s.id); } catch {}
setPlaying(false);
await load();
}, 3000);
return () => clearTimeout(t);
}, [s, isLogged, winners, state, data?.visual_played_at, playing, isAdmin]);
if (loading) return null;
if (!s) return null;
const onJoin = async () => {
if (!s) return;
setJoining(true);
try {
await enterSweepstake(s.id);
await load();
} catch (e) {
// ignore
} finally {
setJoining(false);
}
};
const doPlay = async () => {
if (!s) return;
setPlaying(true);
const t = setTimeout(async () => {
try { await markSweepstakeVisualPlayed(s.id); } catch {}
setPlaying(false);
await load();
}, 2500);
return () => clearTimeout(t);
};
return (
<section data-element="sweepstakes" data-variant="default" style={{ marginTop: 16, marginBottom: 8 }}>
<div className="card" style={{ maxWidth: 1200, margin: '0 auto', padding: 16 }}>
{state === 'upcoming' && (
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Soutěž</h3>
{s.rules_url && (<a href={s.rules_url} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
{s.image_url && (
// eslint-disable-next-line jsx-a11y/alt-text
<img src={s.image_url} style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 8 }} />
)}
<div style={{ flex: 1, minWidth: 240 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
{s.description && <div style={{ opacity: 0.8 }}>{s.description}</div>}
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Začíná: {fmtDate(s.start_at)} Končí: {fmtDate(s.end_at)}</div>
</div>
{!isLogged ? (
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se a zapojit</a>
) : data?.has_entered ? (
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Přihlašuji…' : 'Zapojit se'}
</button>
)}
</div>
</div>
)}
{state === 'active' && (
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Soutěž</h3>
{s.rules_url && (<a href={s.rules_url} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
{s.image_url && (
// eslint-disable-next-line jsx-a11y/alt-text
<img src={s.image_url} style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 8 }} />
)}
<div style={{ flex: 1, minWidth: 240 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
{s.description && <div style={{ opacity: 0.8 }}>{s.description}</div>}
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Konec: {fmtDate(s.end_at)}</div>
</div>
{!isLogged ? (
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
) : data?.has_entered ? (
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Přihlašuji…' : 'Zapojit se'}
</button>
)}
</div>
</div>
)}
{state === 'finalized' && (
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Výherci soutěže</h3>
{s.rules_url && (<a href={s.rules_url} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
</div>
{winners.length === 0 ? (
<div>Výherci budou vyhlášeni brzy.</div>
) : (
<div>
{/* Visualization */}
{!data?.visual_played_at && isLogged && (
<div style={{ marginBottom: 12 }}>
{playing ? (
<div style={{ padding: 16, border: '1px dashed #999', borderRadius: 8, textAlign: 'center' }}>
<div style={{ fontWeight: 700, marginBottom: 6 }}>Losuji výherce</div>
<div className="spinner" style={{ width: 24, height: 24, borderRadius: '50%', border: '3px solid #ccc', borderTopColor: '#333', margin: '0 auto', animation: 'spin 0.9s linear infinite' }} />
</div>
) : (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn" onClick={doPlay}>Spustit losování</button>
<span style={{ opacity: 0.8, fontSize: 14 }}>(animace pouze jednou na uživatele)</span>
</div>
)}
</div>
)}
{/* Winners list */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
{winners.map((w) => (
<div key={w.id} className="card" style={{ padding: 12 }}>
<div style={{ fontWeight: 700 }}>{w.prize_name || 'Výhra'}</div>
<div style={{ fontSize: 14, opacity: 0.8 }}>Výherce: {user && w.user_id === (user as any).id ? 'Vy' : 'Vybraný uživatel'}</div>
</div>
))}
</div>
{iWon && (
<div style={{ marginTop: 8, fontWeight: 700 }}>Gratulujeme! Tato stránka rozpoznala, že patříte mezi výherce.</div>
)}
</div>
)}
</div>
)}
</div>
<style>{`@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}`}</style>
</section>
);
};
export default SweepstakeWidget;
+19
View File
@@ -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(() => {
+4
View File
@@ -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;
+351 -1
View File
@@ -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>
+11 -1
View File
@@ -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>
+27 -2
View File
@@ -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>
+628 -30
View File
@@ -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 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'avatar_animated_upload_unlock', cost_points: 150 })}>Odemknout animovaný upload (150b ~ 15 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'avatar_upload_unlock', cost_points: 250 })}>Odemknout upload (250b ~ 25 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_coupon', cost_points: 1000 })}>Kupon (1000b ~ 100 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_coupon', cost_points: 2000 })}>Kupon (2000b ~ 200 )</Button>
<Button size="sm" onClick={() => setForm({ ...form, type: 'merch_physical', cost_points: 4000, stock: 1 })}>Fyzická odměna (4000b ~ 400 )</Button>
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
</HStack>
<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)} </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)} </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>
+117 -75
View File
@@ -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;
+18
View File
@@ -23,6 +23,24 @@ export async function adminBanUser(user_id: number, reason: string, duration_hou
return res.data as { ok: boolean };
}
export type CommentBan = {
id: number;
user_id: number;
reason?: string;
until?: string | null;
created_at: string;
};
export async function adminListBans(): Promise<{ items: CommentBan[] }>{
const res = await api.get('/admin/comments/bans');
return res.data as { items: CommentBan[] };
}
export async function adminLiftBan(id: number): Promise<{ ok: boolean }>{
const res = await api.post(`/admin/comments/bans/${id}/lift`);
return res.data as { ok: boolean };
}
export type UnbanRequest = {
id: number;
user_id: number;
+46
View File
@@ -1,5 +1,6 @@
import api from '../api';
import { RewardItem } from '../../services/engagement';
import type { LeaderboardResponse } from '../../services/engagement';
export type AdminRewardItem = RewardItem & {
active: boolean;
@@ -66,3 +67,48 @@ export async function adminUpdateRedemptionStatus(id: number, action: 'approve'|
const res = await api.patch(`/admin/engagement/redemptions/${id}`, { action });
return res.data as { ok: boolean; status: string };
}
export async function adminGetLeaderboard(metric: 'points'|'level'|'xp' = 'points', limit?: number): Promise<LeaderboardResponse> {
const res = await api.get('/admin/engagement/leaderboard', { params: { metric, limit } });
return res.data as LeaderboardResponse;
}
export type AdminPointsTx = {
id: number;
user_id: number;
delta: number;
xp_delta?: number;
reason: string;
meta?: Record<string, any>;
created_at: string;
};
export async function adminListTransactions(params?: { user_id?: number|string; reason?: string; limit?: number }): Promise<AdminPointsTx[]> {
const res = await api.get('/admin/engagement/transactions', { params });
return (res.data?.items || []) as AdminPointsTx[];
}
export async function adminAdjustPoints(body: { user_id: number; delta: number; reason?: string; meta?: Record<string, any> }): Promise<{ ok: boolean }>{
const res = await api.post('/admin/engagement/adjust', body);
return res.data as { ok: boolean };
}
export type AdminUserProfile = {
user_id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
points: number;
level: number;
xp: number;
username?: string;
avatar_url?: string;
animated_avatar_url?: string;
avatar_upload_unlocked?: boolean;
};
export async function adminGetUserProfile(user_id: number | string): Promise<AdminUserProfile> {
const res = await api.get(`/admin/engagement/profile/${user_id}`);
return res.data as AdminUserProfile;
}
+2 -1
View File
@@ -19,8 +19,9 @@ export type CommentItem = {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
username?: string;
avatar_url?: string;
};
};
+58
View File
@@ -5,8 +5,11 @@ export type EngagementProfile = {
points: number;
level: number;
xp: number;
username?: string;
avatar_url?: string;
animated_avatar_url?: string;
avatar_upload_unlocked?: boolean;
animated_avatar_upload_unlocked?: boolean;
achievements: number;
};
@@ -15,6 +18,11 @@ export async function getProfile(): Promise<EngagementProfile> {
return res.data as EngagementProfile;
}
export async function patchProfile(body: { username?: string }): Promise<{ ok: boolean }>{
const res = await api.patch('/engagement/profile', body);
return res.data as { ok: boolean };
}
export type RewardItem = {
id: number;
name: string;
@@ -64,3 +72,53 @@ export async function getAchievements(): Promise<AchievementsResponse> {
const res = await api.get('/engagement/achievements');
return res.data as AchievementsResponse;
}
export type LeaderboardItem = {
rank: number;
user_id: number;
first_name?: string;
last_name?: string;
username?: string;
role?: string;
points: number;
level: number;
xp: number;
avatar_url?: string;
animated_avatar_url?: string;
};
export type LeaderboardResponse = { items: LeaderboardItem[] };
export async function getLeaderboard(metric: 'points'|'level'|'xp' = 'points', limit?: number): Promise<LeaderboardResponse> {
const res = await api.get('/engagement/leaderboard', { params: { metric, limit } });
return res.data as LeaderboardResponse;
}
export type PointsTx = {
id: number;
user_id: number;
delta: number;
xp_delta?: number;
reason: string;
meta?: Record<string, any>;
created_at: string;
};
export async function getMyTransactions(params?: { limit?: number; reason?: string }): Promise<PointsTx[]> {
const res = await api.get('/engagement/transactions', { params });
return (res.data?.items || []) as PointsTx[];
}
export type CheckinResponse = { ok: boolean; awarded: boolean; points?: number; level?: number; xp?: number };
export async function checkin(): Promise<CheckinResponse> {
const res = await api.post('/engagement/checkin');
return res.data as CheckinResponse;
}
export type ArticleReadResponse = { ok: boolean; awarded: boolean; points?: number; level?: number; xp?: number };
export async function articleRead(article_id: number): Promise<ArticleReadResponse> {
const res = await api.post('/engagement/article-read', { article_id });
return res.data as ArticleReadResponse;
}
+42
View File
@@ -140,6 +140,48 @@ export async function startSecondHalf(): Promise<void> {
await api.post('/admin/scoreboard/second-half');
}
// Admin: presets
export async function listPresets(): Promise<string[]> {
const res = await api.get<string[]>('/admin/scoreboard/saves');
// API returns an array of filenames
return (res.data || []).slice();
}
export async function savePreset(filename?: string): Promise<{ saved: string }> {
const payload = filename && filename.trim() ? { filename: filename.trim() } : {};
const res = await api.post<{ saved: string }>('/admin/scoreboard/save', payload);
return res.data;
}
export async function loadPreset(filename: string): Promise<void> {
const name = (filename || '').trim();
if (!name) throw new Error('Missing filename');
await api.post('/admin/scoreboard/load', { filename: name });
}
// Admin: sponsors management
export async function listSponsorsAdmin(): Promise<string[]> {
const res = await api.get<string[]>('/admin/scoreboard/sponsors');
return res.data || [];
}
export async function uploadSponsors(files: File[]): Promise<{ saved: number }> {
const fd = new FormData();
for (const f of files) fd.append('files', f);
const res = await api.post<{ saved: number }>('/admin/scoreboard/sponsors/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
return res.data || { saved: 0 };
}
export async function deleteSponsor(name: string): Promise<void> {
await api.delete('/admin/scoreboard/sponsors', { params: { name } });
}
// Public: sponsors list for overlay
export async function listSponsorsPublic(): Promise<string[]> {
const res = await api.get<string[]>('/scoreboard/sponsors');
return res.data || [];
}
// Utilities
export function deriveShort(name?: string): string {
if (!name) return '---';
+5
View File
@@ -120,6 +120,11 @@ export type AdminSettings = PublicSettings & {
api_base_url?: string;
// Homepage matches display configuration
finished_match_display_days?: number; // Number of days to show finished matches with scores on homepage
// Storage quota and thresholds
storage_quota_mb?: number;
storage_warn_threshold?: number;
storage_critical_threshold?: number;
};
export const getPublicSettings = async (): Promise<PublicSettings> => {
+160
View File
@@ -0,0 +1,160 @@
import api from './api';
export type Sweepstake = {
id: number;
title: string;
description?: string;
image_url?: string;
rules_url?: string;
start_at: string;
end_at: string;
status: string;
picker_style?: 'wheel' | 'cycler' | string;
total_prizes?: number;
prize_summary?: string;
winners_selected_at?: string | null;
visibility_until?: string | null;
};
export type SweepstakePrize = {
id: number;
sweepstake_id: number;
name: string;
description?: string;
image_url?: string;
value?: string;
quantity: number;
display_order: number;
kind?: 'physical' | 'points' | 'xp' | 'points_xp';
points?: number;
xp?: number;
};
export type SweepstakeWinner = {
id: number;
sweepstake_id: number;
entry_id: number;
user_id: number;
prize_id?: number | null;
prize_name?: string;
claim_status: string;
announced_at?: string | null;
};
export type CurrentSweepstakeResponse = {
sweepstake: Sweepstake | null;
prizes?: SweepstakePrize[];
winners?: SweepstakeWinner[];
state?: 'upcoming' | 'active' | 'finalized';
has_entered?: boolean;
visual_played_at?: string | null;
};
export async function getCurrentSweepstake(): Promise<CurrentSweepstakeResponse> {
const res = await api.get('/sweepstakes/current');
return res.data;
}
export async function enterSweepstake(id: number): Promise<void> {
await api.post(`/sweepstakes/${id}/enter`, {});
}
export async function markSweepstakeVisualPlayed(id: number): Promise<void> {
await api.post(`/sweepstakes/${id}/played`, {});
}
export async function getMyWinnings(): Promise<{ items: SweepstakeWinner[] }> {
const res = await api.get('/sweepstakes/my-winnings');
return res.data;
}
// Admin
export async function adminListSweepstakes(params?: { status?: string }) {
const res = await api.get('/admin/sweepstakes', { params });
return res.data?.items || [];
}
export async function adminCreateSweepstake(data: Partial<Sweepstake> & { start_at: string; end_at: string }) {
const res = await api.post('/admin/sweepstakes', data);
return res.data;
}
export async function adminUpdateSweepstake(id: number, data: Partial<Sweepstake>) {
const res = await api.put(`/admin/sweepstakes/${id}`, data);
return res.data;
}
export async function adminDeleteSweepstake(id: number) {
const res = await api.delete(`/admin/sweepstakes/${id}`);
return res.data;
}
export async function adminListEntries(id: number) {
const res = await api.get(`/admin/sweepstakes/${id}/entries`);
return res.data?.items || [];
}
export async function adminListWinners(id: number) {
const res = await api.get(`/admin/sweepstakes/${id}/winners`);
return res.data?.items || [];
}
export async function adminFinalizeSweepstake(id: number, seed?: string) {
const res = await api.post(`/admin/sweepstakes/${id}/finalize`, seed ? { seed } : {});
return res.data;
}
// Visualizer data
export type VisualEntry = { user_id: number; display_name: string; avatar_url?: string };
export type VisualWinner = { id?: number; user_id: number; prize_name?: string; claim_status?: string };
export type VisualData = { sweepstake: Sweepstake; entries: VisualEntry[]; winners: VisualWinner[] };
export async function adminGetVisualData(id: number): Promise<VisualData> {
const res = await api.get(`/admin/sweepstakes/${id}/visual`);
return res.data;
}
export async function getPublicVisualData(id: number): Promise<VisualData> {
const res = await api.get(`/sweepstakes/${id}/visual`);
return res.data;
}
// Prizes CRUD
export type SweepstakePrizeInput = Partial<SweepstakePrize> & {
name?: string;
quantity?: number;
display_order?: number;
kind?: 'physical' | 'points' | 'xp' | 'points_xp';
points?: number;
xp?: number;
};
export async function adminListPrizes(id: number): Promise<SweepstakePrize[]> {
const res = await api.get(`/admin/sweepstakes/${id}/prizes`);
return res.data?.items || [];
}
export async function adminCreatePrize(id: number, data: SweepstakePrizeInput) {
const res = await api.post(`/admin/sweepstakes/${id}/prizes`, data);
return res.data;
}
export async function adminUpdatePrize(id: number, prizeId: number, data: SweepstakePrizeInput) {
const res = await api.put(`/admin/sweepstakes/${id}/prizes/${prizeId}`, data);
return res.data;
}
export async function adminDeletePrize(id: number, prizeId: number) {
const res = await api.delete(`/admin/sweepstakes/${id}/prizes/${prizeId}`);
return res.data;
}
export async function adminReorderPrizes(id: number, order: number[]) {
const res = await api.post(`/admin/sweepstakes/${id}/prizes/reorder`, { order });
return res.data;
}
// Winners management
export async function adminUpdateWinner(id: number, winnerId: number, data: { claim_status?: 'pending'|'claimed'|'delivered'; claim_note?: string }) {
const res = await api.patch(`/admin/sweepstakes/${id}/winners/${winnerId}`, data);
return res.data;
}
export async function adminSetWinnerPrize(id: number, winnerId: number, prizeId: number) {
const res = await api.patch(`/admin/sweepstakes/${id}/winners/${winnerId}/prize`, { prize_id: prizeId });
return res.data;
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Comments System Utilities
* Helper functions for comments, reactions, and moderation
*/
import { formatDistanceToNow } from 'date-fns';
import { cs } from 'date-fns/locale';
/**
* Format comment age in human-readable Czech format
*/
export function formatCommentAge(createdAt: string | Date): string {
try {
const date = typeof createdAt === 'string' ? new Date(createdAt) : createdAt;
return formatDistanceToNow(date, { addSuffix: true, locale: cs });
} catch {
return '';
}
}
/**
* Get reaction emoji
*/
export function getReactionEmoji(type: string): string {
const emojis: Record<string, string> = {
like: '👍',
heart: '❤️',
smile: '😊',
laugh: '😂',
thumbs_up: '👍',
thumbs_down: '👎',
sad: '😢',
angry: '😠',
};
return emojis[type] || '👍';
}
/**
* Get reaction display name (localized)
*/
export function getReactionDisplayName(type: string): string {
const names: Record<string, string> = {
like: 'Líbí se',
heart: 'Srdíčko',
smile: 'Úsměv',
laugh: 'Smích',
thumbs_up: 'Palec nahoru',
thumbs_down: 'Palec dolů',
sad: 'Smutné',
angry: 'Naštvaný',
};
return names[type] || type;
}
/**
* Get all available reaction types
*/
export function getAvailableReactions(): Array<{ type: string; emoji: string; name: string }> {
const types = ['like', 'heart', 'smile', 'laugh', 'sad', 'angry'];
return types.map(type => ({
type,
emoji: getReactionEmoji(type),
name: getReactionDisplayName(type),
}));
}
/**
* Count total reactions
*/
export function countTotalReactions(reactions: Record<string, number>): number {
return Object.values(reactions).reduce((sum, count) => sum + count, 0);
}
/**
* Get top reaction (most popular)
*/
export function getTopReaction(reactions: Record<string, number>): string | null {
if (!reactions || Object.keys(reactions).length === 0) return null;
let maxType = '';
let maxCount = 0;
for (const [type, count] of Object.entries(reactions)) {
if (count > maxCount) {
maxCount = count;
maxType = type;
}
}
return maxType || null;
}
/**
* Shorten comment content for previews
*/
export function shortenComment(content: string, maxLength: number = 100): string {
const c = content.trim();
if (c.length <= maxLength) return c;
const shortened = c.substring(0, maxLength);
const lastSpace = shortened.lastIndexOf(' ');
if (lastSpace > maxLength / 2) {
return c.substring(0, lastSpace) + '...';
}
return shortened + '...';
}
/**
* Get target type display name (localized)
*/
export function getTargetTypeDisplayName(targetType: string): string {
const names: Record<string, string> = {
article: 'Článek',
event: 'Aktivita',
gallery_album: 'Galerie',
youtube_video: 'Video',
};
return names[targetType] || targetType;
}
/**
* Get comment status color scheme
*/
export function getCommentStatusColor(status: string): string {
return status === 'visible' ? 'green' : 'red';
}
/**
* Get spam score color scheme
*/
export function getSpamScoreColor(score: number): string {
if (score < 0.3) return 'green';
if (score < 0.6) return 'yellow';
return 'red';
}
/**
* Check if comment is likely spam
*/
export function isSpammy(spamScore: number): boolean {
return spamScore > 0.5;
}
/**
* Validate comment content (client-side pre-validation)
*/
export function validateCommentContent(content: string): { valid: boolean; error?: string } {
const c = content.trim();
if (!c) {
return { valid: false, error: 'Komentář nesmí být prázdný' };
}
if (c.length < 6) {
return { valid: false, error: 'Komentář je příliš krátký (minimálně 6 znaků)' };
}
if (c.length > 2000) {
return { valid: false, error: 'Komentář je příliš dlouhý (maximálně 2000 znaků)' };
}
// Check for excessive caps
if (isExcessiveCaps(c)) {
return { valid: false, error: 'Příliš mnoho velkých písmen' };
}
// Check for excessive repetition
if (hasExcessiveRepetition(c)) {
return { valid: false, error: 'Příliš mnoho opakujících se znaků' };
}
return { valid: true };
}
/**
* Check if text has excessive capital letters (>50% in long texts)
*/
function isExcessiveCaps(text: string): boolean {
if (text.length < 20) return false; // Allow caps in short messages
let upper = 0;
let lower = 0;
for (const char of text) {
if (char >= 'A' && char <= 'Z') upper++;
else if (char >= 'a' && char <= 'z') lower++;
}
const total = upper + lower;
if (total === 0) return false;
return upper / total > 0.5;
}
/**
* Check if text has excessive character repetition
*/
function hasExcessiveRepetition(text: string): boolean {
// Check for 5+ consecutive identical characters
return /(.)\1{4,}/.test(text);
}
/**
* Format ban duration text
*/
export function getBanDurationText(until: string | null | undefined): string {
if (!until) return 'Trvale';
const untilDate = new Date(until);
const now = new Date();
if (untilDate < now) return 'Vypršelo';
const diff = untilDate.getTime() - now.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(hours / 24);
if (days > 0) {
return days === 1 ? '1 den' : `${days} dnů`;
}
if (hours > 0) {
return hours === 1 ? '1 hodina' : `${hours} hodin`;
}
const minutes = Math.floor(diff / (1000 * 60));
if (minutes > 0) {
return minutes === 1 ? '1 minuta' : `${minutes} minut`;
}
return '< 1 minuta';
}
/**
* Get quick ban duration presets
*/
export function getBanDurationPresets(): Array<{ label: string; hours: number }> {
return [
{ label: '1 hodina', hours: 1 },
{ label: '12 hodin', hours: 12 },
{ label: '24 hodin', hours: 24 },
{ label: '3 dny', hours: 72 },
{ label: '7 dní', hours: 168 },
{ label: '30 dní', hours: 720 },
{ label: 'Trvale', hours: 0 },
];
}
/**
* Sort comments for display (newest first or threaded)
*/
export function sortComments<T extends { id: number; parent_id?: number | null; created_at: string }>(
comments: T[],
mode: 'newest' | 'oldest' | 'threaded' = 'newest'
): T[] {
if (mode === 'threaded') {
// Build tree structure
const rootComments = comments.filter(c => !c.parent_id);
const childMap = new Map<number, T[]>();
comments.forEach(c => {
if (c.parent_id) {
const children = childMap.get(c.parent_id) || [];
children.push(c);
childMap.set(c.parent_id, children);
}
});
// Flatten with children
const result: T[] = [];
rootComments.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
for (const root of rootComments) {
result.push(root);
const children = childMap.get(root.id) || [];
children.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
result.push(...children);
}
return result;
}
// Simple sorting
return [...comments].sort((a, b) => {
const timeA = new Date(a.created_at).getTime();
const timeB = new Date(b.created_at).getTime();
return mode === 'newest' ? timeB - timeA : timeA - timeB;
});
}
+245
View File
@@ -0,0 +1,245 @@
/**
* Engagement System Utilities
* Helper functions for XP, levels, points, and rewards
*/
export interface LevelInfo {
level: number;
xp: number;
currentBase: number;
nextBase: number;
inLevel: number;
nextInc: number;
percentage: number;
}
/**
* Compute level information from XP
* Formula: Total XP to reach level L = 50 * (L-1) * L
* Each level requires: 100 * L XP
*/
export function computeLevelInfo(xp: number, currentLevel?: number): LevelInfo {
const level = currentLevel || computeLevelFromXP(xp);
const totalToLevel = 50 * (level - 1) * level;
const nextInc = 100 * level;
const totalToNext = totalToLevel + nextInc;
const inLevel = Math.max(0, xp - totalToLevel);
const percentage = Math.max(0, Math.min(100, Math.floor((inLevel / Math.max(1, nextInc)) * 100)));
return {
level,
xp,
currentBase: totalToLevel,
nextBase: totalToNext,
inLevel,
nextInc,
percentage,
};
}
/**
* Compute level from total XP
*/
export function computeLevelFromXP(xp: number): number {
let level = 1;
let threshold = 100;
let remaining = xp;
while (remaining >= threshold && level < 200) {
remaining -= threshold;
level++;
threshold += 100;
}
return Math.max(1, level);
}
/**
* Get XP needed to reach next level
*/
export function getXPToNextLevel(currentXP: number, currentLevel: number): number {
const totalToLevel = 50 * (currentLevel - 1) * currentLevel;
const nextInc = 100 * currentLevel;
return totalToLevel + nextInc - currentXP;
}
/**
* Get level title (localized)
*/
export function getLevelTitle(level: number): string {
if (level >= 100) return 'Legenda';
if (level >= 80) return 'Mistr';
if (level >= 60) return 'Expert';
if (level >= 40) return 'Veterán';
if (level >= 25) return 'Zkušený fanoušek';
if (level >= 15) return 'Oddaný příznivec';
if (level >= 10) return 'Aktivní člen';
if (level >= 5) return 'Nováček';
return 'Začátečník';
}
/**
* Get level color for badges
*/
export function getLevelColor(level: number): string {
if (level >= 80) return 'yellow.400'; // Diamond/Gold
if (level >= 60) return 'purple.400'; // Platinum
if (level >= 40) return 'blue.400'; // Gold
if (level >= 20) return 'cyan.400'; // Silver
if (level >= 10) return 'green.400'; // Bronze
return 'gray.400'; // Beginner
}
/**
* Get Chakra UI color scheme for level
*/
export function getLevelColorScheme(level: number): string {
if (level >= 80) return 'yellow';
if (level >= 60) return 'purple';
if (level >= 40) return 'blue';
if (level >= 20) return 'cyan';
if (level >= 10) return 'green';
return 'gray';
}
/**
* Format points with thousands separator
*/
export function formatPoints(points: number): string {
if (points < 1000) return points.toString();
if (points < 1000000) return `${(points / 1000).toFixed(1)}k`;
return `${(points / 1000000).toFixed(1)}M`;
}
/**
* Get reward type display name (localized)
*/
export function getRewardTypeDisplayName(type: string): string {
const names: Record<string, string> = {
avatar_static: 'Avatar (statický)',
avatar_animated: 'Avatar (animovaný)',
avatar_upload_unlock: 'Odemknutí vlastního avataru',
merch_coupon: 'Slevový kupon',
merch_physical: 'Fyzické zboží',
merch_digital: 'Digitální produkt',
custom: 'Vlastní',
};
return names[type] || type;
}
/**
* Get redemption status display name (localized)
*/
export function getRedemptionStatusDisplayName(status: string): string {
const names: Record<string, string> = {
pending: 'Čeká na vyřízení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
fulfilled: 'Vydáno',
};
return names[status] || status;
}
/**
* Get redemption status color scheme
*/
export function getRedemptionStatusColor(status: string): string {
const colors: Record<string, string> = {
pending: 'yellow',
approved: 'blue',
rejected: 'red',
fulfilled: 'green',
};
return colors[status] || 'gray';
}
/**
* Get transaction reason display name (localized)
*/
export function getTransactionReasonDisplayName(reason: string): string {
const names: Record<string, string> = {
comment_create: 'Nový komentář',
comment_reacted: 'Reakce na komentář',
poll_vote: 'Hlasování v anketě',
newsletter_subscribe: 'Odběr newsletteru',
redeem: 'Uplatnění odměny',
redeem_refund: 'Vrácení bodů',
admin_adjust: 'Manuální úprava',
};
if (names[reason]) return names[reason];
if (reason.startsWith('achievement:')) return 'Úspěch odemknut';
return reason;
}
/**
* Calculate badge tier based on level
*/
export function getBadgeTier(level: number): string {
if (level >= 80) return 'diamond';
if (level >= 60) return 'platinum';
if (level >= 40) return 'gold';
if (level >= 20) return 'silver';
if (level >= 10) return 'bronze';
return 'none';
}
/**
* Validate username format (client-side pre-validation)
*/
export function validateUsername(username: string): { valid: boolean; error?: string } {
const u = username.trim();
if (!u) {
return { valid: false, error: 'Uživatelské jméno nesmí být prázdné' };
}
if (u.length < 3) {
return { valid: false, error: 'Minimálně 3 znaky' };
}
if (u.length > 32) {
return { valid: false, error: 'Maximálně 32 znaků' };
}
// Only lowercase, numbers, and special chars
if (!/^[a-z0-9\-_.]+$/.test(u)) {
return { valid: false, error: 'Pouze malá písmena, čísla a znaky: - _ .' };
}
// No consecutive special chars
if (u.includes('--') || u.includes('__') || u.includes('..')) {
return { valid: false, error: 'Žádné opakující se speciální znaky' };
}
// Cannot start/end with special chars
if (/^[-_.]|[-_.]$/.test(u)) {
return { valid: false, error: 'Nesmí začínat/končit speciálním znakem' };
}
// Reserved words
const reserved = ['admin', 'administrator', 'mod', 'moderator', 'root', 'system', 'support', 'official', 'bot'];
if (reserved.includes(u.toLowerCase())) {
return { valid: false, error: 'Toto jméno je rezervované' };
}
return { valid: true };
}
/**
* Generate random username suggestion
*/
export function generateUsernameSuggestion(firstName?: string, lastName?: string): string {
const base = [firstName, lastName]
.filter(Boolean)
.join('-')
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/--+/g, '-')
.replace(/^-|-$/g, '');
if (!base) {
return `fan-${Math.floor(Math.random() * 10000)}`;
}
const suffix = Math.floor(Math.random() * 1000);
return `${base}-${suffix}`.substring(0, 32);
}