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