dev day #100 - WE ARE FUCKING DONE, hotfixes incoming but we did it in 100 days, lets fucking go guys, anyone reading this...i love you

This commit is contained in:
Tomas Dvorak
2025-11-22 21:30:10 +01:00
parent f5b6f83974
commit aa036b6550
47 changed files with 3607 additions and 2177 deletions
+27 -27
View File
@@ -28,14 +28,14 @@ This file tracks the final QA passes, defects, and enhancements. Each item inclu
- Status: [x] Fully working
## Tabule (Scoreboard)
- Status: [~] Enhancements only
- Status: [x] Fully working
- Tasks:
- [ ] Minor UI polish and responsiveness
- [x] Minor UI polish and responsiveness
## Scoreboard Remote
- Status: [~] Enhancements only
- Status: [x] Fully working
- Tasks:
- [ ] Minor UI polish and responsiveness
- [x] Minor UI polish and responsiveness
## Rich Text Editor
- Status: [x] Fully working
@@ -128,11 +128,11 @@ This file tracks the final QA passes, defects, and enhancements. Each item inclu
- [x] Preferences page opens and updates subscriptions
## Bannery
- Status: [~] Fixing
- Status: [x] Fully working
- Issue:
- [ ] Postranní banner style/position broken; appears under hero with side gaps
- [x] Postranní banner style/position broken; appears under hero with side gaps
- Acceptance criteria:
- [ ] Banner anchors to left/right side as configured; no extra gaps; not under hero
- [x] Banner anchors to left/right side as configured; no extra gaps; not under hero
## Oblečení
- Status: [x] Fully working
@@ -150,26 +150,26 @@ This file tracks the final QA passes, defects, and enhancements. Each item inclu
- [x] Modal content spaced and scrollable
## Odměny & Úspěchy
- Status: [~] Fixing
- Status: [x] Fully working
- Issues:
- [ ] Remove avatar templates (wont use)
- [ ] Add digitální odměna
- [ ] Image uploads for all variants
- [ ] Rename SKU → Množství/Sklad; -1 = neomezeně
- [ ] Remove avatar typy (statický/animovaný/odemknutí vlastního) cannot be created/disabled
- [x] Remove avatar templates (wont use)
- [x] Add digitální odměna
- [x] Image uploads for all variants
- [x] Rename SKU → Množství/Sklad; -1 = neomezeně
- [x] Remove avatar typy (statický/animovaný/odemknutí vlastního) cannot be created/disabled
- Acceptance criteria:
- [ ] Admin UI simplified; types and fields as requested
- [x] Admin UI simplified; types and fields as requested
## Zkrácené odkazy
- Status: [~] Fixing
- Status: [x] Fully working
- Issues:
- [ ] 400 errors on /api/v1/shortlinks and /api/v1/admin/shortlinks
- [x] 400 errors on /api/v1/shortlinks and /api/v1/admin/shortlinks
- [x] 404 on YouTube thumbnail
- [ ] Console noise (service worker messages ok; others quiet)
- [ ] Specific shortlink not working (e.g., to zeusport)
- [x] Console noise (service worker messages ok; others quiet)
- [x] Specific shortlink not working (e.g., to zeusport)
- Acceptance criteria:
- [ ] API endpoints return 2xx; create/list works; redirects resolve
- [ ] Missing thumbnails handled gracefully (fallback)
- [x] API endpoints return 2xx; create/list works; redirects resolve
- [x] Missing thumbnails handled gracefully (fallback)
## Prefetch & Cache
- Status: [x] Fully working
@@ -178,19 +178,19 @@ This file tracks the final QA passes, defects, and enhancements. Each item inclu
- Status: [x] Fully working
## Uživatelé / Role
- Status: [~] Fixing
- Status: [x] Fully working
- Issues:
- [ ] Editor cannot access admin; should access selected pages by admin configuration
- [ ] Avoid 403 for allowed pages
- [x] Editor cannot access admin; should access selected pages by admin configuration
- [x] Avoid 403 for allowed pages
- Acceptance criteria:
- [ ] Role-based per-page access; configurable; editor can view allowed pages
- [x] Role-based per-page access; configurable; editor can view allowed pages
## Navigace (Admin)
- Status: [~] Fixing
- Status: [x] Fully working
- Issue:
- [ ] Drag between subcategories makes item primary (loses category)
- [x] Drag between subcategories makes item primary (loses category)
- Acceptance criteria:
- [ ] Drag-and-drop across categories preserves/updates category correctly
- [x] Drag-and-drop across categories preserves/updates category correctly
---
+59 -1
View File
@@ -14,6 +14,7 @@ import ProtectedRoute from './components/ProtectedRoute';
import { getSetupStatus } from './services/setup';
import { useState, useEffect } from 'react';
import { usePublicSettings } from './hooks/usePublicSettings';
import { getEditorAllowedAdminNav } from './services/navigation';
// Create a client
const queryClient = new QueryClient({
@@ -177,6 +178,54 @@ const AdminRoutesWrapper = () => {
return <Outlet />;
};
// Admin index: admins see dashboard; editors redirect to first allowed page
const AdminIndexRoute: React.FC = () => {
const { user } = useAuth();
const role = (user as any)?.role;
const [target, setTarget] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(role === 'editor');
useEffect(() => {
let mounted = true;
(async () => {
if (role === 'editor') {
try {
const items: any[] = await getEditorAllowedAdminNav();
let to = '/admin/clanky';
if (Array.isArray(items) && items.length > 0) {
const pickUrl = (it: any): string | null => {
if (it?.url) return it.url;
if (Array.isArray(it?.children) && it.children.length > 0) {
for (const ch of it.children) {
if (ch?.url) return ch.url;
}
}
return null;
};
for (const it of items) {
const u = pickUrl(it);
if (u) { to = u; break; }
}
}
if (mounted) setTarget(to);
} catch (_) {
if (mounted) setTarget('/admin/clanky');
} finally {
if (mounted) setLoading(false);
}
}
})();
return () => { mounted = false; };
}, [role]);
if (role === 'admin') return <AdminDashboardPage />;
if (role === 'editor') {
if (loading) return <PageLoader />;
return <Navigate to={target || '/admin/clanky'} replace />;
}
return <Navigate to="/403" replace />;
};
// Premium-aware route elements (wait for settings before deciding)
const HomeRoute: React.FC = () => {
const { data, isLoading } = usePublicSettings();
@@ -263,6 +312,16 @@ const AppLazy: React.FC = () => {
<Route path="/403" element={<ForbiddenPage />} />
<Route path="/semiadmin" element={<ProtectedRoute><SemiAdminPage /></ProtectedRoute>} />
{/* Admin index: allow both admins and editors; decide inside */}
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminIndexRoute />
</ProtectedRoute>
}
/>
{/* Editor-level content admin routes (accessible to editors and admins) */}
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
@@ -272,7 +331,6 @@ const AppLazy: React.FC = () => {
{/* Admin routes */}
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
+32 -2
View File
@@ -365,6 +365,20 @@ const App: React.FC = () => {
return <Outlet />;
};
// Admin index: admins see dashboard; editors are redirected to their first allowed page
const AdminIndexRoute: React.FC = () => {
const { user } = useAuth();
const role = String(user?.role || '').toLowerCase();
if (role === 'admin') {
return <AdminDashboardPage />;
}
if (role === 'editor') {
// Default first allowed page for editors; configurable nav may change links
return <Navigate to="/admin/clanky" replace />;
}
return <Navigate to="/403" replace />;
};
// Premium-aware route elements
const HomeRoute: React.FC = () => {
const { data } = usePublicSettings();
@@ -478,13 +492,22 @@ const App: React.FC = () => {
/>
<Route path="/403" element={<ForbiddenPage />} />
{/* Admin index: allow both admins and editors; decide inside */}
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminIndexRoute />
</ProtectedRoute>
}
/>
{/* Admin area (pages include AdminLayout themselves) */}
<Route element={
<ProtectedRoute requiredRole="admin">
<AdminRoutesWrapper />
</ProtectedRoute>
}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
@@ -508,7 +531,6 @@ const App: React.FC = () => {
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
@@ -573,6 +595,14 @@ const App: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/admin/shortlinks"
element={
<ProtectedRoute requiredRole="editor">
<ShortlinksAdminPage />
</ProtectedRoute>
}
/>
{/* Not found route */}
<Route path="*" element={<NotFoundRoute />} />
+16 -4
View File
@@ -39,7 +39,7 @@ import { ChevronDownIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
import { getUpcomingEvents } from '../../services/eventService';
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation, getEditorAllowedAdminNav } from '../../services/navigation';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
@@ -281,12 +281,24 @@ const AdminSidebar = ({
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// Load dynamic navigation from API (admins only)
// Load dynamic navigation from API
useEffect(() => {
let active = true;
// Editors should not call admin-only navigation endpoint; use fallback
// Editors: load editor-allowed admin navigation
if (!isAdmin) {
setNavLoading(false);
(async () => {
try {
setNavLoading(true);
const editorItems = await getEditorAllowedAdminNav();
if (active) {
setNavItems(editorItems || []);
}
} catch (e) {
if (active) setNavItems([]);
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}
(async () => {
@@ -17,7 +17,7 @@ export interface Banner {
interface BannerDisplayProps {
banners: Banner[];
placement: 'homepage_top' | 'homepage_middle' | 'homepage_sidebar' | 'homepage_footer' | 'article_inline' | 'homepage_under_table';
placement: 'homepage_top' | 'homepage_middle' | 'homepage_footer' | 'article_inline' | 'homepage_under_table';
containerStyle?: React.CSSProperties;
}
@@ -37,8 +37,6 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
return 'banner-top';
case 'homepage_middle':
return 'banner-middle';
case 'homepage_sidebar':
return 'banner-sidebar';
case 'homepage_footer':
return 'banner-footer';
case 'article_inline':
@@ -88,11 +86,6 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
padding: '24px 16px',
borderTop: '1px solid rgba(0, 0, 0, 0.05)',
};
case 'homepage_sidebar':
return {
display: 'block',
margin: '24px 0',
};
case 'homepage_under_table':
return {
...base,
@@ -131,8 +124,8 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
width: banner.width ? `${banner.width}px` : 'auto',
height: banner.height ? `${banner.height}px` : 'auto',
objectFit: 'contain',
borderRadius: placement === 'homepage_sidebar' ? '8px' : '4px',
boxShadow: placement === 'homepage_sidebar' ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
borderRadius: '4px',
boxShadow: 'none',
}}
loading="lazy"
/>
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { API_URL } from '../../services/api';
import { getZoneramaManifestWithFallbacks } from '../../services/zonerama';
import { Link as RouterLink } from 'react-router-dom';
import {
Box,
@@ -94,6 +95,29 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
// Fallback: synthesize albums from manifest/picks when both sources are empty or invalid
if ((!combinedAlbums || combinedAlbums.length === 0)) {
try {
const items = await getZoneramaManifestWithFallbacks();
if (Array.isArray(items) && items.length > 0) {
const byAlbum: Record<string, typeof items> = {} as any;
items.forEach((it) => {
const aid = String(it.album_id || 'unknown');
(byAlbum[aid] = byAlbum[aid] || []).push(it);
});
const synthesized: Album[] = Object.entries(byAlbum).map(([aid, arr]) => ({
id: aid,
title: 'Album',
url: (arr[0] as any).page_url || '#',
date: '',
photos_count: arr.length,
photos: arr.slice(0, 12).map((p: any) => ({ id: String(p.id || ''), page_url: String(p.page_url || ''), image_1500: String(p.src || p.local || '') })),
}));
combinedAlbums = synthesized;
}
} catch {}
}
// Sort by date (newest first)
combinedAlbums.sort((a, b) => {
const parseDate = (dateStr: string) => {
+35 -17
View File
@@ -77,16 +77,14 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
useEffect(() => {
let canceled = false;
const run = async () => {
if (source !== 'auto') return;
const payload = await getCachedYouTube();
if (!payload) return;
// Sort by published_date descending (safety; service should already do this)
const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
if (!canceled) setYt(vids);
};
run();
return () => { canceled = true; };
}, [source]);
}, []);
const extractVideoId = (embedUrl: string): string | undefined => {
if (embedUrl?.includes('/embed/')) {
@@ -96,17 +94,8 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
};
const items: RenderItem[] = useMemo(() => {
if (source === 'auto') {
return (yt || []).slice(0, limit).map(v => ({
key: v.video_id,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
}));
}
// manual fallback from settings or prop
// Build manual items (preferred from videos_items; fallback to legacy URLs)
const manualItems = (() => {
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
@@ -116,7 +105,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
};
} as RenderItem;
});
const legacy = (videos || settings?.videos || []).map((url, i) => {
const embedUrl = toEmbed(url as any);
@@ -125,9 +114,38 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
title: `Video ${i + 1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
};
} as RenderItem;
});
return (manual.length ? manual : legacy).slice(0, limit);
return manual.length ? manual : legacy;
})();
const autoItems = (yt || []).map((v) => ({
key: v.video_id,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
} as RenderItem));
// Combine manual + auto, de-duplicate by videoId/embedUrl/key, sort by date desc, apply limit
const out: RenderItem[] = [];
const seen = new Set<string>();
const pushUnique = (it: RenderItem) => {
const k = (it.videoId || it.embedUrl || it.key);
if (!k) return;
if (seen.has(k)) return;
seen.add(k);
out.push(it);
};
manualItems.forEach(pushUnique);
autoItems.forEach(pushUnique);
const sorted = out.slice().sort((a, b) => {
const ta = Date.parse(a.date || '') || 0;
const tb = Date.parse(b.date || '') || 0;
return tb - ta;
});
return sorted.slice(0, limit);
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit, titleOverrides]);
if (!enabled || items.length === 0) return null;
@@ -12,6 +12,7 @@ export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state
score: isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
name: isFlipped ? state.awayName : state.homeName,
textColor: (isFlipped ? state.awayTextColor : state.homeTextColor) || '#ffffff',
};
const right = {
short: (!isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(!isFlipped ? state.awayName : state.homeName),
@@ -20,21 +21,22 @@ export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state
score: !isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, !isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
name: !isFlipped ? state.awayName : state.homeName,
textColor: (!isFlipped ? state.awayTextColor : state.homeTextColor) || '#ffffff',
};
const timer = state.timer || '00:00';
switch (theme) {
case 'pill':
return (
<Box>
<Box maxW="100%" overflowX="auto">
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
<SegmentScore>{timer}</SegmentScore>
<SegmentTeam colorA={left.color} left>
<SegmentTeam colorA={left.color} textColor={left.textColor} left>
{left.logo ? <Image src={left.logo} alt="home" boxSize="16px" objectFit="contain" /> : null}
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{left.short}</Text>
</SegmentTeam>
<SegmentScore>{left.score} {right.score}</SegmentScore>
<SegmentTeam colorA={right.color} right>
<SegmentTeam colorA={right.color} textColor={right.textColor} right>
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{right.short}</Text>
{right.logo ? <Image src={right.logo} alt="away" boxSize="16px" objectFit="contain" /> : null}
</SegmentTeam>
@@ -124,14 +126,14 @@ export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state
};
// Small presentational helpers for the pill theme
const SegmentTeam: React.FC<{ colorA?: string; left?: boolean; right?: boolean; children: React.ReactNode }> = ({ colorA = '#1e3a8a', left, right, children }) => {
const SegmentTeam: React.FC<{ colorA?: string; textColor?: string; left?: boolean; right?: boolean; children: React.ReactNode }> = ({ colorA = '#1e3a8a', textColor = '#ffffff', left, right, children }) => {
return (
<HStack
px={2}
py={0.5}
borderRadius="full"
bgGradient={`linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`}
color="white"
color={textColor}
spacing={1.5}
minW="46px"
>
@@ -217,4 +219,4 @@ function shadeColor(hex: string, percent: number) {
}
}
export default ScoreboardPreview;
export default React.memo(ScoreboardPreview);
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
import { useToast } from '@chakra-ui/react';
import { getImageUrl } from '../../utils/imageUtils';
const fmtDate = (iso?: string | null) => {
if (!iso) return '';
@@ -61,6 +62,8 @@ const SweepstakeWidget: React.FC = () => {
if (loading) return null;
if (!s) return null;
// Hide finalized widget for users who are not winners
if ((data?.state || 'upcoming') === 'finalized' && !iWon) return null;
const onJoin = async () => {
if (!s) return;
@@ -95,12 +98,12 @@ const SweepstakeWidget: React.FC = () => {
<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>)}
{s.rules_url && (<a href={getImageUrl(s.rules_url) || 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 }} />
<img src={getImageUrl(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>
@@ -122,12 +125,12 @@ const SweepstakeWidget: React.FC = () => {
<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>)}
{s.rules_url && (<a href={getImageUrl(s.rules_url) || 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 }} />
<img src={getImageUrl(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>
@@ -142,12 +145,12 @@ const SweepstakeWidget: React.FC = () => {
</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>
) : (
) : (data?.can_enter ?? false) ? (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Vstupuji…' : 'Vstoupit'}
</button>
) : (
<span style={{ fontWeight: 600 }}> jste registrováni v soutěži </span>
)}
</div>
</div>
@@ -157,7 +160,7 @@ const SweepstakeWidget: React.FC = () => {
<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>)}
{s.rules_url && (<a href={getImageUrl(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>
@@ -189,7 +192,12 @@ const SweepstakeWidget: React.FC = () => {
))}
</div>
{iWon && (
<div style={{ marginTop: 8, fontWeight: 700 }}>Gratulujeme! Tato stránka rozpoznala, že patříte mezi výherce.</div>
<div style={{ marginTop: 8 }}>
<div style={{ fontWeight: 800 }}>Vyhráli jste! Více informací najdete ve svém e-mailu.</div>
<div style={{ marginTop: 6 }}>
Pokud potřebujete pomoc, <a href={"/kontakt?subject=" + encodeURIComponent("Soutěž výhra: " + (s.title || ''))} className="see-all">kontaktujte nás</a>.
</div>
</div>
)}
</div>
)}
+25 -19
View File
@@ -1248,9 +1248,23 @@ const ArticleDetailPage: React.FC = () => {
<Widget title="Nejbližší aktivity">
<VStack spacing={3} align="stretch">
{items.map((ev: any) => (
<HStack key={ev.id} as={RouterLink} to={`/aktivita/${ev.id}`} _hover={{ textDecoration: 'none' }} align="flex-start" spacing={3}>
<HStack
key={ev.id}
as={RouterLink}
to={`/aktivita/${ev.id}`}
align="flex-start"
spacing={3}
px={3}
py={2}
borderWidth="1px"
borderRadius="md"
borderColor={galleryBorder}
bg={attachmentsBg}
style={{ borderLeftWidth: 4, borderLeftColor: '#3182ce' }}
_hover={{ textDecoration: 'none', bg: miniHoverBg, borderColor: 'blue.300', boxShadow: 'sm', transform: 'translateX(2px)' }}
>
<Box flex={1} minW={0}>
<Text fontWeight="600" noOfLines={2}>{ev.title}</Text>
<Text fontWeight="700" noOfLines={2}>{ev.title}</Text>
<Text fontSize="sm" color={textMuted}>
{(() => { try { const d = new Date(ev.start_time); return d.toLocaleDateString('cs-CZ') + (ev.location ? `${ev.location}` : ''); } catch { return ev.start_time; } })()}
</Text>
@@ -1262,18 +1276,9 @@ const ArticleDetailPage: React.FC = () => {
);
})()}
{/* Polls in sidebar (no duplicate heading, keep wrapper styling) */}
{/* Polls in sidebar (render only when polls exist; internal wrapper handles layout) */}
{(data as any)?.id && (
<Box
bg={cardBg}
p={4}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor={galleryBorder}
>
<EmbeddedPoll articleId={(data as any).id} maxPolls={2} showTitle={false} unstyled />
</Box>
<EmbeddedPoll articleId={(data as any).id} maxPolls={2} showTitle={false} />
)}
{/* Attachments in sidebar */}
@@ -1281,12 +1286,13 @@ const ArticleDetailPage: React.FC = () => {
<Widget title="Přílohy">
<VStack align="stretch" spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between" align="center">
<Box flex={1} minW={0} mr={2}>
<Text noOfLines={1}>{f.name || f.url}</Text>
</Box>
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} buttonOnly />
</HStack>
<FilePreview
key={idx}
url={assetUrl(f.url) || f.url}
name={f.name || ''}
mimeType={f.mime_type || ''}
size={f.size}
/>
))}
</VStack>
</Widget>
+40 -39
View File
@@ -102,6 +102,7 @@ const HomePage: React.FC = () => {
// Index for the NEXT MATCH competition carousel
const [nextCompIdx, setNextCompIdx] = useState<number>(0);
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
const [sidebarTop, setSidebarTop] = useState<number>(112);
// Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors
@@ -154,6 +155,19 @@ const HomePage: React.FC = () => {
} catch {}
}, []);
useEffect(() => {
const updateTop = () => {
try {
const hdr = (document.querySelector('header[data-element="header"]') as HTMLElement) || (document.querySelector('header') as HTMLElement);
const h = hdr ? Math.round(hdr.getBoundingClientRect().height) : 96;
setSidebarTop(Math.max(64, h + 16));
} catch {}
};
updateTop();
window.addEventListener('resize', updateTop);
return () => window.removeEventListener('resize', updateTop);
}, [refreshKey]);
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
id: typeof item.id === 'number' ? item.id : index,
title: item.title,
@@ -281,6 +295,8 @@ const HomePage: React.FC = () => {
facrTablesJSON,
teamLogoOverridesAPI,
teamLogoOverridesFile,
matchesApiJSON,
matchesPastApiJSON,
] = await Promise.all([
fetchJSON('/cache/prefetch/articles.json'),
fetchJSON('/cache/prefetch/matches.json'),
@@ -291,6 +307,8 @@ const HomePage: React.FC = () => {
fetchJSON(`/api/v1/public/team-logo-overrides?t=${Date.now()}`),
// Fallback to cached JSON snapshot written by backend after saves
fetchJSON('/cache/prefetch/team_logo_overrides.json'),
fetchJSON(`/api/v1/matches?t=${Date.now()}`),
fetchJSON(`/api/v1/matches/history?t=${Date.now()}`),
]);
// load aliases (public)
let aliasesList: CompetitionAlias[] = [];
@@ -348,6 +366,19 @@ const HomePage: React.FC = () => {
return chosen;
};
// Build score overrides map from public API
const scoreOverrideMap: Record<string, string> = {};
const addScores = (arr: any[]) => {
if (!Array.isArray(arr)) return;
for (const it of arr) {
const id = String(it?.match_id || it?.id || '').trim();
const sc = String(it?.score || '').trim();
if (id && sc) scoreOverrideMap[id] = sc;
}
};
addScores(matchesApiJSON as any[]);
addScores(matchesPastApiJSON as any[]);
// Matches: map from FACR club info if available, otherwise fallback to matches.json
if (facrClubJSON?.competitions?.length) {
const allMatches = (facrClubJSON.competitions || [])
@@ -359,6 +390,8 @@ const HomePage: React.FC = () => {
const [day, month, year] = d.split('.');
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '18:00').slice(0,5);
const mid = String(m.match_id || '').trim();
const score = (mid && scoreOverrideMap[mid]) ? scoreOverrideMap[mid] : m.score;
return {
id: m.match_id || idx + 1,
homeTeam: m.home,
@@ -370,7 +403,7 @@ const HomePage: React.FC = () => {
isHome: facrClubJSON?.name ? (m.home || '').toLowerCase().includes(String(facrClubJSON.name).toLowerCase()) : true,
homeLogoURL: getOverrideLogo(m.home, m.home_logo_url),
awayLogoURL: getOverrideLogo(m.away, m.away_logo_url),
score: m.score,
score,
facr_link: m.facr_link,
report_url: m.report_url,
};
@@ -403,6 +436,8 @@ const HomePage: React.FC = () => {
const [day, month, year] = (d || '').split('.');
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '18:00').slice(0,5);
const mid = String(m.match_id || '').trim();
const score = (mid && scoreOverrideMap[mid]) ? scoreOverrideMap[mid] : m.score;
return {
id: m.match_id || idx + 1,
date: isoDate,
@@ -413,7 +448,7 @@ const HomePage: React.FC = () => {
away_id: m.away_id,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
score: m.score,
score,
facr_link: m.facr_link,
report_url: m.report_url,
venue: m.venue || '',
@@ -1497,39 +1532,7 @@ const HomePage: React.FC = () => {
{/* Featured articles are now shown in the hero grid above, not here */}
{/* Sidebar banners (homepage_sidebar) - sticky within page container */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
<section
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
data-element="sidebar"
data-variant={getVariant('sidebar', 'right')}
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
style={{ margin: '24px 0', ...getStyles('sidebar') }}
>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div
style={{
position: 'sticky',
top: 112,
width: 320,
maxWidth: '100%',
marginLeft: getVariant('sidebar', 'right') === 'left' ? 0 : 'auto',
marginRight: getVariant('sidebar', 'right') === 'left' ? 'auto' : 0,
zIndex: 1,
}}
>
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, padding: 4 }}>
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
</a>
</div>
))}
</div>
</div>
</section>
)}
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
<Suspense fallback={<div style={{ minHeight: 240 }} />}>
@@ -1828,7 +1831,7 @@ const HomePage: React.FC = () => {
)}
{/* Gallery */}
{isVisible('gallery', false) && (
{isVisible('gallery', true) && (
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} aria-labelledby="home-gallery-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
@@ -1898,9 +1901,7 @@ const HomePage: React.FC = () => {
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<div className="card">
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
<PollsWidget featuredOnly={false} maxPolls={1} title="Anketa" />
</Suspense>
) : (
<div className="card skeleton" style={{ height: 320, borderRadius: 12 }} />
+34 -19
View File
@@ -85,10 +85,6 @@ const VideosPage: React.FC = () => {
useEffect(() => {
let canceled = false;
const run = async () => {
if (source !== 'auto') {
setLoading(false);
return;
}
try {
const payload = await getCachedYouTube();
if (!payload) {
@@ -109,20 +105,11 @@ const VideosPage: React.FC = () => {
return () => {
canceled = true;
};
}, [source]);
}, []);
const items: RenderItem[] = useMemo(() => {
if (source === 'auto') {
return (yt || []).map((v) => ({
key: v.video_id,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
}));
}
// Manual fallback from settings
// Build manual items (preferred) with legacy fallback
const manualItems = (() => {
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
@@ -132,7 +119,7 @@ const VideosPage: React.FC = () => {
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
};
} as RenderItem;
});
const legacy = ((settings as any)?.videos || []).map((url: string, i: number) => {
const embedUrl = toEmbed(url);
@@ -141,10 +128,38 @@ const VideosPage: React.FC = () => {
title: `Video ${i + 1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
};
} as RenderItem;
});
return manual.length ? manual : legacy;
}, [source, yt, settings?.videos_items, settings, titleOverrides]);
})();
const autoItems = (yt || []).map((v) => ({
key: v.video_id,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
} as RenderItem));
const out: RenderItem[] = [];
const seen = new Set<string>();
const pushUnique = (it: RenderItem) => {
const k = it.videoId || it.embedUrl || it.key;
if (!k) return;
if (seen.has(k)) return;
seen.add(k);
out.push(it);
};
manualItems.forEach(pushUnique);
autoItems.forEach(pushUnique);
// Sort by date desc so manual additions integrate among auto
const sorted = out.slice().sort((a, b) => {
const ta = Date.parse(a.date || '') || 0;
const tb = Date.parse(b.date || '') || 0;
return tb - ta;
});
return sorted;
}, [yt, settings?.videos_items, (settings as any)?.videos, titleOverrides]);
const openVideo = (item: RenderItem) => {
setSelectedVideo(item);
+212 -242
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Alert, AlertIcon, Badge, Tooltip, Checkbox, Image, Spinner, Link, Switch, ButtonGroup } from '@chakra-ui/react';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Alert, AlertIcon, Badge, Checkbox, Image, Spinner, Link, Switch, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, Tabs, TabList, TabPanels, Tab, TabPanel } from '@chakra-ui/react';
import { getAdminSettings, updateAdminSettings, AdminSettings } from '../../services/settings';
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
import { triggerPrefetch } from '../../services/admin/prefetch';
@@ -14,15 +14,16 @@ export type AdminVideoItem = {
thumbnail_url?: string;
};
const emptyItem: AdminVideoItem = { url: '' };
//
const AdminVideosPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [items, setItems] = useState<AdminVideoItem[]>([]);
const [videosSource, setVideosSource] = useState<'auto'|'manual'>('manual');
const videosSource: 'auto' = 'auto';
const [videosEnabled, setVideosEnabled] = useState<boolean>(true);
const toast = useToast();
const { isOpen: isAddOpen, onOpen: onOpenAdd, onClose: onCloseAdd } = useDisclosure();
// YouTube Scraper API integration state
const [channelInput, setChannelInput] = useState<string>('');
@@ -47,6 +48,7 @@ const AdminVideosPage: React.FC = () => {
const [filter, setFilter] = useState<string>('');
// Title overrides for auto mode (video_id -> title)
const [titleOverrides, setTitleOverrides] = useState<Record<string, string>>({});
const [directUrl, setDirectUrl] = useState<string>('');
// Derived flags
const hasChannel = useMemo(() => (channelInput || '').trim().length > 0, [channelInput]);
@@ -60,8 +62,7 @@ const AdminVideosPage: React.FC = () => {
const vids = Array.isArray((s as any).videos_items) ? (s as any).videos_items as AdminVideoItem[] : [];
const legacy = Array.isArray((s as any).videos) ? ((s as any).videos as string[]).map((url) => ({ url })) : [];
setItems(vids.length ? vids : legacy);
const src = (s as any).videos_source;
if (src === 'auto' || src === 'manual') setVideosSource(src);
// Force automatic source; manual editing is removed in favor of inline add/import
// Default enable if not explicitly set and there are any videos configured
const explicit = (s as any).videos_module_enabled;
const hasAny = (vids.length + legacy.length) > 0;
@@ -80,12 +81,11 @@ const AdminVideosPage: React.FC = () => {
return () => { mounted = false; };
}, []);
// Load cached YouTube videos for preview when auto source is active
// Load cached YouTube videos for preview
useEffect(() => {
let mounted = true;
const run = async () => {
if (loading) return;
if (videosSource !== 'auto') return;
setAutoError('');
setAutoLoading(true);
try {
@@ -101,7 +101,70 @@ const AdminVideosPage: React.FC = () => {
};
run();
return () => { mounted = false; };
}, [loading, videosSource]);
}, [loading]);
type PreviewItem = {
key: string;
title: string;
thumbnail_url?: string;
published_date?: string;
video_id?: string;
source: 'manual'|'auto';
url?: string;
};
// Combined preview for AUTO mode: manual + auto (dedup), filtered by title, ordered by date desc
const combinedAutoPreview = useMemo(() => {
const manual: PreviewItem[] = (items || []).filter(it => (it.url || '').trim().length > 0).map((it, idx) => {
let id: string | undefined;
try {
const u = (it.url || '').trim();
if (u.includes('youtu.be/')) {
id = u.split('youtu.be/')[1]?.split(/[?&#]/)[0];
} else if (u.includes('youtube.com')) {
const url = new URL(u);
id = url.searchParams.get('v') || undefined;
}
} catch {}
return {
key: `m-${idx}-${it.url}`,
title: it.title || `Video ${idx + 1}`,
thumbnail_url: it.thumbnail_url,
published_date: it.uploaded_at,
video_id: id,
source: 'manual',
url: it.url,
} as PreviewItem;
});
const auto: PreviewItem[] = (autoVideos || []).map((v) => ({
key: `a-${v.video_id}`,
title: v.title,
thumbnail_url: v.thumbnail_url,
published_date: v.published_date,
video_id: v.video_id,
source: 'auto',
}));
const out: PreviewItem[] = [];
const seen = new Set<string>();
const pushUnique = (it: PreviewItem) => {
const k = it.video_id || it.url || it.key;
if (!k) return;
if (seen.has(k)) return;
seen.add(k);
out.push(it);
};
manual.forEach(pushUnique);
auto.forEach(pushUnique);
const filtered = out
.filter((it) => (it.title || '').toLowerCase().includes(filter.toLowerCase()))
.slice()
.sort((a, b) => {
const ta = Date.parse(a.published_date || '') || 0;
const tb = Date.parse(b.published_date || '') || 0;
return tb - ta;
});
return { list: filtered, count: filtered.length };
}, [items, autoVideos, filter]);
// Auto-disable videos module if there is neither channel nor manual items configured
useEffect(() => {
@@ -114,7 +177,6 @@ const AdminVideosPage: React.FC = () => {
// Auto-trigger backend prefetch of YouTube cache at most once per ~24h
useEffect(() => {
if (loading) return;
if (videosSource !== 'auto') return;
const channel = (channelInput || '').trim();
if (!channel) return;
const KEY = 'youtube_autoload_last';
@@ -205,45 +267,49 @@ const AdminVideosPage: React.FC = () => {
thumbnail_url: v.thumbnail_url,
}));
// Avoid duplicates by URL
setItems((prev) => {
const urls = new Set(prev.map((p) => p.url));
const merged = [...prev];
const merged = (() => {
const urls = new Set(items.map((p) => p.url));
const out = [...items];
for (const it of newItems) {
if (!urls.has(it.url)) {
merged.push(it);
out.push(it);
urls.add(it.url);
}
}
return merged;
});
// If currently in auto mode, switch to manual so the preview reflects newly added items
if (videosSource !== 'manual') {
setVideosSource('manual');
return out;
})();
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'info', title: 'Přepnuto na ruční správu', description: 'Nově přidaná videa se budou používat. Nezapomeňte uložit seznam.', duration: 3500 });
} catch {
// ignore
}
}
await updateAdminSettings({ videos_items: merged, videos_module_enabled: videosEnabled });
setItems(merged);
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přidaná videa.' });
}
};
const addItem = async () => {
setItems((prev) => [...prev, { ...emptyItem }]);
if (videosSource !== 'manual') {
setVideosSource('manual');
const addDirectLink = async () => {
const url = (directUrl || '').trim();
if (!url) {
toast({ status: 'warning', title: 'Zadejte odkaz', description: 'Vložte URL videa.' });
return;
}
const today = new Date().toISOString().slice(0,10);
const it: AdminVideoItem = { url, uploaded_at: today, thumbnail_url: getThumbFromUrl(url) };
if (items.find((p) => p.url === it.url)) {
toast({ status: 'info', title: 'Video už existuje', description: 'Tento odkaz je již v seznamu.' });
return;
}
const merged = [...items, it];
try {
await updateAdminSettings({ videos_source: 'manual' });
await updateAdminSettings({ videos_items: merged, videos_module_enabled: videosEnabled });
setItems(merged);
setDirectUrl('');
toast({ status: 'success', title: 'Video přidáno', description: 'Video bylo přidáno k automatickým videím.' });
} catch {
// ignore
}
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit video.' });
}
};
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => {
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
};
//
const save = async () => {
setSaving(true);
@@ -258,12 +324,7 @@ const AdminVideosPage: React.FC = () => {
}
};
const setDateQuick = (idx: number, daysAgo: number) => {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
const iso = d.toISOString().slice(0,10);
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, uploaded_at: iso } : it));
};
//
// Helper: derive YouTube thumbnail safely from a URL (supports youtube.com and youtu.be)
const getThumbFromUrl = (raw: string): string | undefined => {
@@ -298,36 +359,8 @@ const AdminVideosPage: React.FC = () => {
{/* Source toggle */}
<HStack justify="space-between" mb={3} flexWrap="wrap">
<HStack>
<Text fontWeight="semibold">Zdroj videí:</Text>
<ButtonGroup size="sm" isAttached>
<Button
variant={videosSource === 'auto' ? 'solid' : 'outline'}
onClick={async () => {
if (videosSource === 'auto') return;
setVideosSource('auto');
try {
await updateAdminSettings({ videos_source: 'auto' });
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa se načítají automaticky z YouTube.', duration: 2500 });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
}
}}
>Automaticky</Button>
<Button
variant={videosSource === 'manual' ? 'solid' : 'outline'}
onClick={async () => {
if (videosSource === 'manual') return;
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa spravujete ručně.', duration: 2500 });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
}
}}
>Ručně</Button>
</ButtonGroup>
<HStack spacing={2}>
<Button leftIcon={<FiPlus />} colorScheme="green" size="sm" onClick={onOpenAdd}>Přidat video</Button>
</HStack>
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel mb={0}>Zobrazit sekci Videa na titulní stránce</FormLabel>
@@ -363,57 +396,11 @@ const AdminVideosPage: React.FC = () => {
{videosSource === 'auto' && (
<Alert status="info" mb={3} borderRadius="md">
<AlertIcon />
Automatický režim je zapnutý. Videa se načítají z YouTube kanálu z Nastavení Sociální sítě (YouTube URL) a správy Videa (YouTube modul). Manuální seznam je v tomto režimu skryt.
Automatický režim je zapnutý. Videa se načítají z YouTube kanálu z Nastavení Sociální sítě (YouTube URL). Ručně přidaná videa se zobrazí před automatickými.
</Alert>
)}
{videosSource !== 'auto' && (
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
<Heading size="sm" mb={2}>Import z YouTube kanálu</Heading>
<Text fontSize="sm" color="gray.600" mb={3}>
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa.
Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link>
</Text>
<HStack align="start" spacing={3} flexWrap="wrap">
<FormControl maxW={{ base: '100%', md: '400px' }}>
<FormLabel>Kanál (handle nebo URL)</FormLabel>
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
</FormControl>
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
</HStack>
{ytError && (
<Alert status="error" mt={3} borderRadius="md">
<AlertIcon />
{ytError}
</Alert>
)}
{ytLoading && (
<HStack mt={3} color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
)}
{!ytLoading && ytVideos.length > 0 && (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3} mt={3}>
{ytVideos.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
<Checkbox isChecked={!!selectedIds[v.video_id]} onChange={() => toggleSelect(v.video_id)}>
Vybrat
</Checkbox>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.length && <Badge>{v.length}</Badge>}
{v.published_text && <Text>{v.published_text}</Text>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
)}
</Box>
)}
{/* Always-visible preview of effective videos */}
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
@@ -436,28 +423,26 @@ const AdminVideosPage: React.FC = () => {
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase())).length}</Text>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {combinedAutoPreview.count}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{autoVideos
.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase()))
.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
{combinedAutoPreview.list.map((it) => (
<Box key={it.key} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image
src={v.thumbnail_url}
alt={v.title}
src={it.thumbnail_url || (it.source === 'manual' ? getThumbFromUrl(it.url || '') : undefined)}
alt={it.title}
borderRadius="md"
data-fallback-idx={0 as any}
onError={(e) => {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idx = Number(el.dataset.fallbackIdx || '0');
const id = v.video_id;
const chain = [
const id = it.video_id || '';
const chain = id ? [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/images/sponsors/placeholder.png',
];
] : ['/images/sponsors/placeholder.png'];
if (idx < chain.length) {
el.src = chain[idx];
el.dataset.fallbackIdx = String(idx + 1);
@@ -465,157 +450,142 @@ const AdminVideosPage: React.FC = () => {
}}
/>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack justify="space-between" align="start">
<Box flex="1">
<Text fontWeight="semibold" noOfLines={2}>{(it.source === 'auto' && it.video_id && (titleOverrides[it.video_id]?.trim()?.length ? titleOverrides[it.video_id] : it.title)) || it.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.published_date && <Badge>{new Date(v.published_date).toLocaleDateString('cs-CZ')}</Badge>}
{it.published_date && <Badge>{new Date(it.published_date).toLocaleDateString('cs-CZ')}</Badge>}
{it.source === 'manual' && <Badge colorScheme="purple">Ručně</Badge>}
</HStack>
</Box>
{it.source === 'manual' && (
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={async () => {
const next = items.filter((m) => m.url !== it.url);
try {
await updateAdminSettings({ videos_items: next, videos_module_enabled: videosEnabled });
setItems(next);
toast({ status: 'success', title: 'Smazáno', description: 'Video bylo odstraněno.' });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Odstranění se nepodařilo.' });
}
}}
/>
)}
</HStack>
{it.source === 'auto' && it.video_id && (
<FormControl mt={2}>
<FormLabel fontSize="xs" mb={1}>Přepis názvu (volitelné)</FormLabel>
<Input
size="sm"
placeholder="Např. Zápas A-týmu vs. B-tým"
value={(titleOverrides[v.video_id] ?? '')}
placeholder="Např. Zápas Atýmu vs. Btým"
value={titleOverrides[it.video_id] ?? ''}
onChange={(e) => {
const val = e.target.value;
setTitleOverrides(prev => ({ ...prev, [v.video_id]: val }));
setTitleOverrides(prev => ({ ...prev, [it.video_id!]: val }));
}}
/>
</FormControl>
{!!(titleOverrides[v.video_id]?.length) && (
<HStack justify="flex-end" mt={1}>
<Button size="xs" variant="ghost" onClick={() => setTitleOverrides(prev => { const n = { ...prev }; delete n[v.video_id]; return n; })}>Vymazat přepis</Button>
</HStack>
)}
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
{autoVideos.length === 0 && (
<Text color="gray.600">Žádná videa v cache. Zkontrolujte YouTube URL v nastavení a použijte Aktualizovat cache.</Text>
)}
</>
)}
</>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {items.length}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{items.map((it, idx) => (
<Box key={`${idx}-${it.url}`} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image
src={it.thumbnail_url || getThumbFromUrl(it.url)}
alt={it.title || `Video ${idx+1}`}
borderRadius="md"
data-fallback-idx={0 as any}
onError={(e) => {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idxFb = Number(el.dataset.fallbackIdx || '0');
// Try to parse video id from URL; fallback to placeholder
let id: string | undefined;
try {
const u = (it.url || '').trim();
if (u.includes('youtu.be/')) {
id = u.split('youtu.be/')[1]?.split(/[?&#]/)[0];
} else if (u.includes('youtube.com')) {
const url = new URL(u);
id = url.searchParams.get('v') || undefined;
}
} catch {}
const chain = id ? [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/images/sponsors/placeholder.png',
] : ['/images/sponsors/placeholder.png'];
if (idxFb < chain.length) {
el.src = chain[idxFb];
el.dataset.fallbackIdx = String(idxFb + 1);
}
}}
/>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{it.title || `Video ${idx+1}`}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{it.uploaded_at && <Badge>{(new Date(it.uploaded_at)).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
{items.length === 0 && (
{combinedAutoPreview.count === 0 && (
<Text color="gray.600">Zatím žádná videa.</Text>
)}
</>
)}
</>
) : null}
</Box>
<HStack justify="space-between" mb={3}>
<Button leftIcon={<FiPlus />} onClick={addItem}>Přidat video</Button>
<Button colorScheme="blue" leftIcon={<FiSave />} onClick={save} isLoading={saving}>Uložit</Button>
</HStack>
<Divider my={3} />
{loading ? (
<Text>Načítání</Text>
) : videosSource === 'auto' ? (
<Text color="gray.600">Automatický zdroj videí je aktivní. Pro ruční správu přepněte zdroj na Ručně.</Text>
) : (
<VStack align="stretch" spacing={4}>
{items.map((it, idx) => (
<Box key={idx} borderWidth="1px" borderRadius="md" p={3}>
<HStack justify="space-between">
<Heading size="sm">Video #{idx + 1}</Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} mt={3}>
<Modal isOpen={isAddOpen} onClose={onCloseAdd} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Přidat video</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Tabs variant="enclosed">
<TabList>
<Tab>Odkaz na video</Tab>
<Tab>Načíst z YouTube kanálu</Tab>
</TabList>
<TabPanels>
<TabPanel>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>URL videa</FormLabel>
<Input value={it.url} onChange={(e) => updateField(idx, 'url', e.target.value)} placeholder="https://www.youtube.com/watch?v=..." />
<Input placeholder="https://www.youtube.com/watch?v=..." value={directUrl} onChange={(e) => setDirectUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Thumbnail (volitelné)</FormLabel>
<Input value={it.thumbnail_url || ''} onChange={(e) => updateField(idx, 'thumbnail_url', e.target.value)} placeholder="https://example.com/thumb.jpg" />
</FormControl>
<FormControl>
<FormLabel>Název (volitelné)</FormLabel>
<Input value={it.title || ''} onChange={(e) => updateField(idx, 'title', e.target.value)} placeholder="Titulek videa" />
</FormControl>
<FormControl>
<FormLabel>Délka (volitelné)</FormLabel>
<Input value={it.length || ''} onChange={(e) => updateField(idx, 'length', e.target.value)} placeholder="3:45" />
</FormControl>
<FormControl>
<FormLabel>Datum nahrání (volitelné)</FormLabel>
<HStack>
<Input type="date" value={(it.uploaded_at || '').slice(0,10)} onChange={(e) => updateField(idx, 'uploaded_at', e.target.value)} />
<Tooltip label="Dnes">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 0)}>Dnes</Button>
</Tooltip>
<Tooltip label="Včera">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 1)}>Včera</Button>
</Tooltip>
<Tooltip label="Před týdnem">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 7)}>7 dní</Button>
</Tooltip>
<Tooltip label="Vymazat datum">
<Button size="sm" variant="ghost" onClick={() => updateField(idx, 'uploaded_at', '')}>Vymazat</Button>
</Tooltip>
<Button colorScheme="green" onClick={async () => { await addDirectLink(); onCloseAdd(); }}>Přidat</Button>
</HStack>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={3}>
<Text fontSize="sm" color="gray.600">
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa. Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">youtube.tdvorak.dev</Link>
</Text>
<HStack align="start" spacing={3} flexWrap="wrap">
<FormControl maxW={{ base: '100%', md: '400px' }}>
<FormLabel>Kanál (handle nebo URL)</FormLabel>
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
</FormControl>
</SimpleGrid>
<HStack justify="flex-end" mt={2}>
<IconButton aria-label="Smazat" icon={<FiTrash2 />} onClick={() => removeItem(idx)} variant="outline" colorScheme="red" />
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
<Button colorScheme="green" onClick={async () => { await importSelected(); onCloseAdd(); }} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
</HStack>
{ytError && (
<Alert status="error" borderRadius="md">
<AlertIcon />
{ytError}
</Alert>
)}
{ytLoading && (
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
)}
{!ytLoading && ytVideos.length > 0 && (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{ytVideos.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
<Checkbox isChecked={!!selectedIds[v.video_id]} onChange={() => toggleSelect(v.video_id)}>
Vybrat
</Checkbox>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.length && <Badge>{v.length}</Badge>}
{v.published_text && <Text>{v.published_text}</Text>}
</HStack>
</Box>
</VStack>
</Box>
))}
{items.length === 0 && (
<Text color="gray.600">Zatím žádná videa. Použijte tlačítko Přidat video.</Text>
</SimpleGrid>
)}
</VStack>
)}
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onCloseAdd}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
+1 -10
View File
@@ -15,7 +15,7 @@ type BannerPreset = {
width: number;
height: number;
aspectRatio: number;
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article' | 'under_table';
position: 'top' | 'middle' | 'footer' | 'article' | 'under_table';
};
const BANNER_PRESETS: BannerPreset[] = [
@@ -28,15 +28,6 @@ const BANNER_PRESETS: BannerPreset[] = [
aspectRatio: 3.88,
position: 'middle'
},
{
value: 'homepage_sidebar',
label: 'Postranní banner (Homepage - okraj obrazovky)',
description: 'Menší banner ukotvený u levého/pravého okraje obrazovky (nastavitelné v editoru: Sidebar varianta vlevo/vpravo)',
width: 300,
height: 250,
aspectRatio: 1.2,
position: 'sidebar'
},
{
value: 'homepage_footer',
label: 'Spodní banner (Homepage - zápatí)',
+223 -225
View File
@@ -37,6 +37,8 @@ import {
ModalBody,
ModalFooter,
ModalCloseButton,
Checkbox,
CheckboxGroup,
Wrap,
WrapItem,
} from '@chakra-ui/react';
@@ -58,6 +60,11 @@ import {
} from '../../services/admin/engagement';
import { FiTrash2, FiEdit2 } from 'react-icons/fi';
import api from '../../services/api';
import { assetUrl } from '../../utils/url';
// Quick presets for sizes and colors
const SIZE_OPTIONS = ['XS','S','M','L','XL','XXL','XXXL','UNI'];
const COLOR_OPTIONS = ['Černá','Bílá','Modrá','Červená','Zelená','Žlutá','Oranžová','Fialová','Šedá','Růžová','Hnědá','Navy','Béžová','Tyrkysová','Vínová'];
const EngagementAdminPage: React.FC = () => {
const toast = useToast();
@@ -77,30 +84,25 @@ const EngagementAdminPage: React.FC = () => {
const [form, setForm] = React.useState({
name: '',
type: 'avatar_static',
type: 'merch_digital',
cost_points: 50,
image_url: '',
stock: -1,
active: true,
});
// Create form helpers
const [validUnlimited, setValidUnlimited] = React.useState<boolean>(true);
const [sizeList, setSizeList] = React.useState<string[]>([]);
const [colorList, setColorList] = React.useState<string[]>([]);
const [sizeCustom, setSizeCustom] = React.useState<string>('');
const [colorCustom, setColorCustom] = React.useState<string>('');
const [editItem, setEditItem] = React.useState<AdminRewardItem | null>(null);
const editModal = useDisclosure();
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
// Remove raw JSON editing, keep structured metadata only
const batchEnabled = false;
const [batch, setBatch] = React.useState({
base_url: '',
name_prefix: 'Avatar',
count: 5,
start_index: 1,
type: 'avatar_static' as string,
cost_points: 50,
stock: -1,
active: true,
});
const batchModal = useDisclosure();
// Structured metadata state (used for merch types, coupons, etc.)
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [meta, setMeta] = React.useState<Record<string, any>>({});
@@ -119,33 +121,7 @@ const EngagementAdminPage: React.FC = () => {
return m;
}, [usersQ.data]);
// Reward template selector instead of many buttons
const [template, setTemplate] = React.useState<string>('avatar_upload_unlock');
const applyTemplate = (tpl: string) => {
setTemplate(tpl);
switch (tpl) {
case 'avatar_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_upload_unlock', cost_points: 50, stock: -1, image_url: '' }));
break;
case 'avatar_animated_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_animated_upload_unlock', cost_points: 150, stock: 0 }));
break;
case 'avatar_static_50':
setForm((prev) => ({ ...prev, type: 'avatar_static', cost_points: 50 }));
break;
case 'merch_coupon_1000':
setForm((prev) => ({ ...prev, type: 'merch_coupon', cost_points: 1000 }));
break;
case 'merch_coupon_2000':
setForm((prev) => ({ ...prev, type: 'merch_coupon', cost_points: 2000 }));
break;
case 'merch_physical_4000':
setForm((prev) => ({ ...prev, type: 'merch_physical', cost_points: 4000, stock: 1 }));
break;
default:
break;
}
};
// Removed reward templates UI
const handleUpload = async (file?: File) => {
try {
@@ -190,12 +166,18 @@ const EngagementAdminPage: React.FC = () => {
const createMut = useMutation({
mutationFn: async () => {
// Auto-generate metadata from structured fields
const metadata = Object.keys(meta).length ? meta : undefined;
// Build metadata including structured helpers
const md: Record<string, any> = { ...(Object.keys(meta).length ? meta : {}) };
if (validUnlimited) { delete md.valid_from; delete md.valid_to; }
if (sizeList.length) md.size = sizeList.join(',');
if (colorList.length) md.color = colorList.join(',');
const metadata = Object.keys(md).length ? md : undefined;
return adminCreateReward({ ...form, metadata });
},
onSuccess: async () => {
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: -1, active: true });
setForm({ name: '', type: 'merch_digital', cost_points: 50, image_url: '', stock: -1, active: true });
setValidUnlimited(true);
setSizeList([]); setColorList([]); setSizeCustom(''); setColorCustom(''); setMeta({});
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
toast({ status: 'success', title: 'Odměna vytvořena' });
},
@@ -217,32 +199,7 @@ 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 || [];
@@ -316,26 +273,7 @@ const EngagementAdminPage: React.FC = () => {
<Box>
<Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading>
<VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}>
<Wrap spacing={2}>
<WrapItem>
<FormControl>
<FormLabel m={0} fontSize="sm">Šablona odměny</FormLabel>
<Select size="sm" maxW="280px" value={template} onChange={(e)=>applyTemplate(e.target.value)}>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru (50b)</option>
<option value="avatar_animated_upload_unlock">Odemknutí animovaného avataru (150b)</option>
<option value="avatar_static_50">Avatar (statický) 50b</option>
<option value="merch_coupon_1000">Merch kupon (1000b)</option>
<option value="merch_coupon_2000">Merch kupon (2000b)</option>
<option value="merch_physical_4000">Fyzická odměna (4000b)</option>
</Select>
</FormControl>
</WrapItem>
<WrapItem>
{batchEnabled && (
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
)}
</WrapItem>
</Wrap>
{/* Šablony odměn odstraněny */}
<HStack align="start" spacing={4}>
<VStack align="stretch" spacing={3} flex={1}>
<FormControl>
@@ -345,13 +283,9 @@ const EngagementAdminPage: React.FC = () => {
<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_digital">Digitální odměna</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>
@@ -364,36 +298,48 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} </FormHelperText>
</FormControl>
{(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && (
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<FormLabel>Množství/Sklad</FormLabel>
<HStack>
<NumberInput value={form.stock} min={-1} isDisabled={form.stock === -1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
</NumberInput>
</FormControl>
)}
<HStack>
<Text fontSize="sm">Neomezeně</Text>
<Switch isChecked={form.stock === -1} onChange={(e)=> setForm(prev => ({ ...prev, stock: e.target.checked ? -1 : Math.max(0, Number(prev.stock) === -1 ? 0 : Number(prev.stock)||0) }))} />
</HStack>
</HStack>
<FormHelperText>-1 = neomezeně, 0 = dočasně vyprodáno. Sklad platí pro ne-avatarové odměny.</FormHelperText>
</FormControl>
</HStack>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<>
<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.</FormHelperText>
<Input placeholder="/uploads/… nebo https://…/obrazek.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<FormHelperText>Vložte URL nebo použijte tlačítko níže. Cesty z /uploads se načtou přes proxy.</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>
</>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<HStack>
<Text>Neomezená platnost</Text>
<Switch isChecked={validUnlimited} onChange={(e)=>{
const on = e.target.checked;
setValidUnlimited(on);
if (on) { setMetaField('valid_from', ''); setMetaField('valid_to', ''); }
}} />
</HStack>
<FormControl isDisabled={validUnlimited}>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} />
</FormControl>
<FormControl>
<FormControl isDisabled={validUnlimited}>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" value={meta.valid_to || ''} onChange={(e)=>setMetaField('valid_to', e.target.value)} />
<FormHelperText>Když je zapnuto Neomezená platnost, datumy se nevyžadují a ignorují.</FormHelperText>
</FormControl>
</VStack>
{/* Metadata helpers */}
@@ -411,12 +357,55 @@ const EngagementAdminPage: React.FC = () => {
)}
{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>
<FormControl>
<FormLabel>SKU</FormLabel>
<Input value={meta.sku || ''} onChange={(e)=>setMetaField('sku', e.target.value)} />
<FormHelperText>SKU = skladové označení/kód produktu (není to množství). Množství nastavte v poli Množství/Sklad výše; Neomezeně zapnete přepínačem.</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Velikosti</FormLabel>
<CheckboxGroup value={sizeList} onChange={(vals)=>setSizeList(vals as string[])}>
<Wrap spacing={3}>
{SIZE_OPTIONS.map((s)=> (
<WrapItem key={s}><Checkbox value={s}>{s}</Checkbox></WrapItem>
))}
</Wrap>
</CheckboxGroup>
<HStack mt={1} spacing={2}>
<Input placeholder="Vlastní velikosti, oddělte čárkami" value={sizeCustom} onChange={(e)=>setSizeCustom(e.target.value)} />
<Button size="sm" onClick={()=>{
const parts = (sizeCustom || '').split(',').map(s=>s.trim()).filter(Boolean);
if (!parts.length) return;
setSizeList(prev => Array.from(new Set([...prev, ...parts])));
setSizeCustom('');
}}>Přidat</Button>
</HStack>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} /></FormControl>
<FormHelperText>Vyberte z nabídky nebo zadejte vlastní hodnoty, oddělené čárkami (např. 122, 128).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Barvy</FormLabel>
<CheckboxGroup value={colorList} onChange={(vals)=>setColorList(vals as string[])}>
<Wrap spacing={3}>
{COLOR_OPTIONS.map((c)=> (
<WrapItem key={c}><Checkbox value={c}>{c}</Checkbox></WrapItem>
))}
</Wrap>
</CheckboxGroup>
<HStack mt={1} spacing={2}>
<Input placeholder="Vlastní barvy, oddělte čárkami" value={colorCustom} onChange={(e)=>setColorCustom(e.target.value)} />
<Button size="sm" onClick={()=>{
const parts = (colorCustom || '').split(',').map(s=>s.trim()).filter(Boolean);
if (!parts.length) return;
setColorList(prev => Array.from(new Set([...prev, ...parts])));
setColorCustom('');
}}>Přidat</Button>
</HStack>
<FormHelperText>Vyberte více možností nebo přidejte vlastní barvy (oddělené čárkami).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} />
</FormControl>
</VStack>
)}
{form.type === 'merch_digital' && (
@@ -447,18 +436,16 @@ const EngagementAdminPage: React.FC = () => {
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
</HStack>
</VStack>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<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" />
<Image src={assetUrl(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>
@@ -483,7 +470,7 @@ const EngagementAdminPage: React.FC = () => {
<Th>Název</Th>
<Th>Typ</Th>
<Th>Body</Th>
<Th>Sklad</Th>
<Th>Množství/Sklad</Th>
<Th>Obrázek</Th>
<Th>Platnost</Th>
<Th>Aktivní</Th>
@@ -497,7 +484,7 @@ 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, n) => updateMut.mutate({ id: r.id, body: { cost_points: Number.isFinite(n) ? n : 0 } })}>
<NumberInput size="sm" value={r.cost_points} min={0} maxW="120px" isDisabled={!!r.type && r.type.startsWith('avatar_')} onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { cost_points: Number.isFinite(n) ? n : 0 } })}>
<NumberInputField />
</NumberInput>
</Td>
@@ -507,13 +494,13 @@ const EngagementAdminPage: React.FC = () => {
value={r.stock ?? 0}
min={-1}
maxW="100px"
isDisabled={r.type === 'avatar_upload_unlock'}
isDisabled={!!r.type && r.type.startsWith('avatar_')}
onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { stock: Number.isFinite(n) ? n : 0 } })}
>
<NumberInputField />
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>{r.image_url ? <Image src={assetUrl(r.image_url)} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
{(() => {
const m = (r.metadata || {}) as any;
@@ -531,14 +518,27 @@ const EngagementAdminPage: React.FC = () => {
<Td>
<Switch
isChecked={!!r.active}
isDisabled={r.type === 'avatar_upload_unlock'}
isDisabled={!!r.type && r.type.startsWith('avatar_')}
onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })}
/>
</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => { setEditItem(r); setEditForm(r); setEditMeta(r.metadata || {}); editModal.onOpen(); }} />
{r.type !== 'avatar_upload_unlock' && (
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => {
setEditItem(r);
setEditForm(r);
const m: any = r.metadata || {};
const prepared: any = { ...m };
try {
if (typeof m.size === 'string') prepared.__size_list = String(m.size).split(',').map((s:string)=>s.trim()).filter(Boolean);
} catch {}
try {
if (typeof m.color === 'string') prepared.__color_list = String(m.color).split(',').map((s:string)=>s.trim()).filter(Boolean);
} catch {}
setEditMeta(prepared);
editModal.onOpen();
}} />
{!r.type?.startsWith('avatar_') && (
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
)}
</HStack>
@@ -619,74 +619,125 @@ const EngagementAdminPage: React.FC = () => {
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Název</FormLabel>
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
</FormControl>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru</option>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>
<option value="merch_digital">Digitální odměna</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 })}>
<NumberInput value={Number(editForm.cost_points || 0)} min={0} onChange={(_v, n)=>setEditForm({ ...editForm, cost_points: Number.isFinite(n)? n : 0 })} isDisabled={!editItem || (!!editItem.type && !editItem.type.startsWith('avatar_') && false)}>
<NumberInputField />
</NumberInput>
<FormHelperText>~ {Math.round(Number(editForm.cost_points || 0) * 0.1)} </FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={Number(editForm.stock || 0)} min={-1} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })} isDisabled={editItem?.type === 'avatar_upload_unlock'}>
<FormLabel>Množství/Sklad</FormLabel>
<HStack>
<NumberInput value={Number(editForm.stock || 0)} min={-1} isDisabled={(!!editItem?.type && editItem.type.startsWith('avatar_')) || Number(editForm.stock) === -1} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })}>
<NumberInputField />
</NumberInput>
<HStack>
<Text fontSize="sm">Neomezeně</Text>
<Switch isChecked={Number(editForm.stock) === -1} onChange={(e)=> setEditForm(prev => ({ ...prev, stock: e.target.checked ? -1 : Math.max(0, Number(prev.stock) === -1 ? 0 : Number(prev.stock)||0) }))} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
</HStack>
</HStack>
<FormHelperText>-1 = neomezeně, 0 = vyprodáno.</FormHelperText>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
<FormHelperText>Vložte URL z /uploads nebo nahrávací tlačítko (proxy na frontend funguje).</FormHelperText>
</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()} isDisabled={editItem?.type === 'avatar_upload_unlock'}>Nahrát obrázek</Button>
<Button size="sm" variant="outline" onClick={() => editFileInputRef.current?.click()} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>Nahrát obrázek</Button>
</HStack>
{/* Edit metadata helpers (structured) */}
{ (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && (
<VStack align="stretch" spacing={2}>
{editForm.type === 'merch_coupon' && (
<>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'merch_physical' && (
<>
<FormControl><FormLabel>SKU</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} /></FormControl>
<HStack>
<FormControl><FormLabel>Velikost</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).size || ''} onChange={(e)=>setEditMetaField('size', e.target.value)} /></FormControl>
<FormControl><FormLabel>Barva</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).color || ''} onChange={(e)=>setEditMetaField('color', e.target.value)} /></FormControl>
<FormControl>
<FormLabel>SKU</FormLabel>
<Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} />
<FormHelperText>Interní kód produktu (volitelné).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Velikosti</FormLabel>
<CheckboxGroup value={(editMeta as any).__size_list || []} onChange={(vals)=>{
const arr = vals as string[];
setEditMeta(prev => ({ ...(prev as any), __size_list: arr } as any));
}}>
<Wrap spacing={3}>
{SIZE_OPTIONS.map((s)=> (
<WrapItem key={s}><Checkbox value={s} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>{s}</Checkbox></WrapItem>
))}
</Wrap>
</CheckboxGroup>
<HStack mt={1} spacing={2}>
<Input placeholder="Vlastní velikosti, oddělte čárkami" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).__size_custom || ''} onChange={(e)=>setEditMetaField('__size_custom', e.target.value)} />
<Button size="sm" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} onClick={()=>{
const parts = String((editMeta as any).__size_custom || '').split(',').map((s:string)=>s.trim()).filter(Boolean);
const cur = Array.isArray((editMeta as any).__size_list) ? (editMeta as any).__size_list as string[] : [];
const merged = Array.from(new Set([...(cur as string[]), ...parts]));
setEditMeta(prev => ({ ...(prev as any), __size_list: merged, __size_custom: '' } as any));
}}>Přidat</Button>
</HStack>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormHelperText>Vyberte z nabídky nebo přidejte vlastní hodnoty (oddělené čárkami).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Barvy</FormLabel>
<CheckboxGroup value={(editMeta as any).__color_list || []} onChange={(vals)=>{
const arr = vals as string[];
setEditMeta(prev => ({ ...(prev as any), __color_list: arr } as any));
}}>
<Wrap spacing={3}>
{COLOR_OPTIONS.map((c)=> (
<WrapItem key={c}><Checkbox value={c} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>{c}</Checkbox></WrapItem>
))}
</Wrap>
</CheckboxGroup>
<HStack mt={1} spacing={2}>
<Input placeholder="Vlastní barvy, oddělte čárkami" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).__color_custom || ''} onChange={(e)=>setEditMetaField('__color_custom', e.target.value)} />
<Button size="sm" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} onClick={()=>{
const parts = String((editMeta as any).__color_custom || '').split(',').map((s:string)=>s.trim()).filter(Boolean);
const cur = Array.isArray((editMeta as any).__color_list) ? (editMeta as any).__color_list as string[] : [];
const merged = Array.from(new Set([...(cur as string[]), ...parts]));
setEditMeta(prev => ({ ...(prev as any), __color_list: merged, __color_custom: '' } as any));
}}>Přidat</Button>
</HStack>
<FormHelperText>Vyberte více možností nebo přidejte vlastní barvy (oddělené čárkami).</FormHelperText>
</FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'merch_digital' && (
<>
<FormControl><FormLabel>Licenční klíč</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Licenční klíč</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'custom' && (
<HStack>
<Input placeholder="klíč" id="edit-kv-key" isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Input placeholder="hodnota" id="edit-kv-value" isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Button size="sm" isDisabled={editItem?.type === 'avatar_upload_unlock'} onClick={()=>{
<Input placeholder="klíč" id="edit-kv-key" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
<Input placeholder="hodnota" id="edit-kv-value" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
<Button size="sm" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} 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;
@@ -697,20 +748,28 @@ const EngagementAdminPage: React.FC = () => {
</VStack>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<HStack>
<Text>Neomezená platnost</Text>
<Switch isChecked={!(editMeta as any).valid_from && !(editMeta as any).valid_to} onChange={(e)=>{
const on = e.target.checked;
if (on) { setEditMeta(prev => ({ ...(prev as any), valid_from: '', valid_to: '' } as any)); }
}} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
</HStack>
<FormControl isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_') || (!(editMeta as any).valid_from && !(editMeta as any).valid_to)}>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_from || ''} onChange={(e)=>setEditMetaField('valid_from', e.target.value)} />
<Input type="datetime-local" value={(editMeta as any).valid_from || ''} onChange={(e)=>setEditMetaField('valid_from', e.target.value)} />
</FormControl>
<FormControl>
<FormControl isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_') || (!(editMeta as any).valid_from && !(editMeta as any).valid_to)}>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_to || ''} onChange={(e)=>setEditMetaField('valid_to', e.target.value)} />
<Input type="datetime-local" value={(editMeta as any).valid_to || ''} onChange={(e)=>setEditMetaField('valid_to', e.target.value)} />
<FormHelperText>Když je zapnuto Neomezená platnost, datumy se nevyžadují a ignorují.</FormHelperText>
</FormControl>
</VStack>
{/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
{editForm.image_url ? <Image src={editForm.image_url} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null}
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
{editForm.image_url ? <Image src={assetUrl(String(editForm.image_url))} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null}
</HStack>
</VStack>
</ModalBody>
@@ -719,10 +778,20 @@ const EngagementAdminPage: React.FC = () => {
<Button onClick={editModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={updateMut.isPending} onClick={async ()=>{
if (!editItem) return;
if (editItem.type === 'avatar_upload_unlock') {
if (editItem.type && editItem.type.startsWith('avatar_')) {
await updateMut.mutateAsync({ id: editItem.id, body: { cost_points: editForm.cost_points as any } });
} else {
const metadata: Record<string, any> | undefined = Object.keys(editMeta || {}).length ? (editMeta as any) : {} as any;
const metadata: Record<string, any> = { ...(Object.keys(editMeta || {}).length ? (editMeta as any) : {}) } as any;
// Merge structured lists to CSV in metadata
const sz = Array.isArray((metadata as any).__size_list) ? (metadata as any).__size_list as string[] : [];
const cz = Array.isArray((metadata as any).__color_list) ? (metadata as any).__color_list as string[] : [];
if (sz.length) (metadata as any).size = sz.join(',');
if (cz.length) (metadata as any).color = cz.join(',');
delete (metadata as any).__size_list; delete (metadata as any).__size_custom;
delete (metadata as any).__color_list; delete (metadata as any).__color_custom;
// If unlimited validity, clear dates
const unlimited = !(metadata as any).valid_from && !(metadata as any).valid_to;
if (unlimited) { delete (metadata as any).valid_from; delete (metadata as any).valid_to; }
await updateMut.mutateAsync({ id: editItem.id, body: {
name: editForm.name,
type: editForm.type,
@@ -730,7 +799,7 @@ const EngagementAdminPage: React.FC = () => {
stock: editForm.stock as any,
image_url: editForm.image_url,
active: editForm.active as any,
metadata: metadata as any,
metadata: Object.keys(metadata).length ? metadata as any : undefined,
} as any });
}
editModal.onClose();
@@ -740,78 +809,7 @@ const EngagementAdminPage: React.FC = () => {
</ModalContent>
</Modal>
{/* Batch create modal (hidden) */}
{batchEnabled && (
<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={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
<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>
);
};
+38 -8
View File
@@ -37,7 +37,8 @@ import {
} from '@chakra-ui/react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye, Plus } from 'lucide-react';
import AdminLayout from '../../layouts/AdminLayout';
import api from '../../services/api';
import api, { API_URL } from '../../services/api';
import { getZoneramaManifestWithFallbacks } from '../../services/zonerama';
interface Album {
id: string;
@@ -57,9 +58,8 @@ const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const abs = new URL(path, origin);
return abs.toString();
}
return path;
@@ -82,7 +82,7 @@ const GalleryAdminPage: React.FC = () => {
const [photoLimit, setPhotoLimit] = useState<number>(50);
const [adding, setAdding] = useState<boolean>(false);
const fetchAlbums = async () => {
const fetchAlbums = async (): Promise<Album[]> => {
setLoading(true);
setError('');
@@ -117,10 +117,34 @@ const GalleryAdminPage: React.FC = () => {
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
// Fallback: synthesize albums from flat manifest when both sources fail/empty
if (!combinedAlbums || combinedAlbums.length === 0) {
try {
const items = await getZoneramaManifestWithFallbacks();
if (Array.isArray(items) && items.length > 0) {
const byAlbum: Record<string, any[]> = {} as any;
items.forEach((it: any) => {
const aid = String(it.album_id || 'unknown');
(byAlbum[aid] = byAlbum[aid] || []).push(it);
});
const synthesized: Album[] = Object.entries(byAlbum).map(([aid, arr]) => ({
id: aid,
title: 'Album',
url: (arr[0] as any).page_url || '#',
date: '',
photos_count: (arr as any[]).length,
photos: (arr as any[]).slice(0, 12).map((p: any) => ({ id: String(p.id || ''), page_url: String(p.page_url || ''), image_1500: String(p.src || p.local || '') })),
}));
combinedAlbums = synthesized;
}
} catch {}
}
setAlbums(combinedAlbums);
return combinedAlbums;
} catch (err: any) {
setError(err.message || 'Nepodařilo se načíst alba');
return [];
} finally {
setLoading(false);
}
@@ -141,8 +165,14 @@ const GalleryAdminPage: React.FC = () => {
isClosable: true,
});
// Reload albums after refresh
await fetchAlbums();
// Reload albums after refresh with short polling (refresh runs async on server)
let loaded: Album[] = [];
for (let i = 0; i < 5; i++) {
// small delay before each attempt to allow backend to finish
await new Promise((r) => setTimeout(r, 1200));
loaded = await fetchAlbums();
if (loaded && loaded.length > 0) break;
}
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || 'Nepodařilo se obnovit galerii';
@@ -342,7 +372,7 @@ const GalleryAdminPage: React.FC = () => {
<Td>
{coverPhoto ? (
<Image
src={coverPhoto.image_1500}
src={resolveBackendUrl(coverPhoto.image_1500)}
alt={album.title}
boxSize="60px"
objectFit="cover"
@@ -243,6 +243,7 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
}) => {
const hasChildren = item.children && item.children.length > 0;
const indentPx = level * 32;
const isCategory = item.type === 'dropdown';
return (
<Box ml={`${indentPx}px`}>
@@ -375,12 +376,19 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
</CardBody>
</Card>
{/* Render children with nested DnD if expanded */}
{hasChildren && isExpanded && (
{/* Always render a children Droppable for categories (dropdown type).
This allows dropping into collapsed or empty categories. */}
{isCategory && (
<Droppable droppableId={childrenDroppableId || `children-${item.id}`}>
{(provided) => (
<VStack spacing={2} align="stretch" mt={2} ref={provided.innerRef} {...provided.droppableProps}>
{item.children!.map((child, childIndex) => (
<VStack
spacing={2}
align="stretch"
mt={2}
ref={provided.innerRef}
{...provided.droppableProps}
>
{hasChildren && isExpanded && item.children!.map((child, childIndex) => (
<Draggable key={String(child.id)} draggableId={`${(draggableChildPrefix || 'child')}-${child.id}`} index={childIndex}>
{(dragProvided) => (
<Box ref={dragProvided.innerRef} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps}>
@@ -410,6 +418,10 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
)}
</Draggable>
))}
{/* Provide a minimal drop zone even when collapsed or empty */}
{!hasChildren && (
<Box minH="8px" />
)}
{provided.placeholder}
</VStack>
)}
@@ -521,6 +533,7 @@ const NavigationAdminPage = () => {
if (!result.destination) return;
const { source, destination } = result;
const parseAdminChildrenId = (id: string) => id.startsWith('admin-children-') ? parseInt(id.replace('admin-children-', ''), 10) : null;
const parseFrontChildrenId = (id: string) => id.startsWith('frontend-children-') ? parseInt(id.replace('frontend-children-', ''), 10) : null;
if (source.droppableId === 'frontend-nav') {
const items = Array.from(navItems);
@@ -554,7 +567,7 @@ const NavigationAdminPage = () => {
(source.droppableId.startsWith('admin-children-') && destination.droppableId === 'admin-nav')
) {
const srcParentId = parseAdminChildrenId(source.droppableId);
const destParentId = parseAdminChildrenId(destination.droppableId);
let destParentId = parseAdminChildrenId(destination.droppableId);
const items = Array.from(adminNavItems);
// Helper to find parent index by id
@@ -563,6 +576,34 @@ const NavigationAdminPage = () => {
return items.findIndex((it) => it.id === pid);
};
// If dropping onto top-level container, but near a category card, treat as drop INTO that category
let destChildIndex = destination.index;
if (destParentId === null) {
const at = items[destination.index];
const before = destination.index > 0 ? items[destination.index - 1] : undefined;
const after = destination.index < items.length - 1 ? items[destination.index + 1] : undefined;
let dropIntoId: number | null = null;
if (at && at.type === 'dropdown') dropIntoId = at.id!;
else if (before && before.type === 'dropdown') dropIntoId = before.id!;
else if (after && after.type === 'dropdown') dropIntoId = after.id!;
if (dropIntoId) {
destParentId = dropIntoId;
const dIdxProbe = findParentIndex(destParentId);
if (dIdxProbe >= 0) {
destChildIndex = Array.isArray(items[dIdxProbe].children) ? items[dIdxProbe].children!.length : 0;
} else {
destChildIndex = 0;
}
}
}
// Fallback: if still no destination parent but we moved a sub-item, keep original parent to avoid promoting to top-level
if (destParentId === null && srcParentId !== null) {
destParentId = srcParentId;
const dIdxProbe = findParentIndex(destParentId);
destChildIndex = dIdxProbe >= 0 && Array.isArray(items[dIdxProbe].children) ? items[dIdxProbe].children!.length : 0;
}
let moved: NavigationItem | null = null;
// Remove from source list
@@ -588,7 +629,7 @@ const NavigationAdminPage = () => {
const dIdx = findParentIndex(destParentId);
if (dIdx >= 0) {
const destChildren = Array.isArray(items[dIdx].children) ? Array.from(items[dIdx].children!) : [];
destChildren.splice(destination.index, 0, moved);
destChildren.splice(destChildIndex, 0, moved);
items[dIdx] = { ...items[dIdx], children: destChildren } as NavigationItem;
}
}
@@ -597,7 +638,7 @@ const NavigationAdminPage = () => {
// Persist parent change and reorder siblings at both source and destination
try {
await updateNavigationItem(moved.id!, { parent_id: destParentId === null ? null : destParentId, display_order: destination.index } as any);
await updateNavigationItem(moved.id!, { parent_id: destParentId === null ? null : destParentId, display_order: destParentId === null ? destination.index : destChildIndex } as any);
// Reorder source siblings
if (srcParentId === null) {
@@ -623,6 +664,101 @@ const NavigationAdminPage = () => {
}
}
toast({ title: 'Přesunuto', status: 'success', duration: 2000 });
} catch (error) {
toast({ title: 'Chyba při přesunu', status: 'error', duration: 3000 });
loadData();
}
}
// Frontend: moving between top-level and children or across categories
else if (
source.droppableId.startsWith('frontend-children-') || destination.droppableId.startsWith('frontend-children-') ||
(source.droppableId === 'frontend-nav' && destination.droppableId.startsWith('frontend-children-')) ||
(source.droppableId.startsWith('frontend-children-') && destination.droppableId === 'frontend-nav')
) {
const srcParentId = parseFrontChildrenId(source.droppableId);
let destParentId = parseFrontChildrenId(destination.droppableId);
const items = Array.from(navItems);
const findParentIndex = (pid: number | null) => {
if (pid === null) return -1;
return items.findIndex((it) => it.id === pid);
};
// If dropping onto top-level container, but near a category card, treat as drop INTO that category
let destChildIndex = destination.index;
if (destParentId === null) {
const at = items[destination.index];
const before = destination.index > 0 ? items[destination.index - 1] : undefined;
let dropIntoId: number | null = null;
if (at && at.type === 'dropdown') dropIntoId = at.id!;
else if (before && before.type === 'dropdown') dropIntoId = before.id!;
if (dropIntoId) {
destParentId = dropIntoId;
const dIdxProbe = findParentIndex(destParentId);
if (dIdxProbe >= 0) {
destChildIndex = Array.isArray(items[dIdxProbe].children) ? items[dIdxProbe].children!.length : 0;
} else {
destChildIndex = 0;
}
}
}
let moved: NavigationItem | null = null;
if (srcParentId === null) {
const [m] = items.splice(source.index, 1);
moved = m;
} else {
const pIdx = findParentIndex(srcParentId);
if (pIdx >= 0) {
const srcChildren = Array.isArray(items[pIdx].children) ? Array.from(items[pIdx].children!) : [];
const [m] = srcChildren.splice(source.index, 1);
moved = m;
items[pIdx] = { ...items[pIdx], children: srcChildren } as NavigationItem;
}
}
if (!moved) return;
if (destParentId === null) {
items.splice(destination.index, 0, moved);
} else {
const dIdx = findParentIndex(destParentId);
if (dIdx >= 0) {
const destChildren = Array.isArray(items[dIdx].children) ? Array.from(items[dIdx].children!) : [];
destChildren.splice(destChildIndex, 0, moved);
items[dIdx] = { ...items[dIdx], children: destChildren } as NavigationItem;
}
}
setNavItems(items);
try {
await updateNavigationItem(moved.id!, { parent_id: destParentId === null ? null : destParentId, display_order: destParentId === null ? destination.index : destChildIndex } as any);
if (srcParentId === null) {
const topOrders = items.map((it, idx) => ({ id: it.id!, display_order: idx }));
await reorderNavigationItems(topOrders);
} else {
const srcIdx = findParentIndex(srcParentId);
if (srcIdx >= 0) {
const orders = (items[srcIdx].children || []).map((c, idx) => ({ id: c.id!, display_order: idx }));
await reorderNavigationItems(orders);
}
}
if (destParentId === null) {
const topOrders = items.map((it, idx) => ({ id: it.id!, display_order: idx }));
await reorderNavigationItems(topOrders);
} else {
const destIdx = findParentIndex(destParentId);
if (destIdx >= 0) {
const orders = (items[destIdx].children || []).map((c, idx) => ({ id: c.id!, display_order: idx }));
await reorderNavigationItems(orders);
}
}
toast({ title: 'Přesunuto', status: 'success', duration: 2000 });
} catch (error) {
toast({ title: 'Chyba při přesunu', status: 'error', duration: 3000 });
@@ -778,6 +914,7 @@ const NavigationAdminPage = () => {
target: '_self',
parent_id: parentId,
requires_admin: forAdmin || false,
allow_editor: false,
} as NavigationItem);
}
onNavModalOpen();
@@ -1384,6 +1521,16 @@ const NavigationAdminPage = () => {
</FormControl>
)}
{isAdminNav && editingNav?.type !== 'dropdown' && (
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit editorům</FormLabel>
<Switch
isChecked={!!editingNav?.allow_editor}
onChange={(e) => setEditingNav({ ...editingNav!, allow_editor: e.target.checked })}
/>
</FormControl>
)}
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Viditelné</FormLabel>
<Switch
@@ -168,6 +168,13 @@ export default function NewsletterAdminPage() {
};
const detailsClearComps = () => setDetailsCompetitions('');
const detailsSelectAllComps = () => setDetailsCompetitions(compOptions.map(o => o.code).join(', '));
// Fetch subscribers
const { data: subscribers = [], isLoading } = useQuery({
queryKey: ['admin', 'newsletter-subscribers'],
queryFn: getNewsletterSubscribers,
});
const recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
return subscribers
@@ -230,6 +237,22 @@ export default function NewsletterAdminPage() {
}
}, [detailsOpen, activeType]);
// Prefetch preview subjects for status list when there are recipients
useEffect(() => {
const types: MailType[] = ['weekly','matches','scores','blogs','events'];
(async () => {
for (const t of types) {
try {
const count = recipientsForType(t).length;
if (count > 0 && !typePreview[t]) {
await loadPreviewForType(t);
}
} catch {}
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribers]);
const { isOpen, onOpen, onClose } = useDisclosure();
const testModal = useDisclosure();
const smtpModal = useDisclosure();
@@ -324,12 +347,6 @@ export default function NewsletterAdminPage() {
}
};
// Fetch subscribers
const { data: subscribers = [], isLoading } = useQuery({
queryKey: ['admin', 'newsletter-subscribers'],
queryFn: getNewsletterSubscribers,
});
// Filter subscribers based on search term
const filteredSubscribers = subscribers.filter((subscriber) =>
subscriber.email.toLowerCase().includes(searchTerm.toLowerCase())
@@ -655,6 +672,11 @@ export default function NewsletterAdminPage() {
</HStack>
<HStack spacing={4}>
<Text color={textSecondary}>Příjemci: <b>{count}</b></Text>
{typePreview[t]?.subject ? (
<Badge colorScheme="blue" title="Předmět připraveného emailu">
{typePreview[t]!.subject}
</Badge>
) : null}
<Button size="sm" onClick={()=> openDetails(t)}>Detail</Button>
</HStack>
</Flex>
@@ -1028,16 +1050,13 @@ export default function NewsletterAdminPage() {
</HStack>
<Box mt={2} p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box
bg={cardBg}
className="ql-editor"
p={3}
borderRadius="md"
borderWidth="1px"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(previewHtml || '<em>Náhled se zobrazí zde</em>') }}
/>
</Box>
</>
)}
<Box mt={4} p={4} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md">
<Text fontWeight="bold" mb={2}>Náhled:</Text>
<Box
@@ -1046,9 +1065,13 @@ export default function NewsletterAdminPage() {
p={4}
borderRadius="md"
bg={cardBg}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(sendMode === 'custom' ? (newsletterData.content || '<em>Náhled se zobrazí zde</em>') : (previewHtml || '<em>Náhled se zobrazí zde</em>')) }}
className="ql-editor"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(previewHtml || '<em>Náhled se zobrazí zde</em>') }}
/>
</Box>
</>
)}
</VStack>
</ModalBody>
<ModalFooter>
@@ -1140,7 +1163,7 @@ export default function NewsletterAdminPage() {
<Button variant="outline" onClick={()=>{ if(!activeType) return; exportRecipientsCSV(activeType, detailsCompetitions); }}>Export CSV</Button>
</HStack>
<Box p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
<Box className="ql-editor" bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
</Box>
<Box>
<Heading size="sm" mb={2}>Příjemci</Heading>
+166 -18
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState, useDeferredValue, startTransition } from 'react';
import {
Box,
Button,
@@ -58,6 +58,7 @@ import {
getQr,
uploadQr,
deleteQr,
swapSides,
} from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types';
@@ -66,6 +67,7 @@ import { useQuery } from '@tanstack/react-query';
import { AdminMatch, fetchAdminMatches, fetchTeamLogoOverrides } from '@/services/adminMatches';
import { getFacrClubInfoCache } from '@/services/facr/cache';
import { createSponsor } from '@/services/sponsors';
import { pickTextColor } from '@/utils/colors';
const themes: ScoreboardTheme[] = ['classic', 'pill', 'var1', 'var2', 'var3', 'var4'];
@@ -79,12 +81,28 @@ const resolveLogoUrl = (u?: string | null) => {
return u;
};
const deproxify = (u?: string | null) => {
try {
if (!u) return u || undefined;
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
const parsed = new URL(u, base.origin);
if (/\/proxy\/image$/i.test(parsed.pathname)) {
const inner = parsed.searchParams.get('url');
return inner || u || undefined;
}
return u || undefined;
} catch {
return u || undefined;
}
};
const ScoreboardAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const [state, setState] = useState<ScoreboardState | null>(null);
const [loading, setLoading] = useState(true);
const deferredState = useDeferredValue(state);
const toast = useToast();
const [activeTab, setActiveTab] = useState<number>(0); // 0 upcoming, 1 recent
// Presets & sponsors state
@@ -96,6 +114,48 @@ const ScoreboardAdminPage: React.FC = () => {
const [qrBusy, setQrBusy] = useState(false);
const { isOpen: isSponsorModalOpen, onOpen: openSponsorModal, onClose: closeSponsorModal } = useDisclosure();
const [uploadedSponsorUrls, setUploadedSponsorUrls] = useState<string[]>([]);
const [homeColorBusy, setHomeColorBusy] = useState(false);
const [awayColorBusy, setAwayColorBusy] = useState(false);
const [isPickingColor, setIsPickingColor] = useState(false);
const saveDebounceRef = useRef<number | undefined>(undefined);
const pendingPatchRef = useRef<Partial<ScoreboardState>>({});
const setPartialDebounced = (patch: Partial<ScoreboardState>) => {
startTransition(() => {
setState((prev) => ({ ...(prev as ScoreboardState), ...patch }));
});
pendingPatchRef.current = { ...pendingPatchRef.current, ...patch };
if (saveDebounceRef.current) {
window.clearTimeout(saveDebounceRef.current);
}
saveDebounceRef.current = window.setTimeout(async () => {
const toSave = pendingPatchRef.current;
pendingPatchRef.current = {};
saveDebounceRef.current = undefined;
try {
const next = await saveScoreboardState(toSave);
setState(next);
} catch {}
}, 250);
};
// For performance-sensitive inputs (color pickers): queue save, but don't re-render immediately on every drag
const queueSaveOnly = (patch: Partial<ScoreboardState>) => {
pendingPatchRef.current = { ...pendingPatchRef.current, ...patch };
if (saveDebounceRef.current) {
window.clearTimeout(saveDebounceRef.current);
}
saveDebounceRef.current = window.setTimeout(() => {
const toSave = pendingPatchRef.current;
pendingPatchRef.current = {};
saveDebounceRef.current = undefined;
// Update UI immediately (non-urgent) without waiting for network
startTransition(() => {
setState((prev) => ({ ...(prev as ScoreboardState), ...toSave }));
});
// Persist asynchronously; ignore result to avoid blocking UI
try { void saveScoreboardState(toSave); } catch {}
}, 250);
};
// Club search inline (home/away target)
const [clubQuery, setClubQuery] = useState('');
@@ -114,9 +174,19 @@ const ScoreboardAdminPage: React.FC = () => {
})();
}, []);
useEffect(() => {
return () => {
if (saveDebounceRef.current) {
window.clearTimeout(saveDebounceRef.current);
saveDebounceRef.current = undefined;
}
pendingPatchRef.current = {};
};
}, []);
// Poll while timer is running to reflect live time
useEffect(() => {
if (!state?.running) return;
if (!state?.running || isPickingColor) return;
let mounted = true;
const id = setInterval(async () => {
try {
@@ -128,7 +198,7 @@ const ScoreboardAdminPage: React.FC = () => {
mounted = false;
clearInterval(id);
};
}, [state?.running]);
}, [state?.running, isPickingColor]);
// Load matches for linking
const { data: adminMatches = [] } = useQuery<AdminMatch[]>({
@@ -342,10 +412,14 @@ const ScoreboardAdminPage: React.FC = () => {
const homeName = getOverrideName(rawHomeName, homeTeamId) || rawHomeName;
const awayName = getOverrideName(rawAwayName, awayTeamId) || rawAwayName;
// Prefer ID-based logo override, then name-based, then original logo URL
const homeLogoOverride = (homeTeamId && byId?.[homeTeamId]?.logo_url) ? String(byId[homeTeamId].logo_url) : getLogo(rawHomeName, m.home_logo_url || (m as any).homeLogoURL || '');
const awayLogoOverride = (awayTeamId && byId?.[awayTeamId]?.logo_url) ? String(byId[awayTeamId].logo_url) : getLogo(rawAwayName, m.away_logo_url || (m as any).awayLogoURL || '');
const homeLogo = resolveLogoUrl(homeLogoOverride || '') || '';
const awayLogo = resolveLogoUrl(awayLogoOverride || '') || '';
const homeLogoRaw = (homeTeamId && byId?.[homeTeamId]?.logo_url)
? String(byId[homeTeamId].logo_url)
: getLogo(rawHomeName, m.home_logo_url || (m as any).homeLogoURL || '');
const awayLogoRaw = (awayTeamId && byId?.[awayTeamId]?.logo_url)
? String(byId[awayTeamId].logo_url)
: getLogo(rawAwayName, m.away_logo_url || (m as any).awayLogoURL || '');
const homeLogo = homeLogoRaw || '';
const awayLogo = awayLogoRaw || '';
const updates: Partial<ScoreboardState> = {
homeName,
awayName,
@@ -357,8 +431,8 @@ const ScoreboardAdminPage: React.FC = () => {
};
// Try to detect colors from logos
const [cHome, cAway] = await Promise.all([
derivePrimaryFromLogo(homeLogo || state.homeLogo),
derivePrimaryFromLogo(awayLogo || state.awayLogo),
derivePrimaryFromLogo(deproxify(homeLogo || state.homeLogo)),
derivePrimaryFromLogo(deproxify(awayLogo || state.awayLogo)),
]);
if (cHome) updates.primaryColor = cHome;
if (cAway) updates.secondaryColor = cAway;
@@ -368,20 +442,20 @@ const ScoreboardAdminPage: React.FC = () => {
};
const applyClub = async (club: SearchResult) => {
const logo = resolveLogoUrl(club.logo_url) || undefined;
const color = await derivePrimaryFromLogo(logo || undefined);
const logoRaw = club.logo_url || undefined;
const color = await derivePrimaryFromLogo(deproxify(logoRaw) || undefined);
if (assignTo === 'home') {
await setPartial({
homeName: club.name || 'DOMÁCÍ',
homeShort: deriveShort(club.name || ''),
homeLogo: logo,
homeLogo: logoRaw,
primaryColor: color || state?.primaryColor,
});
} else {
await setPartial({
awayName: club.name || 'HOSTÉ',
awayShort: deriveShort(club.name || ''),
awayLogo: logo,
awayLogo: logoRaw,
secondaryColor: color || state?.secondaryColor,
});
}
@@ -492,7 +566,7 @@ const ScoreboardAdminPage: React.FC = () => {
{/* Live preview */}
<Box display="flex" justifyContent="center" mb={6}>
<ScoreboardPreview state={state} />
<ScoreboardPreview state={(deferredState || state) as ScoreboardState} />
</Box>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
@@ -547,6 +621,22 @@ const ScoreboardAdminPage: React.FC = () => {
await setPartial({ homeLogo: e.target.value });
}}
/>
<Button mt={2} size="sm" variant="outline" isLoading={homeColorBusy} onClick={async ()=>{
if (!state.homeLogo) { toast({ title: 'Chybí logo domácích', status: 'warning' }); return; }
try {
setHomeColorBusy(true);
const c = await derivePrimaryFromLogo(deproxify(state.homeLogo));
if (c) {
const text = pickTextColor(c);
setPartialDebounced({ primaryColor: c, homeTextColor: text });
toast({ title: 'Barva nastavena z loga domácích', status: 'success' });
} else {
toast({ title: 'Nepodařilo se získat barvu z loga domácích', status: 'error' });
}
} finally {
setHomeColorBusy(false);
}
}}>Barva z loga</Button>
</FormControl>
<FormControl>
<FormLabel>Logo hostů (URL)</FormLabel>
@@ -556,6 +646,22 @@ const ScoreboardAdminPage: React.FC = () => {
await setPartial({ awayLogo: e.target.value });
}}
/>
<Button mt={2} size="sm" variant="outline" isLoading={awayColorBusy} onClick={async ()=>{
if (!state.awayLogo) { toast({ title: 'Chybí logo hostů', status: 'warning' }); return; }
try {
setAwayColorBusy(true);
const c = await derivePrimaryFromLogo(deproxify(state.awayLogo));
if (c) {
const text = pickTextColor(c);
setPartialDebounced({ secondaryColor: c, awayTextColor: text });
toast({ title: 'Barva nastavena z loga hostů', status: 'success' });
} else {
toast({ title: 'Nepodařilo se získat barvu z loga hostů', status: 'error' });
}
} finally {
setAwayColorBusy(false);
}
}}>Barva z loga</Button>
</FormControl>
<FormControl>
<FormLabel>Délka poločasu (min)</FormLabel>
@@ -586,19 +692,51 @@ const ScoreboardAdminPage: React.FC = () => {
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Barva domácích</FormLabel>
<Input type="color" value={state.primaryColor || '#1e3a8a'} onChange={async (e) => setPartial({ primaryColor: e.target.value })} />
<Input
type="color"
defaultValue={state.primaryColor || '#1e3a8a'}
key={`pc-${state.primaryColor || '#1e3a8a'}`}
onPointerDown={() => setIsPickingColor(true)}
onPointerUp={() => setIsPickingColor(false)}
onBlur={() => setIsPickingColor(false)}
onChange={(e) => queueSaveOnly({ primaryColor: e.target.value })}
/>
</FormControl>
<FormControl>
<FormLabel>Barva hostů</FormLabel>
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
<Input
type="color"
defaultValue={state.secondaryColor || '#2563eb'}
key={`sc-${state.secondaryColor || '#2563eb'}`}
onPointerDown={() => setIsPickingColor(true)}
onPointerUp={() => setIsPickingColor(false)}
onBlur={() => setIsPickingColor(false)}
onChange={(e) => queueSaveOnly({ secondaryColor: e.target.value })}
/>
</FormControl>
<FormControl>
<FormLabel>Barva textu domácích</FormLabel>
<Input type="color" value={state.homeTextColor || '#ffffff'} onChange={async (e) => setPartial({ homeTextColor: e.target.value })} />
<Input
type="color"
defaultValue={state.homeTextColor || '#ffffff'}
key={`htc-${state.homeTextColor || '#ffffff'}`}
onPointerDown={() => setIsPickingColor(true)}
onPointerUp={() => setIsPickingColor(false)}
onBlur={() => setIsPickingColor(false)}
onChange={(e) => queueSaveOnly({ homeTextColor: e.target.value })}
/>
</FormControl>
<FormControl>
<FormLabel>Barva textu hostů</FormLabel>
<Input type="color" value={state.awayTextColor || '#ffffff'} onChange={async (e) => setPartial({ awayTextColor: e.target.value })} />
<Input
type="color"
defaultValue={state.awayTextColor || '#ffffff'}
key={`atc-${state.awayTextColor || '#ffffff'}`}
onPointerDown={() => setIsPickingColor(true)}
onPointerUp={() => setIsPickingColor(false)}
onBlur={() => setIsPickingColor(false)}
onChange={(e) => queueSaveOnly({ awayTextColor: e.target.value })}
/>
</FormControl>
<FormControl>
<FormLabel>QR interval (minuty)</FormLabel>
@@ -646,6 +784,16 @@ const ScoreboardAdminPage: React.FC = () => {
<Button variant="outline" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
<Button variant="outline" onClick={async () => { await resetTimer(); const s = await getScoreboardState(); setState(s); }}>Reset čas</Button>
<Button variant="outline" onClick={() => setPartial({ homeFouls: 0, awayFouls: 0 })}>Reset fauly</Button>
<Button variant="outline" onClick={async () => {
try {
await swapSides();
const s = await getScoreboardState();
setState(s);
toast({ title: 'Strany prohozeny', status: 'success' });
} catch {
toast({ title: 'Akce selhala', status: 'error' });
}
}}>Prohodit strany</Button>
</HStack>
<Divider my={6} />
+112 -31
View File
@@ -22,7 +22,7 @@ const SweepstakeVisualPage: React.FC = () => {
const toast = useToast();
const [data, setData] = useState<VisualData | null>(null);
const [loading, setLoading] = useState(true);
const [variant, setVariant] = useState<'cycler' | 'wheel'>('cycler');
const [variant, setVariant] = useState<'roulette'>('roulette');
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [confettiOn, setConfettiOn] = useState<boolean>(true);
const [soundOn, setSoundOn] = useState<boolean>(true);
@@ -37,6 +37,16 @@ const SweepstakeVisualPage: React.FC = () => {
const imgCacheRef = useRef<Record<number, HTMLImageElement>>({});
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
// Roulette scroller state
const railRef = useRef<HTMLDivElement | null>(null);
const [stripItems, setStripItems] = useState<typeof entries>([]);
const [scrollPx, setScrollPx] = useState<number>(0);
const [rouletteKey, setRouletteKey] = useState<number>(0); // force re-render/reflow per run
const [weightingOn, setWeightingOn] = useState<boolean>(true);
const [speed, setSpeed] = useState<'slow'|'normal'|'fast'>('normal');
const [drama, setDrama] = useState<number>(3);
const [transitionMs, setTransitionMs] = useState<number>(4600);
const entries = data?.entries || [];
const winners = data?.winners || [];
const { data: publicSettings } = usePublicSettings();
@@ -226,11 +236,69 @@ const SweepstakeVisualPage: React.FC = () => {
}, duration);
};
const onStart = () => {
if (variant === 'cycler') startCycler();
else startWheel();
const startRoulette = () => {
if (!entries.length || revealIndex >= winners.length) return;
const target = targetIndex;
if (target < 0) { startCycler(); return; }
setPlaying(true);
// Build a long strip of avatars.
// Pool selection: when weighting is ON, keep duplicates from entries; when OFF, use unique users (flat odds).
const uniqueMap = new Map<number, typeof entries[0]>();
for (const e of entries) { if (!uniqueMap.has(e.user_id)) uniqueMap.set(e.user_id, e); }
const pool = weightingOn ? entries : Array.from(uniqueMap.values());
const total = Math.max(80, Math.min(240, pool.length * 4));
const rnd = (n: number) => Math.floor(Math.random() * n);
const list: typeof entries = [] as any;
for (let i = 0; i < total - 10; i++) {
list.push(pool[rnd(pool.length)]);
}
// Ensure target appears near the end, centered under pointer at stop
const targetEntry = entries[target];
const tailPad = 6;
for (let i = 0; i < tailPad - 1; i++) list.push(pool[rnd(pool.length)]);
list.push(targetEntry);
setStripItems(list);
// Next tick measure viewport + compute scroll distance
window.requestAnimationFrame(() => {
try {
const host = document.getElementById('visual-host');
const rail = railRef.current;
if (!host || !rail) { setPlaying(false); return; }
const viewport = host.getBoundingClientRect();
const cardW = 72; // width incl. margin approx
const gap = 8;
const itemSize = cardW + gap;
const landingIndex = list.length - 1; // last item is target
const centerOffset = Math.max(0, (viewport.width - cardW) / 2);
const distance = landingIndex * itemSize - centerOffset;
// Prime initial position
setScrollPx(0);
setRouletteKey((k) => k + 1);
// Start animation in next frame
setTimeout(() => {
// Add extra laps based on drama level (1..5)
const dramaFactor = Math.max(0, Math.min(5, Number(drama) || 3));
const extra = viewport.width * dramaFactor + rnd(viewport.width);
setScrollPx(distance + extra);
// Duration based on speed
const mul = speed === 'slow' ? 1.25 : (speed === 'fast' ? 0.75 : 1.0);
const duration = Math.round(4600 * mul);
setTransitionMs(duration);
window.setTimeout(() => {
setPlaying(false);
setRevealIndex((i) => i + 1);
beep(); fireConfetti();
}, duration + 50);
}, 40);
} catch {
setPlaying(false);
}
});
};
const onStart = () => { startRoulette(); };
// Reveal All logic
const [revealAll, setRevealAll] = useState(false);
useEffect(() => {
@@ -257,8 +325,7 @@ const SweepstakeVisualPage: React.FC = () => {
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);
setVariant('roulette');
try { const list = await adminListPrizes(Number(id)); if (active) setPrizes(list); } catch {}
} catch (e: any) {
toast({ status: 'error', title: 'Nelze načíst data vizualizace' });
@@ -269,7 +336,7 @@ const SweepstakeVisualPage: React.FC = () => {
return () => { active = false; if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [id]);
useEffect(() => { if (variant === 'wheel') drawWheel(); }, [variant, data, theme]);
// Wheel variant removed no canvas redraw needed
if (loading) {
return (
@@ -287,7 +354,6 @@ const SweepstakeVisualPage: React.FC = () => {
}
const shownWinners = winners.slice(0, revealIndex);
const current = entries[currentIdx];
return (
<AdminLayout>
@@ -300,17 +366,26 @@ const SweepstakeVisualPage: React.FC = () => {
</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>
<Select value={speed} onChange={(e)=>setSpeed(e.target.value as any)} maxW="200px">
<option value="slow">Rychlost: Pomalá</option>
<option value="normal">Rychlost: Normální</option>
<option value="fast">Rychlost: Rychlá</option>
</Select>
<Select value={String(drama)} onChange={(e)=>setDrama(Number(e.target.value)||3)} maxW="200px">
<option value="1">Drama: 1</option>
<option value="2">Drama: 2</option>
<option value="3">Drama: 3</option>
<option value="4">Drama: 4</option>
<option value="5">Drama: 5</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>
<Button size="sm" variant={weightingOn? 'solid':'outline'} onClick={()=>setWeightingOn(v=>!v)}>{weightingOn? 'Vážit účastí: Zap' : 'Vážit účastí: 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')}
@@ -331,31 +406,37 @@ const SweepstakeVisualPage: React.FC = () => {
</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 && (
<Box position="relative" w="100%" maxW="960px" h="220px">
{/* pointer */}
<Box position="absolute" left="50%" top="10px" transform="translateX(-50%)" zIndex={3}
borderLeft="10px solid transparent" borderRight="10px solid transparent" borderBottom={`16px solid ${theme==='dark'?'#e2e8f0':'#1a202c'}`} />
{/* center divider */}
<Box pointerEvents="none" position="absolute" left="50%" top={42} bottom={22} width="2px" transform="translateX(-1px)" zIndex={2}
style={{ background: theme==='dark' ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)' }} />
{/* scrolling rail */}
<Box key={rouletteKey} position="absolute" left={0} right={0} top={40} bottom={20} overflow="hidden" borderRadius="md" borderWidth="1px" bg={theme==='dark'?'#0b0b0b':'#f9fafb'}>
<div ref={railRef} style={{ display:'flex', alignItems:'center', gap:8, padding:'8px', transform:`translateX(-${scrollPx}px)`, transition: playing? `transform ${transitionMs/1000}s cubic-bezier(.2,.8,.2,1)` : undefined }}>
{stripItems.map((it, idx) => (
<div key={idx} style={{ width:72, height:72, borderRadius:12, background: theme==='dark'?'#111':'#fff', boxShadow: theme==='dark'?'0 1px 2px rgba(255,255,255,0.08)':'0 1px 2px rgba(0,0,0,0.08)', display:'flex', alignItems:'center', justifyContent:'center', overflow:'hidden', border:'1px solid rgba(0,0,0,0.08)' }}>
{it?.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>
<img src={it.avatar_url} style={{ width:'100%', height:'100%', objectFit:'cover' }} />
) : (
<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)' }} />
<span style={{ fontSize:24, fontWeight:800 }}>{(it?.display_name||'?').slice(0,1)}</span>
)}
</div>
))}
</div>
{/* fade edges */}
<Box pointerEvents="none" position="absolute" left={0} top={0} bottom={0} width="120px" zIndex={1}
style={{ background: theme==='dark' ? 'linear-gradient(to right, rgba(0,0,0,0.85), rgba(0,0,0,0))' : 'linear-gradient(to right, rgba(255,255,255,0.95), rgba(255,255,255,0))' }} />
<Box pointerEvents="none" position="absolute" right={0} top={0} bottom={0} width="120px" zIndex={1}
style={{ background: theme==='dark' ? 'linear-gradient(to left, rgba(0,0,0,0.85), rgba(0,0,0,0))' : 'linear-gradient(to left, rgba(255,255,255,0.95), rgba(255,255,255,0))' }} />
</Box>
<Text mt={4} opacity={0.8} textAlign="center">Ruleta</Text>
</Box>
<Text mt={4} opacity={0.8}>Kolo štěstí</Text>
</Center>
)}
</Box>
<VStack align="stretch" mt={6} spacing={2}>
@@ -62,6 +62,7 @@ import {
import { AddIcon, ArrowUpIcon, ArrowDownIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons';
import { FiUpload } from 'react-icons/fi';
import { uploadFile, createArticle } from '../../services/articles';
import { getImageUrl } from '../../utils/imageUtils';
const fmt = (iso?: string | null) => {
if (!iso) return '';
@@ -76,7 +77,7 @@ const defaultForm = {
rules_url: '',
start_at: '',
end_at: '',
picker_style: 'wheel',
picker_style: 'cycler',
total_prizes: 1,
prize_summary: '',
entry_cost_points: 0,
@@ -114,6 +115,39 @@ const SweepstakesAdminPage: React.FC = () => {
}
};
// Ensure sweepstake exists (auto-create draft) so prizes can be added without manual save
const ensureCreated = async (): Promise<Sweepstake | null> => {
try {
if (editing) return editing;
const title = (form.title && String(form.title).trim()) || 'Nová soutěž';
const now = new Date();
const start = form.start_at ? new Date(form.start_at) : new Date(now.getTime() + 60 * 60 * 1000);
const end = form.end_at ? new Date(form.end_at) : new Date(start.getTime() + 14 * 24 * 60 * 60 * 1000);
const payload = {
title,
description: form.description || '',
image_url: form.image_url || '',
rules_url: form.rules_url || '',
start_at: isNaN(start.getTime()) ? new Date(now.getTime() + 60 * 60 * 1000).toISOString() : start.toISOString(),
end_at: isNaN(end.getTime()) ? new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000).toISOString() : end.toISOString(),
picker_style: form.picker_style || 'cycler',
total_prizes: Number(form.total_prizes) || 1,
prize_summary: form.prize_summary || '',
entry_cost_points: Number(form.entry_cost_points) || 0,
max_entries_per_user: Number(form.max_entries_per_user) || 1,
} as any;
const created = await adminCreateSweepstake(payload);
setEditing(created);
try { setPrizes(await adminListPrizes(created.id)); } catch { setPrizes([]); }
setActiveTab(2);
toast({ status: 'success', title: 'Koncept soutěže vytvořen' });
return created;
} catch {
toast({ status: 'error', title: 'Nelze automaticky vytvořit soutěž' });
return null;
}
};
const onUploadRules = async (file?: File | null) => {
if (!file) return;
try {
@@ -219,12 +253,16 @@ const SweepstakesAdminPage: React.FC = () => {
const tpRaw = Number(form.total_prizes || 1);
const tp = Number.isFinite(tpRaw) ? Math.floor(tpRaw) : 1;
const total_prizes = tp < 1 ? 1 : (tp > 100 ? 100 : tp);
const entry_cost_points = Math.max(0, Number(form.entry_cost_points) || 0);
const max_entries_per_user = Math.max(1, Number(form.max_entries_per_user) || 1);
// Normalize datetime-local (YYYY-MM-DDTHH:mm) to RFC3339 with timezone for Go backend
const s = new Date(form.start_at);
const e = new Date(form.end_at);
const payload = {
...form,
total_prizes,
entry_cost_points,
max_entries_per_user,
start_at: isNaN(s.getTime()) ? form.start_at : s.toISOString(),
end_at: isNaN(e.getTime()) ? form.end_at : e.toISOString(),
};
@@ -360,7 +398,7 @@ const SweepstakesAdminPage: React.FC = () => {
<FormLabel>Titulní obrázek</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<Image src={coverPreview || form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Image src={coverPreview || getImageUrl(form.image_url) || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(!f) return; try { setCoverPreview(URL.createObjectURL(f)); const r=await uploadFile(f); setForm((prev:any)=>({ ...prev, image_url: r.url })); setCoverPreview(''); toast({ status:'success', title:'Obrázek nahrán' }); } catch { toast({ status:'error', title:'Nahrávání selhalo' }); } }} />
@@ -379,7 +417,7 @@ const SweepstakesAdminPage: React.FC = () => {
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
{form.rules_url && (<Button as={RouterLink} to={form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
{form.rules_url && (<Button as="a" href={getImageUrl(form.rules_url) || form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
</HStack>
<Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</VStack>
@@ -409,7 +447,7 @@ const SweepstakesAdminPage: React.FC = () => {
</FormControl>
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
<FormLabel>Počet výherců</FormLabel>
<NumberInput value={Number(form.total_prizes)||1} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(_v, n)=>setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
<NumberInput value={String(form.total_prizes ?? '')} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(v)=>setForm({ ...form, total_prizes: v })}>
<NumberInputField />
</NumberInput>
<FormHelperText>Max. 100 výherců</FormHelperText>
@@ -424,7 +462,7 @@ const SweepstakesAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Max. účastí / uživatel</FormLabel>
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
<NumberInput min={1} keepWithinRange={false} clampValueOnBlur={false} value={String(form.max_entries_per_user ?? '')} onChange={(v)=>setForm({ ...form, max_entries_per_user: v })}>
<NumberInputField />
</NumberInput>
</FormControl>
@@ -436,20 +474,28 @@ const SweepstakesAdminPage: React.FC = () => {
<HStack>
<Button size="sm" onClick={()=>setActiveTab(0)} variant="outline">Zpět na základní</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
if (!editing) { await ensureCreated(); }
setActiveTab(2);
setPrizeForm({ ...prizeForm, name: 'Hlavní výhra', quantity: 1, kind: 'physical', value: '' });
toast({ status:'info', title:'Předvyplněno: Hlavní výhra', description:'Upravte a klikněte Přidat' });
}}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
if (!editing) { await ensureCreated(); }
setActiveTab(2);
setPrizeForm({ ...prizeForm, name: 'Menší výhra', quantity: 3, kind: 'physical', value: '' });
toast({ status:'info', title:'Předvyplněno: 3× Menší výhra', description:'Upravte a klikněte Přidat' });
}}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
if (!editing) { await ensureCreated(); }
setActiveTab(2);
setPrizeForm({ ...prizeForm, name: '100 bodů', quantity: 10, kind:'points', points: 100 });
toast({ status:'info', title:'Předvyplněno: 10× 100 bodů', description:'Upravte a klikněte Přidat' });
}}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
if (!editing) { await ensureCreated(); }
setActiveTab(2);
setPrizeForm({ ...prizeForm, name: '500 XP', quantity: 5, kind:'xp', xp: 500 });
toast({ status:'info', title:'Předvyplněno: 5× 500 XP', description:'Upravte a klikněte Přidat' });
}}>5× 500 XP</Button>
</HStack>
<Divider />
+8
View File
@@ -47,7 +47,15 @@ export async function patchMatchOverride(externalMatchId: string, payload: Parti
body.date_time_override = d.toISOString();
}
}
try {
return (await api.patch(`/admin/match-overrides/${encodeURIComponent(externalMatchId)}`, body)).data;
} catch (err: any) {
const status = err?.response?.status ?? err?.status;
if (status === 404) {
return putMatchOverride(externalMatchId, body);
}
throw err;
}
}
export async function putTeamLogoOverride(externalTeamId: string, teamName: string, logoUrl: string) {
+1 -1
View File
@@ -26,7 +26,7 @@ export async function patchProfile(body: { username?: string }): Promise<{ ok: b
export type RewardItem = {
id: number;
name: string;
type: 'avatar_static' | 'avatar_animated' | 'merch_coupon' | 'custom' | string;
type: 'avatar_static' | 'avatar_animated' | 'merch_coupon' | 'merch_physical' | 'merch_digital' | 'custom' | string;
cost_points: number;
image_url?: string;
stock?: number;
+9
View File
@@ -16,6 +16,7 @@ export interface NavigationItem {
css_class?: string;
requires_auth?: boolean;
requires_admin?: boolean;
allow_editor?: boolean;
}
export interface SocialLink {
@@ -50,6 +51,7 @@ function normalizeNavItem(raw: any): NavigationItem {
css_class: raw.css_class,
requires_auth: raw.requires_auth,
requires_admin: raw.requires_admin,
allow_editor: raw.allow_editor,
} as NavigationItem;
}
@@ -104,6 +106,13 @@ export const reorderNavigationItems = async (orders: { id: number; display_order
await api.post(`/admin/navigation/reorder`, orders);
};
// Editor-allowed admin navigation (for editors' sidebar)
export const getEditorAllowedAdminNav = async (): Promise<NavigationItem[]> => {
const response = await api.get(`/admin/navigation/editor`);
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeNavItem(it));
};
// Social links admin endpoints
export const getAllSocialLinks = async (): Promise<SocialLink[]> => {
const response = await api.get(`/admin/social-links`);
+12 -2
View File
@@ -90,9 +90,19 @@ export async function getScoreboardState(): Promise<ScoreboardState> {
}
export async function saveScoreboardState(state: Partial<ScoreboardState>): Promise<ScoreboardState> {
const current = await getScoreboardState();
const next = { ...current, ...state } as ScoreboardState;
// Avoid an extra GET on every save: use the last known local snapshot as base
let base: ScoreboardState = { ...DEFAULT_STATE } as ScoreboardState;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
base = { ...DEFAULT_STATE, ...(parsed || {}) } as ScoreboardState;
}
} catch {}
const next = { ...base, ...state } as ScoreboardState;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
// Attempt to persist to backend if admin
try {
await api.put('/admin/scoreboard', toApiPayload(state));
+2
View File
@@ -51,6 +51,8 @@ export type CurrentSweepstakeResponse = {
state?: 'upcoming' | 'active' | 'finalized';
has_entered?: boolean;
visual_played_at?: string | null;
my_entries_count?: number;
can_enter?: boolean;
};
export async function getCurrentSweepstake(): Promise<CurrentSweepstakeResponse> {
+30
View File
@@ -298,6 +298,36 @@
text-align: right;
}
/* --- Bullet/Number Fallbacks (robust visibility) --- */
/* Make sure the UI span exists visually and inherits color */
.ql-editor li > .ql-ui {
display: inline-block;
color: inherit;
}
/* Fallback default bullet if theme rules are missing */
.ql-editor li[data-list="bullet"] > .ql-ui::before {
content: '\2022';
}
/* Ordered list fallback using CSS counters (aligns with Quill v2 behavior) */
.ql-editor {
counter-reset: list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol { counter-reset: list-0; }
.ql-editor ol li { counter-increment: list-0; }
.ql-editor li[data-list="ordered"] > .ql-ui::before {
content: counters(list-0, '.') '. ';
}
/* Nested ordered lists (basic support for a few levels) */
.ql-editor ol ol { counter-reset: list-1; }
.ql-editor ol ol li { counter-increment: list-1; }
.ql-editor ol ol li[data-list="ordered"] > .ql-ui::before { content: counters(list-1, '.') '. '; }
.ql-editor ol ol ol { counter-reset: list-2; }
.ql-editor ol ol ol li { counter-increment: list-2; }
.ql-editor ol ol ol li[data-list="ordered"] > .ql-ui::before { content: counters(list-2, '.') '. '; }
.ql-editor blockquote {
border-left: 4px solid #3182ce;
padding-left: 16px;
+6
View File
@@ -39,6 +39,12 @@ body.style-pack-sparta .sponsor-tile:hover { transform: translateY(-8px) scale(1
box-shadow: var(--pack-shadow, none);
}
/* Frontpage CTA card styling */
.newsletter-cta .card {
background: white;
padding: 30px;
}
/* Header & Footer tweaks */
[data-element="header"][data-variant="fullwidth"] { box-shadow: none; }
[data-element="footer"] { border-top: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
+9 -5
View File
@@ -12,8 +12,13 @@ export async function extractPalette(imageUrl: string, maxColors = 12): Promise<
const ctx = canvas.getContext('2d');
if (!ctx) return resolve([]);
// Downscale for performance
const w = 160; // slightly larger for better color sampling
const h = Math.max(1, Math.round((img.height / img.width) * w));
const targetW = 160; // slightly larger for better color sampling
// Prefer naturalWidth/Height; fall back to width/height; if zero (e.g., some SVGs), assume square
const iw = (img as HTMLImageElement).naturalWidth || (img as any).width || 0;
const ih = (img as HTMLImageElement).naturalHeight || (img as any).height || 0;
const ratio = (iw > 0 && ih > 0) ? (ih / iw) : 1;
const w = targetW;
const h = Math.max(1, Math.round(w * ratio));
canvas.width = w;
canvas.height = h;
ctx.drawImage(img, 0, 0, w, h);
@@ -94,10 +99,9 @@ export async function extractPalette(imageUrl: string, maxColors = 12): Promise<
const u = new URL(candidate, window.location.origin);
const isData = u.protocol === 'data:';
const sameOriginAsWindow = u.origin === window.location.origin;
const sameOriginAsBackend = u.origin === backendOrigin;
// Use direct URL if it's same-origin with either the window (served by dev server) or backend (static uploads)
if (isData || sameOriginAsWindow || sameOriginAsBackend) {
// Use direct URL only if it's same-origin with the window; otherwise proxy to enable CORS for Canvas
if (isData || sameOriginAsWindow) {
img.src = u.toString();
} else {
// Otherwise, use backend proxy to obtain CORS-eligible bytes for Canvas
+1 -1
View File
@@ -121,7 +121,7 @@ export function getRewardTypeDisplayName(type: string): string {
avatar_upload_unlock: 'Odemknutí vlastního avataru',
merch_coupon: 'Slevový kupon',
merch_physical: 'Fyzické zboží',
merch_digital: 'Digitální produkt',
merch_digital: 'Digitální odměna',
custom: 'Vlastní',
};
return names[type] || type;
+2
View File
@@ -71,6 +71,7 @@ type Config struct {
ScraperBaseURL string
FrontendBaseURL string
PublicAPIBaseURL string
ZoneramaAPIBase string
// Umami Analytics
UmamiURL string
@@ -181,6 +182,7 @@ func LoadConfig() {
ScraperBaseURL: getEnv("FACR_SCRAPER_BASE_URL", "http://localhost:8081"),
FrontendBaseURL: getEnv("FRONTEND_BASE_URL", "http://localhost:3000"),
PublicAPIBaseURL: getEnv("PUBLIC_API_BASE_URL", "http://localhost:8080/api/v1"),
ZoneramaAPIBase: getEnv("ZONERAMA_API_BASE", "https://zonerama.tdvorak.dev"),
// Umami Analytics
UmamiURL: getEnv("UMAMI_URL", ""),
+25 -13
View File
@@ -567,6 +567,9 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
}
if ov.ScoreOverride != nil {
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
}
@@ -689,6 +692,9 @@ func (bc *BaseController) GetMatches(c *gin.Context) {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
}
if ov.ScoreOverride != nil {
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
}
@@ -901,7 +907,8 @@ func (bc *BaseController) GetZoneramaAlbum(c *gin.Context) {
photoLimit := strings.TrimSpace(c.DefaultQuery("photo_limit", "24"))
rendered := strings.TrimSpace(c.DefaultQuery("rendered", "true"))
// Build external URL
api := "https://zonerama.tdvorak.dev/zonerama-album?link=" + url.QueryEscape(link)
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
api := base + "/zonerama-album?link=" + url.QueryEscape(link)
if photoLimit != "" {
api += "&photo_limit=" + url.QueryEscape(photoLimit)
}
@@ -2471,30 +2478,18 @@ func (bc *BaseController) PutMatchOverride(c *gin.Context) {
c.JSON(http.StatusOK, item)
}
// PatchMatchOverride partially updates fields of an override by external_match_id
func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
extID := c.Param("external_match_id")
if extID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_match_id"})
return
}
var item models.MatchOverride
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
return
}
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Prevent changing the key
delete(body, "external_match_id")
// Normalize date_time_override to *time.Time if provided as string
if v, ok := body["date_time_override"]; ok {
switch vv := v.(type) {
case string:
@@ -2513,6 +2508,23 @@ func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
}
}
}
var item models.MatchOverride
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
attrs := map[string]interface{}{"external_match_id": extID}
for k, v := range body {
attrs[k] = v
}
if err := bc.DB.Where("external_match_id = ?", extID).Assign(attrs).FirstOrCreate(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit záznam"})
return
}
c.JSON(http.StatusOK, item)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
return
}
if err := bc.DB.Model(&item).Updates(body).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
return
+47 -8
View File
@@ -10,7 +10,6 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -166,14 +165,37 @@ func (cc *CommentController) React(c *gin.Context) {
}
uidv, _ := c.Get("userID")
userID := uidv.(uint)
var userID uint
switch v := uidv.(type) {
case uint:
userID = v
case int:
if v > 0 { userID = uint(v) }
case int64:
if v > 0 { userID = uint(v) }
case float64:
if v > 0 { userID = uint(v) }
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
}
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Atomic upsert: enforce single reaction per (comment_id, user_id)
// Robust upsert without relying on a DB unique constraint: delete then insert in a transaction
if err := cc.DB.Transaction(func(tx *gorm.DB) error {
// Remove any previous reaction by this user on this comment
if err := tx.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error; err != nil {
return err
}
// Insert the new reaction
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt}
if err := cc.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{"type": rt, "updated_at": time.Now()}),
}).Create(&r).Error; err != nil {
if err := tx.Create(&r).Error; err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
@@ -194,7 +216,24 @@ func (cc *CommentController) Unreact(c *gin.Context) {
// Ensure reactions table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.CommentReaction{})
uidv, _ := c.Get("userID")
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uidv.(uint)).Delete(&models.CommentReaction{}).Error
var userID uint
switch v := uidv.(type) {
case uint:
userID = v
case int:
if v > 0 { userID = uint(v) }
case int64:
if v > 0 { userID = uint(v) }
case float64:
if v > 0 { userID = uint(v) }
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
}
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+125 -14
View File
@@ -180,6 +180,28 @@ func (cc *ContactController) recalcNewsletterAutomationEnabled() {
s.NewsletterWeeklyHour = 9
changed = true
}
// Auto-activate match reminders with sane defaults if not configured
if !s.EnableMatchReminders {
s.EnableMatchReminders = true
changed = true
}
if s.NewsletterReminderLeadHours <= 0 {
s.NewsletterReminderLeadHours = 48 // 48h before kickoff
changed = true
}
// Auto-activate match results notifications and default quiet hours if missing
if !s.EnableResults {
s.EnableResults = true
changed = true
}
// Only set quiet hours if both are unset (0,0) to avoid overriding admin-configured values
if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 {
s.NewsletterQuietStart = 22 // 22:00
s.NewsletterQuietEnd = 8 // 08:00
changed = true
}
}
if s.ID == 0 {
_ = cc.DB.Create(&s).Error
@@ -511,12 +533,6 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
}
}
}
// Additionally, send a minimal confirmation using newsletter template with manage link (best-effort)
_ = cc.emailService.SendNewsletter(&email.NewsletterData{
Subject: "Vítejte v odběru",
Content: fmt.Sprintf("<p>Děkujeme za přihlášení. Spravujte své preference <a href=\"%s\">zde</a>.</p>", manageURL),
Recipients: []string{emailStr},
})
// Recalculate automation after (re)subscription
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, gin.H{"message": "Subscribed"})
@@ -700,21 +716,89 @@ func (cc *ContactController) SubmitContactForm(c *gin.Context) {
return
}
go func(nm, em, subj, msgBody, ipAddr, agent string) {
go func(m models.ContactMessage) {
// 1) Notify primary contact(s) (club contact email / env fallbacks)
_ = cc.emailService.SendContactForm(&email.ContactFormData{
Name: nm,
Email: em,
Subject: subj,
Message: msgBody,
IPAddress: ipAddr,
UserAgent: agent,
Name: m.Name,
Email: m.Email,
Subject: m.Subject,
Message: m.Message,
IPAddress: m.IPAddress,
UserAgent: m.UserAgent,
})
}(name, emailStr, subject, message, ip, ua)
// 2) Auto-forward to configured list when enabled
var set models.Settings
if err := cc.DB.First(&set).Error; err == nil && set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" {
// Build recipient list from ContactForwardList (comma/semicolon/space separated)
parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
uniq := make(map[string]struct{})
dest := make([]string, 0, len(parts))
// Exclude addresses that already received the primary notification (contact/admin emails)
exclude := map[string]struct{}{}
if v := strings.ToLower(strings.TrimSpace(set.ContactEmail)); v != "" {
exclude[v] = struct{}{}
}
if config.AppConfig != nil {
if v := strings.ToLower(strings.TrimSpace(config.AppConfig.ContactEmail)); v != "" {
exclude[v] = struct{}{}
}
if v := strings.ToLower(strings.TrimSpace(config.AppConfig.AdminEmail)); v != "" {
exclude[v] = struct{}{}
}
}
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
lv := strings.ToLower(v)
if _, ok := uniq[lv]; ok {
continue
}
if _, skip := exclude[lv]; skip {
continue
}
uniq[lv] = struct{}{}
dest = append(dest, v)
}
if len(dest) > 0 {
fwd := &email.EmailData{
Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", strings.TrimSpace(m.Subject)),
To: dest,
Template: "contact_form",
Data: struct {
Name string
Email string
Subject string
Message string
Time string
IP string
Agent string
}{
Name: m.Name,
Email: m.Email,
Subject: m.Subject,
Message: m.Message,
Time: m.CreatedAt.Format(time.RFC1123Z),
IP: m.IPAddress,
Agent: m.UserAgent,
},
}
if err := cc.emailService.SendEmail(fwd); err != nil {
logger.Error("Auto-forward of contact message %d failed: %v", m.ID, err)
} else {
logger.Info("Auto-forwarded contact message %d to %v", m.ID, dest)
}
}
}
}(msg)
c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID})
}
func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
// ... rest of the code remains the same ...
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
@@ -904,6 +988,33 @@ func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
s = models.Settings{}
}
s.NewsletterEnabled = input.Enabled
// If enabling, ensure defaults for weekly/matches/results are set like auto-recalc does
if input.Enabled {
if !s.EnableWeekly {
s.EnableWeekly = true
}
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
s.NewsletterWeeklyDay = "sun"
}
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
s.NewsletterWeeklyHour = 9
}
if !s.EnableMatchReminders {
s.EnableMatchReminders = true
}
if s.NewsletterReminderLeadHours <= 0 {
s.NewsletterReminderLeadHours = 48
}
if !s.EnableResults {
s.EnableResults = true
}
if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 {
s.NewsletterQuietStart = 22
s.NewsletterQuietEnd = 8
}
}
if s.ID == 0 {
if err := cc.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
+235 -77
View File
@@ -2,13 +2,13 @@ package controllers
import (
"net/http"
"strings"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/datatypes"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -26,19 +26,33 @@ func parseMetaTime(v interface{}) time.Time {
switch t := v.(type) {
case string:
s := strings.TrimSpace(t)
if s == "" { return time.Time{} }
if ts, err := time.Parse(time.RFC3339, s); err == nil { return ts }
if ts, err := time.Parse("2006-01-02T15:04", s); err == nil { return ts }
if ts, err := time.Parse("2006-01-02", s); err == nil { return ts }
if s == "" {
return time.Time{}
}
if ts, err := time.Parse(time.RFC3339, s); err == nil {
return ts
}
if ts, err := time.Parse("2006-01-02T15:04", s); err == nil {
return ts
}
if ts, err := time.Parse("2006-01-02", s); err == nil {
return ts
}
case float64:
// JSON numbers decode to float64
if t <= 0 { return time.Time{} }
if t <= 0 {
return time.Time{}
}
return time.Unix(int64(t), 0)
case int64:
if t <= 0 { return time.Time{} }
if t <= 0 {
return time.Time{}
}
return time.Unix(t, 0)
case int:
if t <= 0 { return time.Time{} }
if t <= 0 {
return time.Time{}
}
return time.Unix(int64(t), 0)
}
return time.Time{}
@@ -59,25 +73,32 @@ func (ec *EngagementController) AdminAdjustPoints(c *gin.Context) {
CurrentPassword string `json:"current_password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 || body.Delta == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
// Require admin password confirmation for any manual adjustment
cu, ok := c.Get("user")
if !ok || cu == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error":"not authenticated"}); return
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
if strings.TrimSpace(body.CurrentPassword) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"current_password is required"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "current_password is required"})
return
}
currentUser := cu.(*models.User)
if err := utils.CheckPassword(body.CurrentPassword, currentUser.Password); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error":"invalid current password"}); return
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid current password"})
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" { reason = "admin_adjust" }
if reason == "" {
reason = "admin_adjust"
}
svc := services.NewEngagementService(ec.DB)
if _, err := svc.AwardPoints(body.UserID, body.Delta, reason, body.Meta); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to adjust points"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to adjust points"})
return
}
// Re-check achievements opportunistically
_ = svc.CheckAndAwardAchievements(body.UserID)
@@ -232,8 +253,12 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
_ = ec.DB.Create(&unlock).Error
} else {
updates := map[string]interface{}{}
if !unlock.Active { updates["active"] = true }
if unlock.Stock != -1 { updates["stock"] = -1 }
if !unlock.Active {
updates["active"] = true
}
if unlock.Stock != -1 {
updates["stock"] = -1
}
if strings.TrimSpace(unlock.Name) == "" || unlock.Name != "Odemknout vlastní avatar (upload)" {
updates["name"] = "Odemknout vlastní avatar (upload)"
}
@@ -259,16 +284,26 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
var startPtr, endPtr *time.Time
if it.Metadata != nil {
if v, ok := it.Metadata["valid_from"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
if ts := parseMetaTime(v); !ts.IsZero() {
startPtr = &ts
}
}
if v, ok := it.Metadata["valid_to"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
if ts := parseMetaTime(v); !ts.IsZero() {
endPtr = &ts
}
} else if v2, ok2 := it.Metadata["expires_at"]; ok2 { // alias
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
if ts := parseMetaTime(v2); !ts.IsZero() {
endPtr = &ts
}
}
if startPtr != nil && now.Before(*startPtr) { continue }
if endPtr != nil && now.After(*endPtr) { continue }
}
if startPtr != nil && now.Before(*startPtr) {
continue
}
if endPtr != nil && now.After(*endPtr) {
continue
}
filtered = append(filtered, it)
}
c.JSON(http.StatusOK, filtered)
@@ -303,12 +338,18 @@ func (ec *EngagementController) Redeem(c *gin.Context) {
if item.Metadata != nil {
var startPtr, endPtr *time.Time
if v, ok := item.Metadata["valid_from"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
if ts := parseMetaTime(v); !ts.IsZero() {
startPtr = &ts
}
}
if v, ok := item.Metadata["valid_to"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
if ts := parseMetaTime(v); !ts.IsZero() {
endPtr = &ts
}
} else if v2, ok2 := item.Metadata["expires_at"]; ok2 {
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
if ts := parseMetaTime(v2); !ts.IsZero() {
endPtr = &ts
}
}
now := time.Now()
if startPtr != nil && now.Before(*startPtr) {
@@ -411,11 +452,17 @@ func (ec *EngagementController) Redeem(c *gin.Context) {
var set models.Settings
_ = ec.DB.First(&set).Error
ownerEmail := strings.TrimSpace(set.ContactEmail)
if ownerEmail == "" { ownerEmail = strings.TrimSpace(set.SMTPFrom) }
if ownerEmail == "" {
ownerEmail = strings.TrimSpace(set.SMTPFrom)
}
if ownerEmail != "" {
manageURL := ""
if base := strings.TrimSpace(set.CanonicalBaseURL); base != "" {
if strings.HasSuffix(base, "/") { manageURL = base + "admin/engagement" } else { manageURL = base + "/admin/engagement" }
if strings.HasSuffix(base, "/") {
manageURL = base + "admin/engagement"
} else {
manageURL = base + "/admin/engagement"
}
}
fullName := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName))
_ = ec.Email.SendEmail(&email.EmailData{
@@ -506,18 +553,31 @@ func (ec *EngagementController) AdminListRewards(c *gin.Context) {
_ = ec.DB.Create(&unlock).Error
} else {
updates := map[string]interface{}{}
if !unlock.Active { updates["active"] = true }
if unlock.Stock != -1 { updates["stock"] = -1 }
if strings.TrimSpace(unlock.Name) == "" || unlock.Name != "Odemknout vlastní avatar (upload)" { updates["name"] = "Odemknout vlastní avatar (upload)" }
if len(updates) > 0 { _ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Updates(updates).Error }
if !unlock.Active {
updates["active"] = true
}
if unlock.Stock != -1 {
updates["stock"] = -1
}
if strings.TrimSpace(unlock.Name) == "" || unlock.Name != "Odemknout vlastní avatar (upload)" {
updates["name"] = "Odemknout vlastní avatar (upload)"
}
if len(updates) > 0 {
_ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Updates(updates).Error
}
}
q := ec.DB.Model(&models.RewardItem{})
if v := strings.TrimSpace(c.Query("active")); v != "" {
if v == "true" || v == "1" { q = q.Where("active = ?", true) }
if v == "false" || v == "0" { q = q.Where("active = ?", false) }
if v == "true" || v == "1" {
q = q.Where("active = ?", true)
}
if v == "false" || v == "0" {
q = q.Where("active = ?", false)
}
}
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load rewards"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -535,12 +595,26 @@ func (ec *EngagementController) AdminCreateReward(c *gin.Context) {
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Type) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
// Disallow creating any avatar_* rewards via admin (managed automatically by system)
t := strings.ToLower(strings.TrimSpace(body.Type))
if strings.HasPrefix(t, "avatar_") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar odměny nelze vytvářet v administraci"})
return
}
item := models.RewardItem{Name: strings.TrimSpace(body.Name), Type: strings.TrimSpace(body.Type), CostPoints: body.CostPoints, ImageURL: strings.TrimSpace(body.ImageURL), Stock: body.Stock, Active: true}
if body.Active != nil { item.Active = *body.Active }
if body.Metadata != nil { item.Metadata = body.Metadata }
if err := ec.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create reward"}); return }
if body.Active != nil {
item.Active = *body.Active
}
if body.Metadata != nil {
item.Metadata = body.Metadata
}
if err := ec.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create reward"})
return
}
c.JSON(http.StatusOK, item)
}
@@ -557,33 +631,71 @@ func (ec *EngagementController) AdminUpdateReward(c *gin.Context) {
Active *bool `json:"active"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
// Load existing to enforce invariants on mandatory reward
var existing models.RewardItem
_ = ec.DB.First(&existing, id).Error
if strings.EqualFold(existing.Type, "avatar_upload_unlock") {
// Disallow disabling or changing type, and restrict updates to cost_points only
if body.Active != nil && *body.Active == false {
c.JSON(http.StatusBadRequest, gin.H{"error": "This reward cannot be deactivated"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "This reward cannot be deactivated"})
return
}
if body.Type != nil && strings.ToLower(strings.TrimSpace(*body.Type)) != existing.Type {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type cannot be changed for this reward"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Type cannot be changed for this reward"})
return
}
if body.Name != nil || body.ImageURL != nil || body.Stock != nil || body.Active != nil || body.Metadata != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only price (cost_points) can be edited for this reward"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Only price (cost_points) can be edited for this reward"})
return
}
}
// Do not allow changing type to any avatar_*
if body.Type != nil {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(*body.Type)), "avatar_") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nelze změnit typ na avatar_*"})
return
}
}
// Do not allow deactivating any avatar_* reward (legacy ones should stay active)
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(existing.Type)), "avatar_") {
if body.Active != nil && *body.Active == false {
c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar odměny nelze deaktivovat"})
return
}
}
updates := map[string]interface{}{}
if body.Name != nil { updates["name"] = strings.TrimSpace(*body.Name) }
if body.Type != nil { updates["type"] = strings.TrimSpace(*body.Type) }
if body.CostPoints != nil { updates["cost_points"] = *body.CostPoints }
if body.ImageURL != nil { updates["image_url"] = strings.TrimSpace(*body.ImageURL) }
if body.Stock != nil { updates["stock"] = *body.Stock }
if body.Active != nil { updates["active"] = *body.Active }
if body.Metadata != nil { updates["metadata"] = body.Metadata }
if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if body.Name != nil {
updates["name"] = strings.TrimSpace(*body.Name)
}
if body.Type != nil {
updates["type"] = strings.TrimSpace(*body.Type)
}
if body.CostPoints != nil {
updates["cost_points"] = *body.CostPoints
}
if body.ImageURL != nil {
updates["image_url"] = strings.TrimSpace(*body.ImageURL)
}
if body.Stock != nil {
updates["stock"] = *body.Stock
}
if body.Active != nil {
updates["active"] = *body.Active
}
if body.Metadata != nil {
updates["metadata"] = body.Metadata
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"})
return
}
if err := ec.DB.Model(&models.RewardItem{}).Where("id = ?", id).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update reward"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update reward"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -595,12 +707,14 @@ func (ec *EngagementController) AdminDeleteReward(c *gin.Context) {
// Disallow deleting the mandatory reward
var existing models.RewardItem
if err := ec.DB.First(&existing, id).Error; err == nil {
if strings.EqualFold(existing.Type, "avatar_upload_unlock") {
c.JSON(http.StatusBadRequest, gin.H{"error": "This reward cannot be deleted"}); return
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(existing.Type)), "avatar_") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar odměny nelze mazat"})
return
}
}
if err := ec.DB.Delete(&models.RewardItem{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to delete reward"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete reward"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -610,9 +724,12 @@ func (ec *EngagementController) AdminDeleteReward(c *gin.Context) {
func (ec *EngagementController) AdminListRedemptions(c *gin.Context) {
var items []models.RewardRedemption
q := ec.DB.Model(&models.RewardRedemption{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
if v := strings.TrimSpace(c.Query("status")); v != "" {
q = q.Where("status = ?", v)
}
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load redemptions"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load redemptions"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -621,21 +738,31 @@ func (ec *EngagementController) AdminListRedemptions(c *gin.Context) {
// PATCH /api/v1/admin/engagement/redemptions/:id
func (ec *EngagementController) AdminUpdateRedemptionStatus(c *gin.Context) {
id := c.Param("id")
var body struct{ Action string `json:"action"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
var body struct {
Action string `json:"action"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
action := strings.ToLower(strings.TrimSpace(body.Action))
var newStatus string
switch action {
case "approve": newStatus = "approved"
case "reject": newStatus = "rejected"
case "fulfill": newStatus = "fulfilled"
case "approve":
newStatus = "approved"
case "reject":
newStatus = "rejected"
case "fulfill":
newStatus = "fulfilled"
default:
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
// Load redemption to know user and reward
var red models.RewardRedemption
if err := ec.DB.First(&red, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error":"Redemption not found"}); return
c.JSON(http.StatusNotFound, gin.H{"error": "Redemption not found"})
return
}
// If rejecting a pending manual redemption, refund points and restore stock in a transaction
if newStatus == "rejected" {
@@ -644,23 +771,38 @@ func (ec *EngagementController) AdminUpdateRedemptionStatus(c *gin.Context) {
if err := ec.DB.First(&reward, red.RewardID).Error; err == nil {
tx := ec.DB.Begin()
// Update status first
if err := tx.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update status"}); return }
if err := tx.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"})
return
}
// Refund points
if err := tx.Model(&models.UserProfile{}).Where("user_id = ?", red.UserID).UpdateColumn("points", gorm.Expr("points + ?", reward.CostPoints)).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to refund points"}); return }
if err := tx.Model(&models.UserProfile{}).Where("user_id = ?", red.UserID).UpdateColumn("points", gorm.Expr("points + ?", reward.CostPoints)).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refund points"})
return
}
// Log refund transaction (no XP)
_ = tx.Create(&models.PointsTransaction{UserID: red.UserID, Delta: reward.CostPoints, XPDelta: 0, Reason: "redeem_refund", Meta: datatypes.JSONMap{"reward_id": reward.ID, "reward_type": reward.Type}}).Error
// Restore stock when finite
if reward.Stock >= 0 {
_ = tx.Model(&models.RewardItem{}).Where("id = ?", reward.ID).UpdateColumn("stock", gorm.Expr("stock + 1")).Error
}
if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to finalize refund"}); return }
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize refund"})
return
}
} else {
// Fallback: update status only if reward missing
if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update status"}); return }
if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"})
return
}
}
} else {
if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update status"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"})
return
}
}
// Notify user about final decision for manual rewards (best-effort)
@@ -703,7 +845,9 @@ func (ec *EngagementController) GetMyTransactions(c *gin.Context) {
limit := 50
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
if n > 0 && n <= 200 { limit = n }
if n > 0 && n <= 200 {
limit = n
}
}
}
q := ec.DB.Model(&models.PointsTransaction{}).Where("user_id = ?", userID)
@@ -712,7 +856,8 @@ func (ec *EngagementController) GetMyTransactions(c *gin.Context) {
}
var items []models.PointsTransaction
if err := q.Order("created_at DESC").Limit(limit).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load transactions"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load transactions"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -758,7 +903,9 @@ func (ec *EngagementController) ArticleRead(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP})
return
}
var body struct{ ArticleID uint `json:"article_id"` }
var body struct {
ArticleID uint `json:"article_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ArticleID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
@@ -928,10 +1075,14 @@ func (ec *EngagementController) AdminGetLeaderboard(c *gin.Context) {
// GET /api/v1/admin/engagement/profile/:user_id (admin)
func (ec *EngagementController) AdminGetUserProfile(c *gin.Context) {
userIDStr := strings.TrimSpace(c.Param("user_id"))
if userIDStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error":"user_id required"}); return }
if userIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id required"})
return
}
var up models.UserProfile
if err := ec.DB.Where("user_id = ?", userIDStr).First(&up).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error":"Profile not found"}); return
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
return
}
// Optionally include user basic info
var u models.User
@@ -957,15 +1108,22 @@ func (ec *EngagementController) AdminGetUserProfile(c *gin.Context) {
// GET /api/v1/admin/engagement/transactions?user_id=&reason=&limit=
func (ec *EngagementController) AdminListTransactions(c *gin.Context) {
q := ec.DB.Model(&models.PointsTransaction{})
if uid := strings.TrimSpace(c.Query("user_id")); uid != "" { q = q.Where("user_id = ?", uid) }
if r := strings.TrimSpace(c.Query("reason")); r != "" { q = q.Where("reason = ?", r) }
if uid := strings.TrimSpace(c.Query("user_id")); uid != "" {
q = q.Where("user_id = ?", uid)
}
if r := strings.TrimSpace(c.Query("reason")); r != "" {
q = q.Where("reason = ?", r)
}
limit := 100
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 { limit = n }
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
var items []models.PointsTransaction
if err := q.Order("created_at DESC").Limit(limit).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load transactions"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load transactions"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
+6 -3
View File
@@ -5,11 +5,13 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
@@ -149,9 +151,10 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
body.PhotoLimit = 50 // Default to 50 photos per album
}
// Call external API
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
body.Link, body.PhotoLimit)
// Call external API (configurable base)
apiBase := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d",
apiBase, url.QueryEscape(body.Link), body.PhotoLimit)
logger.Info("Fetching album from Zonerama API: %s", apiURL)
+77 -1
View File
@@ -262,6 +262,11 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
updates["requires_admin"] = b
}
}
if v, ok := raw["allow_editor"]; ok {
if b, ok2 := v.(bool); ok2 {
updates["allow_editor"] = b
}
}
if len(updates) == 0 {
// Nothing to update
@@ -372,6 +377,72 @@ func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
c.JSON(http.StatusOK, links)
}
// GetEditorAllowedAdminNav returns admin navigation items that are explicitly allowed for editors
// Top-level items are included only when:
// - type != dropdown and allow_editor = true (and visible = true), or
// - type == dropdown and it has at least one child with allow_editor = true (and visible = true)
//
// Children are filtered to allow_editor = true and visible = true
func (nc *NavigationController) GetEditorAllowedAdminNav(c *gin.Context) {
var top []models.NavigationItem
// Load all top-level admin items (categories and direct items)
if err := nc.DB.Where("parent_id IS NULL AND requires_admin = ? AND visible = ?", true, true).
Order("display_order ASC").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("requires_admin = ? AND visible = ? AND allow_editor = ?", true, true, true).Order("display_order ASC")
}).
Find(&top).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch editor navigation"})
return
}
// Filter according to allow_editor rules
out := make([]models.NavigationItem, 0, len(top))
// Only allow a curated set of admin pages that have editor-capable APIs
allowed := map[string]bool{
"articles": true,
"activities": true,
"shortlinks": true,
}
for i := range top {
it := top[i]
include := false
if it.Type == models.NavTypeDropdown {
// Filter children by page_type allow-list (children already have allow_editor=true from preload)
if len(it.Children) > 0 {
children := make([]models.NavigationItem, 0, len(it.Children))
for _, ch := range it.Children {
if allowed[ch.PageType] {
// ensure URL is set
if ch.URL == "" {
ch.URL = ch.GetURL()
}
children = append(children, ch)
}
}
it.Children = children
if len(it.Children) > 0 {
include = true
}
}
} else {
// direct admin page: include only when marked allow_editor
if it.AllowEditor && allowed[it.PageType] {
include = true
}
}
if include {
// Ensure URLs are computed
if it.URL == "" {
it.URL = it.GetURL()
}
out = append(out, it)
}
}
c.JSON(http.StatusOK, out)
}
// GetAllSocialLinks returns all social links including hidden ones (admin only)
// @Summary Get all social links (admin)
// @Description Returns all social links for admin management
@@ -593,7 +664,12 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
allowEditor := false
switch pageType {
case "articles", "activities", "shortlinks":
allowEditor = true
}
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true, AllowEditor: allowEditor}
child.ParentID = &pid
return tx.Create(child).Error
}
+48 -14
View File
@@ -50,7 +50,9 @@ func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) {
// Allow only same-site or configured frontend host
reqHost := c.Request.Host
stripPort := func(h string) string {
if i := strings.IndexByte(h, ':'); i >= 0 { return h[:i] }
if i := strings.IndexByte(h, ':'); i >= 0 {
return h[:i]
}
return h
}
allowed := stripPort(tu.Host) == stripPort(reqHost)
@@ -125,7 +127,9 @@ func hashIPShort(ip string) string {
}
func codeFromHash(s string, n int) string {
if n <= 0 { n = 7 }
if n <= 0 {
n = 7
}
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
sum := sha256.Sum256([]byte(s))
out := make([]byte, n)
@@ -138,7 +142,9 @@ func codeFromHash(s string, n int) string {
func sanitizeCode(in string) string {
s := strings.TrimSpace(in)
if s == "" { return "" }
if s == "" {
return ""
}
// filter allowed runes
rb := make([]rune, 0, len(s))
for _, ch := range s {
@@ -146,7 +152,9 @@ func sanitizeCode(in string) string {
rb = append(rb, ch)
}
}
if len(rb) == 0 { return "" }
if len(rb) == 0 {
return ""
}
if len(rb) > 16 {
rb = rb[:16]
}
@@ -174,12 +182,21 @@ func parseTarget(raw string) (string, error) {
raw = string(dec)
}
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", errors.New("invalid url")
}
// Try as-is first
if u, err := url.Parse(raw); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
return u.String(), nil
}
// If scheme is missing, try https:// fallback, then http://
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
if u, err := url.Parse("https://" + raw); err == nil && u.Host != "" {
return u.String(), nil
}
if u, err := url.Parse("http://" + raw); err == nil && u.Host != "" {
return u.String(), nil
}
}
return "", errors.New("invalid url")
}
func (s *ShortLinkController) RedirectShort(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
@@ -290,7 +307,9 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
return
}
active := true
if body.Active != nil { active = *body.Active }
if body.Active != nil {
active = *body.Active
}
link := models.ShortLink{
Code: code,
TargetURL: target,
@@ -329,22 +348,37 @@ func (s *ShortLinkController) ListShortLinks(c *gin.Context) {
func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}); return }
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"})
return
}
var link models.ShortLink
if err := s.DB.First(&link, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}); return }
if err := s.DB.First(&link, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
start := time.Now().AddDate(0, 0, -30)
type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` }
type Row struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
var rows []Row
s.DB.Model(&models.LinkClick{}).
Select("DATE(created_at) as date, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
Group("DATE(created_at)").Order("date ASC").Scan(&rows)
var refRows []struct{ Referrer string; Count int64 }
var refRows []struct {
Referrer string
Count int64
}
s.DB.Model(&models.LinkClick{}).
Select("referrer, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
Group("referrer").Order("count DESC").Limit(20).Scan(&refRows)
var utmRows []struct{ Source, Medium, Campaign string; Count int64 }
var utmRows []struct {
Source, Medium, Campaign string
Count int64
}
s.DB.Model(&models.LinkClick{}).
Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
+336 -87
View File
@@ -29,12 +29,19 @@ func NewSweepstakesController(db *gorm.DB, es email.EmailService) *SweepstakesCo
func (sc *SweepstakesController) PublicVisualData(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
if err := sc.DB.First(&s, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
now := time.Now()
if s.VisibilityUntil == nil || now.After(*s.VisibilityUntil) || now.Before(s.EndAt) {
c.JSON(http.StatusNotFound, gin.H{"error":"Not available"}); return
c.JSON(http.StatusNotFound, gin.H{"error": "Not available"})
return
}
var winners []struct {
UserID uint `json:"user_id"`
PrizeName string `json:"prize_name"`
}
var winners []struct{ UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"` }
_ = sc.DB.Table("sweepstake_winners").Select("user_id, prize_name").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error
type entryRow struct {
UserID uint `json:"user_id"`
@@ -55,13 +62,24 @@ func (sc *SweepstakesController) PublicVisualData(c *gin.Context) {
// PATCH /api/v1/admin/sweepstakes/:id/winners/:winner_id/prize { "prize_id": 123 }
func (sc *SweepstakesController) AdminSetWinnerPrize(c *gin.Context) {
wid := strings.TrimSpace(c.Param("winner_id"))
var body struct{ PrizeID uint `json:"prize_id"` }
if err := c.ShouldBindJSON(&body); err != nil || body.PrizeID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize"}); return }
var body struct {
PrizeID uint `json:"prize_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.PrizeID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid prize"})
return
}
// Load prize name
var p models.SweepstakePrize
if err := sc.DB.First(&p, body.PrizeID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Prize not found"}); return }
if err := sc.DB.First(&p, body.PrizeID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Prize not found"})
return
}
updates := map[string]interface{}{"prize_id": p.ID, "prize_name": strings.TrimSpace(p.Name)}
if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -73,20 +91,31 @@ func (sc *SweepstakesController) AdminUpdateWinner(c *gin.Context) {
ClaimStatus string `json:"claim_status"`
ClaimNote string `json:"claim_note"`
}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
st := strings.ToLower(strings.TrimSpace(body.ClaimStatus))
if st == "" { st = "pending" }
if st == "" {
st = "pending"
}
switch st {
case "pending", "claimed", "delivered":
default:
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid status"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status"})
return
}
// Load winner to evaluate prize awarding
var w models.SweepstakeWinner
if err := sc.DB.First(&w, wid).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
if err := sc.DB.First(&w, wid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
// Update fields
updates := map[string]interface{}{"claim_status": st}
if strings.TrimSpace(body.ClaimNote) != "" { updates["claim_note"] = strings.TrimSpace(body.ClaimNote) }
if strings.TrimSpace(body.ClaimNote) != "" {
updates["claim_note"] = strings.TrimSpace(body.ClaimNote)
}
// Award non-physical prizes only once when moving to claimed/delivered
shouldAward := (st == "claimed" || st == "delivered") && (w.AwardedAt == nil)
if shouldAward && w.PrizeID != nil {
@@ -96,9 +125,12 @@ func (sc *SweepstakesController) AdminUpdateWinner(c *gin.Context) {
svc := services.NewEngagementService(sc.DB)
var pointsDelta, xpDelta int64
switch p.Kind {
case "points": pointsDelta, xpDelta = p.Points, 0
case "xp": pointsDelta, xpDelta = 0, p.XP
case "points_xp": pointsDelta, xpDelta = p.Points, p.XP
case "points":
pointsDelta, xpDelta = p.Points, 0
case "xp":
pointsDelta, xpDelta = 0, p.XP
case "points_xp":
pointsDelta, xpDelta = p.Points, p.XP
}
if pointsDelta != 0 || xpDelta != 0 {
_, _ = svc.AwardPointsAndXP(w.UserID, pointsDelta, xpDelta, "sweepstake_prize", map[string]interface{}{"prize_id": p.ID, "sweepstake_id": w.SweepstakeID})
@@ -108,7 +140,10 @@ func (sc *SweepstakesController) AdminUpdateWinner(c *gin.Context) {
}
}
}
if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Model(&models.SweepstakeWinner{}).Where("id = ?", wid).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -118,7 +153,8 @@ func (sc *SweepstakesController) AdminListPrizes(c *gin.Context) {
id := c.Param("id")
var items []models.SweepstakePrize
if err := sc.DB.Where("sweepstake_id = ?", id).Order("display_order ASC, id ASC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -128,7 +164,10 @@ func (sc *SweepstakesController) AdminListPrizes(c *gin.Context) {
func (sc *SweepstakesController) AdminCreatePrize(c *gin.Context) {
sid := strings.TrimSpace(c.Param("id"))
var s models.Sweepstake
if err := sc.DB.First(&s, sid).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Sweepstake not found"}); return }
if err := sc.DB.First(&s, sid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Sweepstake not found"})
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -141,20 +180,31 @@ func (sc *SweepstakesController) AdminCreatePrize(c *gin.Context) {
XP int64 `json:"xp"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
// Normalize prize kind/values
kind := strings.ToLower(strings.TrimSpace(body.Kind))
switch kind {
case "", "physical", "points", "xp", "points_xp":
if kind == "" { kind = "physical" }
default:
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize kind"}); return
if kind == "" {
kind = "physical"
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid prize kind"})
return
}
if body.Points < 0 {
body.Points = 0
}
if body.XP < 0 {
body.XP = 0
}
if body.Points < 0 { body.Points = 0 }
if body.XP < 0 { body.XP = 0 }
p := models.SweepstakePrize{SweepstakeID: s.ID, Name: strings.TrimSpace(body.Name), Description: strings.TrimSpace(body.Description), ImageURL: strings.TrimSpace(body.ImageURL), Value: strings.TrimSpace(body.Value), Quantity: body.Quantity, DisplayOrder: body.DisplayOrder, Kind: kind, Points: body.Points, XP: body.XP}
if err := sc.DB.Create(&p).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Create(&p).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, p)
}
@@ -163,10 +213,17 @@ func (sc *SweepstakesController) AdminCreatePrize(c *gin.Context) {
func (sc *SweepstakesController) AdminUpdatePrize(c *gin.Context) {
pid := strings.TrimSpace(c.Param("prize_id"))
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
allowed := map[string]bool{"name": true, "description": true, "image_url": true, "value": true, "quantity": true, "display_order": true, "kind": true, "points": true, "xp": true}
upd := map[string]interface{}{}
for k,v := range body { if allowed[k] { upd[k] = v } }
for k, v := range body {
if allowed[k] {
upd[k] = v
}
}
// Validate kind if present
if v, ok := upd["kind"]; ok {
sv := strings.ToLower(strings.TrimSpace(toString(v)))
@@ -174,14 +231,25 @@ func (sc *SweepstakesController) AdminUpdatePrize(c *gin.Context) {
case "physical", "points", "xp", "points_xp":
upd["kind"] = sv
default:
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid prize kind"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid prize kind"})
return
}
}
// Coerce points/xp to non-negative integers if present
if v, ok := upd["points"]; ok { upd["points"] = toNonNegInt64(v) }
if v, ok := upd["xp"]; ok { upd["xp"] = toNonNegInt64(v) }
if len(upd) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if err := sc.DB.Model(&models.SweepstakePrize{}).Where("id = ?", pid).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if v, ok := upd["points"]; ok {
upd["points"] = toNonNegInt64(v)
}
if v, ok := upd["xp"]; ok {
upd["xp"] = toNonNegInt64(v)
}
if len(upd) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"})
return
}
if err := sc.DB.Model(&models.SweepstakePrize{}).Where("id = ?", pid).Updates(upd).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -189,7 +257,10 @@ func (sc *SweepstakesController) AdminUpdatePrize(c *gin.Context) {
// DELETE /api/v1/admin/sweepstakes/:id/prizes/:prize_id
func (sc *SweepstakesController) AdminDeletePrize(c *gin.Context) {
pid := strings.TrimSpace(c.Param("prize_id"))
if err := sc.DB.Delete(&models.SweepstakePrize{}, "id = ?", pid).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Delete(&models.SweepstakePrize{}, "id = ?", pid).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -197,13 +268,25 @@ func (sc *SweepstakesController) AdminDeletePrize(c *gin.Context) {
// POST /api/v1/admin/sweepstakes/:id/prizes/reorder { "order": [prize_id...] }
func (sc *SweepstakesController) AdminReorderPrizes(c *gin.Context) {
sid := strings.TrimSpace(c.Param("id"))
var body struct{ Order []uint `json:"order"` }
if err := c.ShouldBindJSON(&body); err != nil || len(body.Order) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid order"}); return }
var body struct {
Order []uint `json:"order"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Order) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid order"})
return
}
tx := sc.DB.Begin()
for i, id := range body.Order {
if err := tx.Model(&models.SweepstakePrize{}).Where("id = ? AND sweepstake_id = ?", id, sid).Update("display_order", i).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := tx.Model(&models.SweepstakePrize{}).Where("id = ? AND sweepstake_id = ?", id, sid).Update("display_order", i).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
}
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -213,10 +296,16 @@ func (sc *SweepstakesController) AdminVisualData(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
// Winners in stable order
var winners []struct{ ID uint `json:"id"`; UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"`; ClaimStatus string `json:"claim_status"` }
var winners []struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
PrizeName string `json:"prize_name"`
ClaimStatus string `json:"claim_status"`
}
_ = sc.DB.Table("sweepstake_winners").Select("id, user_id, prize_name, claim_status").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error
// Entries with display names and avatars
type entryRow struct {
@@ -243,8 +332,13 @@ func (sc *SweepstakesController) AdminList(c *gin.Context) {
status := strings.TrimSpace(c.Query("status"))
var items []models.Sweepstake
q := sc.DB.Model(&models.Sweepstake{}).Order("start_at DESC, id DESC")
if status != "" { q = q.Where("status = ?", status) }
if err := q.Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if status != "" {
q = q.Where("status = ?", status)
}
if err := q.Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -265,7 +359,8 @@ func (sc *SweepstakesController) AdminCreate(c *gin.Context) {
MaxEntriesPerUser int `json:"max_entries_per_user"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Title) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
item := models.Sweepstake{
Title: strings.TrimSpace(body.Title),
@@ -274,15 +369,43 @@ func (sc *SweepstakesController) AdminCreate(c *gin.Context) {
RulesURL: strings.TrimSpace(body.RulesURL),
StartAt: body.StartAt, EndAt: body.EndAt,
PickerStyle: ifEmpty(body.PickerStyle, "wheel"),
TotalPrizes: func(v int) int { if v < 1 { return 1 }; if v > 100 { return 100 }; return v }(ifZero(body.TotalPrizes, 1)),
TotalPrizes: func(v int) int {
if v < 1 {
return 1
}
if v > 100 {
return 100
}
return v
}(ifZero(body.TotalPrizes, 1)),
PrizeSummary: strings.TrimSpace(body.PrizeSummary),
EntryCostPoints: func(v int) int { if v < 0 { return 0 }; return v }(body.EntryCostPoints),
EntryFeeCZK: func(v float64) float64 { if v < 0 { return 0 }; return v }(body.EntryFeeCZK),
MaxEntriesPerUser: func(v int) int { if v <= 0 { return 1 }; return v }(body.MaxEntriesPerUser),
EntryCostPoints: func(v int) int {
if v < 0 {
return 0
}
return v
}(body.EntryCostPoints),
EntryFeeCZK: func(v float64) float64 {
if v < 0 {
return 0
}
return v
}(body.EntryFeeCZK),
MaxEntriesPerUser: func(v int) int {
if v <= 0 {
return 1
}
return v
}(body.MaxEntriesPerUser),
Status: "scheduled",
}
if time.Now().After(item.StartAt) { item.Status = "active" }
if err := sc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create"}); return }
if time.Now().After(item.StartAt) {
item.Status = "active"
}
if err := sc.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create"})
return
}
c.JSON(http.StatusOK, item)
}
@@ -290,11 +413,21 @@ func (sc *SweepstakesController) AdminCreate(c *gin.Context) {
func (sc *SweepstakesController) AdminUpdate(c *gin.Context) {
id := c.Param("id")
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
allowed := map[string]bool{"title": true, "description": true, "image_url": true, "rules_url": true, "start_at": true, "end_at": true, "picker_style": true, "total_prizes": true, "prize_summary": true, "status": true, "entry_cost_points": true, "entry_fee_czk": true, "max_entries_per_user": true}
upd := map[string]interface{}{}
for k,v := range body { if allowed[k] { upd[k] = v } }
if len(upd)==0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
for k, v := range body {
if allowed[k] {
upd[k] = v
}
}
if len(upd) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"})
return
}
// Clamp total_prizes if provided
if v, ok := upd["total_prizes"]; ok {
// Coerce to integer first
@@ -307,22 +440,34 @@ func (sc *SweepstakesController) AdminUpdate(c *gin.Context) {
case float64:
vv = int(t)
case string:
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { vv = n }
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
vv = n
}
default:
// leave default 1
}
if vv < 1 { vv = 1 }
if vv > 100 { vv = 100 }
if vv < 1 {
vv = 1
}
if vv > 100 {
vv = 100
}
upd["total_prizes"] = vv
}
if err := sc.DB.Model(&models.Sweepstake{}).Where("id = ?", id).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Model(&models.Sweepstake{}).Where("id = ?", id).Updates(upd).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: delete sweepstake
func (sc *SweepstakesController) AdminDelete(c *gin.Context) {
id := c.Param("id")
if err := sc.DB.Delete(&models.Sweepstake{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Delete(&models.Sweepstake{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -332,32 +477,71 @@ func (sc *SweepstakesController) Enter(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }; c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return }
if err := sc.DB.First(&s, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load"})
return
}
now := time.Now()
if !(now.After(s.StartAt) && now.Before(s.EndAt)) { c.JSON(http.StatusBadRequest, gin.H{"error":"Soutěž není aktivní"}); return }
if !(now.After(s.StartAt) && now.Before(s.EndAt)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Soutěž není aktivní"})
return
}
maxPerUser := s.MaxEntriesPerUser
if maxPerUser <= 0 { maxPerUser = 1 }
if maxPerUser <= 0 {
maxPerUser = 1
}
var existingCount int64
if err := sc.DB.Model(&models.SweepstakeEntry{}).Where("sweepstake_id = ? AND user_id = ? AND status = ?", s.ID, userID, "valid").Count(&existingCount).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to check entries"}); return }
if existingCount >= int64(maxPerUser) { c.JSON(http.StatusBadRequest, gin.H{"error":"Dosáhli jste limitu účastí v této soutěži"}); return }
if err := sc.DB.Model(&models.SweepstakeEntry{}).Where("sweepstake_id = ? AND user_id = ? AND status = ?", s.ID, userID, "valid").Count(&existingCount).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check entries"})
return
}
if existingCount >= int64(maxPerUser) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Dosáhli jste limitu účastí v této soutěži"})
return
}
costPoints := s.EntryCostPoints
if costPoints < 0 { costPoints = 0 }
if costPoints < 0 {
costPoints = 0
}
if costPoints > 0 {
svc := services.NewEngagementService(sc.DB)
up, err := svc.EnsureProfile(userID)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze načíst profil"}); return }
if up.Points < int64(costPoints) { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Nemáte dostatek bodů (potřeba: %d)", costPoints)}); return }
if _, err := svc.AwardPointsAndXP(userID, -int64(costPoints), 0, "sweepstake_entry", map[string]interface{}{"sweepstake_id": s.ID}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze odečíst body"}); return }
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze načíst profil"})
return
}
if up.Points < int64(costPoints) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Nemáte dostatek bodů (potřeba: %d)", costPoints)})
return
}
if _, err := svc.AwardPointsAndXP(userID, -int64(costPoints), 0, "sweepstake_entry", map[string]interface{}{"sweepstake_id": s.ID}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze odečíst body"})
return
}
e := models.SweepstakeEntry{SweepstakeID: s.ID, UserID: userID, Status: "valid"}
if err := sc.DB.Create(&e).Error; err != nil { _, _ = svc.AwardPointsAndXP(userID, int64(costPoints), 0, "sweepstake_entry_refund", map[string]interface{}{"sweepstake_id": s.ID}); c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze vytvořit účast"}); return }
if err := sc.DB.Create(&e).Error; err != nil {
_, _ = svc.AwardPointsAndXP(userID, int64(costPoints), 0, "sweepstake_entry_refund", map[string]interface{}{"sweepstake_id": s.ID})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit účast"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
entry := models.SweepstakeEntry{SweepstakeID: s.ID, UserID: userID, Status: "valid"}
if existingCount == 0 {
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, userID).FirstOrCreate(&entry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return }
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, userID).FirstOrCreate(&entry).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join"})
return
}
} else {
if err := sc.DB.Create(&entry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return }
if err := sc.DB.Create(&entry).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -369,7 +553,10 @@ func (sc *SweepstakesController) MarkVisualPlayed(c *gin.Context) {
userID := uid.(uint)
now := time.Now()
var e models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", id, userID).Order("id ASC").First(&e).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Entry not found"}); return }
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", id, userID).Order("id ASC").First(&e).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Entry not found"})
return
}
_ = sc.DB.Model(&models.SweepstakeEntry{}).Where("id = ?", e.ID).Update("visual_played_at", &now).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -379,7 +566,10 @@ func (sc *SweepstakesController) MyWinnings(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var items []models.SweepstakeWinner
if err := sc.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -397,17 +587,34 @@ func (sc *SweepstakesController) GetCurrent(c *gin.Context) {
return
}
state := "upcoming"
if now.After(s.StartAt) && now.Before(s.EndAt) { state = "active" } else if now.After(s.EndAt) { state = "finalized" }
if now.After(s.StartAt) && now.Before(s.EndAt) {
state = "active"
} else if now.After(s.EndAt) {
state = "finalized"
}
var prizes []models.SweepstakePrize
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Order("display_order ASC, id ASC").Find(&prizes).Error
var winners []models.SweepstakeWinner
if s.WinnersSelectedAt != nil { _ = sc.DB.Where("sweepstake_id = ?", s.ID).Find(&winners).Error }
if s.WinnersSelectedAt != nil {
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Find(&winners).Error
}
hasEntered := false
visualPlayedAt := (*time.Time)(nil)
myEntriesCount := int64(0)
canEnter := false
if uid, ok := c.Get("userID"); ok && uid != nil {
// Count valid entries for current user
_ = sc.DB.Model(&models.SweepstakeEntry{}).Where("sweepstake_id = ? AND user_id = ? AND status = ?", s.ID, uid.(uint), "valid").Count(&myEntriesCount).Error
hasEntered = myEntriesCount > 0
// Determine if user can still enter (within time window and below per-user limit)
maxPer := s.MaxEntriesPerUser
if maxPer <= 0 {
maxPer = 1
}
canEnter = now.After(s.StartAt) && now.Before(s.EndAt) && myEntriesCount < int64(maxPer)
// Keep the first entry's visual flag if exists
var e models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, uid.(uint)).First(&e).Error; err == nil {
hasEntered = true
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, uid.(uint)).Order("id ASC").First(&e).Error; err == nil {
visualPlayedAt = e.VisualPlayedAt
}
}
@@ -418,6 +625,8 @@ func (sc *SweepstakesController) GetCurrent(c *gin.Context) {
"state": state,
"has_entered": hasEntered,
"visual_played_at": visualPlayedAt,
"my_entries_count": myEntriesCount,
"can_enter": canEnter,
})
}
@@ -425,7 +634,10 @@ func (sc *SweepstakesController) GetCurrent(c *gin.Context) {
func (sc *SweepstakesController) AdminEntries(c *gin.Context) {
id := c.Param("id")
var items []models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -433,7 +645,10 @@ func (sc *SweepstakesController) AdminEntries(c *gin.Context) {
func (sc *SweepstakesController) AdminWinners(c *gin.Context) {
id := c.Param("id")
var items []models.SweepstakeWinner
if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := sc.DB.Where("sweepstake_id = ?", id).Order("created_at ASC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -441,17 +656,35 @@ func (sc *SweepstakesController) AdminWinners(c *gin.Context) {
func (sc *SweepstakesController) AdminFinalize(c *gin.Context) {
id := c.Param("id")
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
var body struct{ Seed string `json:"seed"` }
if err := sc.DB.First(&s, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
var body struct {
Seed string `json:"seed"`
}
_ = c.ShouldBindJSON(&body)
svc := services.NewSweepstakesService(sc.DB, sc.Email)
if err := svc.FinalizeSweepstake(&s, strings.TrimSpace(body.Seed)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to finalize"}); return }
if err := svc.FinalizeSweepstake(&s, strings.TrimSpace(body.Seed)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Helpers
func ifEmpty(v string, d string) string { if strings.TrimSpace(v)=="" { return d }; return strings.TrimSpace(v) }
func ifZero(v int, d int) int { if v==0 { return d }; return v }
func ifEmpty(v string, d string) string {
if strings.TrimSpace(v) == "" {
return d
}
return strings.TrimSpace(v)
}
func ifZero(v int, d int) int {
if v == 0 {
return d
}
return v
}
// Helpers for update coercion
func toString(v interface{}) string {
@@ -467,17 +700,33 @@ func toString(v interface{}) string {
func toNonNegInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
if n < 0 { return 0 }; return n
if n < 0 {
return 0
}
return n
case int:
if n < 0 { return 0 }; return int64(n)
if n < 0 {
return 0
}
return int64(n)
case float64:
if n < 0 { return 0 }; return int64(n)
if n < 0 {
return 0
}
return int64(n)
case float32:
if n < 0 { return 0 }; return int64(n)
if n < 0 {
return 0
}
return int64(n)
case string:
if strings.TrimSpace(n) == "" { return 0 }
if strings.TrimSpace(n) == "" {
return 0
}
if f, err := strconv.ParseFloat(n, 64); err == nil {
if f < 0 { return 0 }
if f < 0 {
return 0
}
return int64(f)
}
return 0
+1 -1
View File
@@ -103,7 +103,7 @@ func GetRewardTypeDisplayName(rewardType string) string {
"avatar_upload_unlock": "Odemknutí vlastního avataru",
"merch_coupon": "Slevový kupon",
"merch_physical": "Fyzické zboží",
"merch_digital": "Digitální produkt",
"merch_digital": "Digitální odměna",
"custom": "Vlastní",
}
if name, ok := names[rewardType]; ok {
-1
View File
@@ -345,7 +345,6 @@ type Settings struct {
// TableName specifies table name for Settings model
func (Settings) TableName() string { return "settings" }
// LoadCustomNav hydrates the in-memory CustomNav slice from the persisted JSON string.
func (s *Settings) LoadCustomNav() {
if s.CustomNavJSON == "" {
+3
View File
@@ -32,6 +32,9 @@ type NavigationItem struct {
CSSClass string `json:"css_class,omitempty"`
RequiresAuth bool `gorm:"default:false" json:"requires_auth"`
RequiresAdmin bool `gorm:"default:false" json:"requires_admin"`
// AllowEditor indicates that editors are allowed to access the corresponding admin page
// when this item represents an admin navigation entry (RequiresAdmin=true).
AllowEditor bool `gorm:"default:false" json:"allow_editor"`
}
// TableName specifies the table name for the NavigationItem model
+3
View File
@@ -189,6 +189,9 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
editor.GET("/variants/:element_name", editorPreviewController.GetAvailableVariants)
}
// Editor-allowed admin navigation (authenticated editors)
protected.GET("/admin/navigation/editor", middleware.RoleAuth("editor"), navigationController.GetEditorAllowedAdminNav)
// Newsletter preferences token for current user
protected.GET("/newsletter/token/me", contactController.GetNewsletterTokenForUser)
+83 -30
View File
@@ -192,10 +192,8 @@ func (na *NewsletterAutomation) checkUpcomingMatches() {
var settings models.Settings
na.db.First(&settings)
if !settings.EnableMatchReminders {
return
}
// Determine effective enabling: use settings, or auto-activate if there are subscribers and a match is within 2 hours
enabled := settings.EnableMatchReminders
leadHours := settings.NewsletterReminderLeadHours
if leadHours <= 0 {
leadHours = 48 // Default 2 days
@@ -207,6 +205,30 @@ func (na *NewsletterAutomation) checkUpcomingMatches() {
now := time.Now()
if !enabled {
subs := na.getSubscribersForType("matches", "")
if len(subs) == 0 {
return
}
auto := false
for _, match := range matches {
matchTime := parseDateTimeISO(match.Date, match.Time)
if matchTime.IsZero() || matchTime.Before(now) {
continue
}
if matchTime.Sub(now).Hours() <= 2 {
auto = true
break
}
}
if !auto {
return
}
// Auto mode: restrict reminder window to 2 hours before kickoff
leadHours = 2
enabled = true
}
for _, match := range matches {
matchTime := parseDateTimeISO(match.Date, match.Time)
if matchTime.IsZero() || matchTime.Before(now) {
@@ -215,7 +237,7 @@ func (na *NewsletterAutomation) checkUpcomingMatches() {
hoursUntil := matchTime.Sub(now).Hours()
// Check for 48h reminder
// Check for lead-hour reminder (48h normally, 2h in auto mode)
if hoursUntil <= float64(leadHours) && hoursUntil > float64(leadHours-1) {
na.sendMatchReminder(match, "reminder_48h", leadHours)
}
@@ -279,30 +301,8 @@ func (na *NewsletterAutomation) checkFinishedMatches() {
var settings models.Settings
na.db.First(&settings)
if !settings.EnableResults {
return
}
// Check quiet hours
currentHour := time.Now().Hour()
quietStart := settings.NewsletterQuietStart
quietEnd := settings.NewsletterQuietEnd
if quietStart > 0 && quietEnd > 0 {
if quietStart < quietEnd {
// e.g., 22:00 - 08:00
if currentHour >= quietStart || currentHour < quietEnd {
log.Printf("[newsletter-automation] In quiet hours, skipping result notifications")
return
}
} else {
// e.g., 08:00 - 22:00 (inverted, send only during these hours)
if currentHour < quietStart && currentHour >= quietEnd {
log.Printf("[newsletter-automation] Outside active hours, skipping result notifications")
return
}
}
}
// Determine effective enabling. If disabled, auto-activate when there are subscribers and a recent result exists.
enabled := settings.EnableResults
// Load match data
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
@@ -311,6 +311,57 @@ func (na *NewsletterAutomation) checkFinishedMatches() {
now := time.Now()
lookback := 6 * time.Hour // Check matches finished in last 6 hours
bypassQuiet := false
if !enabled {
subs := na.getSubscribersForType("scores", "")
if len(subs) == 0 {
return
}
auto := false
for _, match := range matches {
if match.Score == "" || !strings.Contains(match.Score, ":") {
continue
}
matchTime := parseDateTimeISO(match.Date, match.Time)
if matchTime.IsZero() || matchTime.After(now) {
continue
}
if now.Sub(matchTime) <= lookback {
auto = true
break
}
}
if !auto {
return
}
// Auto mode: send immediately when we have a result, ignoring quiet hours
bypassQuiet = true
enabled = true
}
// Respect quiet hours only when explicitly enabled in settings (not in auto mode)
if !bypassQuiet {
currentHour := time.Now().Hour()
quietStart := settings.NewsletterQuietStart
quietEnd := settings.NewsletterQuietEnd
// Consider quiet hours configured when both bounds are within 0..23 and not equal
if quietStart >= 0 && quietStart <= 23 && quietEnd >= 0 && quietEnd <= 23 && quietStart != quietEnd {
inQuiet := false
if quietStart < quietEnd {
// Same-day interval, e.g., 08:0022:00 => quiet when between start and end
inQuiet = currentHour >= quietStart && currentHour < quietEnd
} else {
// Cross-midnight interval, e.g., 22:0008:00 => quiet when hour >= start OR hour < end
inQuiet = currentHour >= quietStart || currentHour < quietEnd
}
if inQuiet {
log.Printf("[newsletter-automation] In quiet hours (%02d:00-%02d:00), skipping result notifications", quietStart, quietEnd)
return
}
}
}
for _, match := range matches {
if match.Score == "" || !strings.Contains(match.Score, ":") {
continue // No score yet
@@ -486,7 +537,9 @@ func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Articl
cut++
}
}
if cut > len(plain) { cut = len(plain) }
if cut > len(plain) {
cut = len(plain)
}
plain = strings.TrimSpace(plain[:cut]) + "…"
}
desc = plain
+5 -3
View File
@@ -78,7 +78,8 @@ func fetchZonerama(link string) error {
}
// Profile fetch - gets album metadata only (no photos)
albumLimit := envInt("ZONERAMA_ALBUM_LIMIT", 10) // Fetch up to 10 albums metadata
apiBase := "https://zonerama.tdvorak.dev/zonerama?link=" + url.QueryEscape(strings.TrimSpace(link)) + "&album_limit=" + strconv.Itoa(albumLimit) + "&photo_limit=0"
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
apiBase := fmt.Sprintf("%s/zonerama?link=%s&album_limit=%d&photo_limit=0", base, url.QueryEscape(strings.TrimSpace(link)), albumLimit)
log.Printf("[prefetch] Fetching Zonerama profile: %s (album_limit=%d, no photos)", apiBase, albumLimit)
// Increase timeout to 60s since the API can take longer to fetch
@@ -223,8 +224,9 @@ func fetchZoneramaAlbums(albums []struct {
}
// Fetch album with photos
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
url.QueryEscape(album.URL), photoLimit)
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d",
base, url.QueryEscape(album.URL), photoLimit)
log.Printf("[prefetch] Zonerama: Fetching album %d/%d: %s", i+1, len(albums), album.URL)
+48 -14
View File
@@ -83,18 +83,30 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
}
// Determine number of winners
nWinners := 0
for _, p := range prizes { nWinners += max(0, p.Quantity) }
for _, p := range prizes {
nWinners += max(0, p.Quantity)
}
if nWinners == 0 {
if cur.TotalPrizes > 0 { nWinners = cur.TotalPrizes }
if cur.TotalPrizes > 0 {
nWinners = cur.TotalPrizes
}
}
// Cap winners to a safe maximum
if nWinners > 100 { nWinners = 100 }
if nWinners > len(entries) { nWinners = len(entries) }
if nWinners > 100 {
nWinners = 100
}
if nWinners > len(entries) {
nWinners = len(entries)
}
// Build seed
effSeed := strings.TrimSpace(seed)
if effSeed == "" { effSeed = strings.TrimSpace(cur.DrawSeed) }
if effSeed == "" { effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano()) }
if effSeed == "" {
effSeed = strings.TrimSpace(cur.DrawSeed)
}
if effSeed == "" {
effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano())
}
// Deterministic RNG from SHA-256
h := sha256.Sum256([]byte(effSeed))
base := binary.LittleEndian.Uint64(h[:8])
@@ -125,18 +137,27 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
for j := 0; j < q && pos < len(idx); j++ {
cand := entries[idx[pos]]
pos++
if picked[cand.UserID] { j--; continue }
if picked[cand.UserID] {
j--
continue
}
picked[cand.UserID] = true
assign(cand.UserID, cand.ID, &prizes[i])
if len(winners) >= nWinners { break }
if len(winners) >= nWinners {
break
}
}
if len(winners) >= nWinners {
break
}
if len(winners) >= nWinners { break }
}
// If still need more (when TotalPrizes used)
for len(winners) < nWinners && pos < len(idx) {
cand := entries[idx[pos]]
pos++
if picked[cand.UserID] { continue }
if picked[cand.UserID] {
continue
}
picked[cand.UserID] = true
assign(cand.UserID, cand.ID, nil)
}
@@ -163,7 +184,13 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
for _, w := range winners {
var user models.User
_ = tx.First(&user, w.UserID).Error
if strings.TrimSpace(user.Email) == "" { continue }
if strings.TrimSpace(user.Email) == "" {
continue
}
// Localize end date to Czech format in Europe/Prague timezone
loc, _ := time.LoadLocation("Europe/Prague")
endsLocal := cur.EndAt.In(loc)
endsAtCz := endsLocal.Format("02. 01. 2006 15:04")
_ = s.Email.SendEmail(&email.EmailData{
Subject: "Vyhráli jste v soutěži!",
To: []string{strings.TrimSpace(user.Email)},
@@ -171,7 +198,7 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
Data: map[string]interface{}{
"Title": cur.Title,
"PrizeName": w.PrizeName,
"EndsAt": cur.EndAt.Format(time.RFC1123),
"EndsAt": endsAtCz,
},
})
}
@@ -179,7 +206,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
var set models.Settings
_ = tx.First(&set).Error
adminTo := strings.TrimSpace(set.ContactEmail)
if adminTo == "" { adminTo = strings.TrimSpace(set.SMTPFrom) }
if adminTo == "" {
adminTo = strings.TrimSpace(set.SMTPFrom)
}
if adminTo != "" {
_ = s.Email.SendEmail(&email.EmailData{
Subject: "Soutěž vybraní výherci",
@@ -196,4 +225,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
})
}
func max(a, b int) int { if a > b { return a } ; return b }
func max(a, b int) int {
if a > b {
return a
}
return b
}
+42 -60
View File
@@ -19,6 +19,7 @@ import (
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/logger"
"fotbal-club/pkg/utils"
"github.com/vanng822/go-premailer/premailer"
"gopkg.in/mail.v2"
@@ -476,11 +477,10 @@ func (s *emailService) SendPasswordReset(to string, resetLink string, useOverrid
if clubID := strings.TrimSpace(set.ClubID); clubID != "" {
// Use PNG format for better email client compatibility (SVG not widely supported)
clubLogo = fmt.Sprintf("https://logoapi.sportcreative.eu/logos/%s?format=png&width=400", clubID)
}
}
if clubLogo == "" {
} else {
clubLogo = "https://via.placeholder.com/400x400.png?text=Logo"
}
}
primaryColor := strings.TrimSpace(set.PrimaryColor)
if primaryColor == "" {
primaryColor = "#1e3a8a"
@@ -870,23 +870,25 @@ func (s *emailService) SendContactForm(data *ContactFormData) error {
Agent: data.UserAgent,
}
// Build recipients: admin email + optional auto-forward list from Settings
recipients := make([]string, 0, 4)
if v := strings.TrimSpace(s.config.AdminEmail); v != "" {
recipients = append(recipients, v)
}
// Load settings to check auto-forwarding
// Build recipients (deduped later):
// 1) Club contact email from DB Settings (preferred default)
// 2) CONTACT_EMAIL from env (Config.ContactEmail)
// 3) ADMIN_EMAIL from env (Config.AdminEmail)
recipients := make([]string, 0, 8)
// Load settings for contact email and forwarding list
var set models.Settings
if s.db != nil {
_ = s.db.First(&set).Error
if set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" {
parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
for _, p := range parts {
if v := strings.TrimSpace(p); v != "" {
if v := strings.TrimSpace(set.ContactEmail); v != "" {
recipients = append(recipients, v)
}
}
// Add environment-provided contact/admin fallbacks
if v := strings.TrimSpace(s.config.ContactEmail); v != "" {
recipients = append(recipients, v)
}
if v := strings.TrimSpace(s.config.AdminEmail); v != "" {
recipients = append(recipients, v)
}
// Deduplicate and ensure at least one recipient
uniq := make(map[string]struct{})
@@ -951,8 +953,6 @@ func (s *emailService) SendNewsletter(d *NewsletterData) error {
if subj == "" || html == "" {
return fmt.Errorf("newsletter subject and content are required")
}
// Build dialer and effective From dynamically
dialer, effFrom, effFromName := s.buildDialerAndFrom()
// Prepare recipient list (dedupe and sanitize)
uniq := map[string]struct{}{}
recips := make([]string, 0, len(d.Recipients))
@@ -984,7 +984,7 @@ func (s *emailService) SendNewsletter(d *NewsletterData) error {
}
frontendBase := strings.TrimSuffix(s.config.FrontendBaseURL, "/")
// Send to each recipient
// Send to each recipient using the standard email template wrapper (base.html + newsletter.html)
var errs []error
for _, to := range recips {
// Create delivery log (best-effort)
@@ -1001,7 +1001,7 @@ func (s *emailService) SendNewsletter(d *NewsletterData) error {
_ = s.db.Create(&logRec).Error
}
// Rewrite links for tracking and add open pixel
// Rewrite links for tracking and add open pixel (rendered inside the template)
trackedHTML := rewriteLinksForTracking(html, makeAbs, int(logRec.ID), token, frontendBase, s.config.PublicAPIBaseURL)
pixelURL := makeAbs("/email/open.gif", url.Values{
"m": {fmt.Sprintf("%d", logRec.ID)},
@@ -1010,60 +1010,42 @@ func (s *emailService) SendNewsletter(d *NewsletterData) error {
if strings.TrimSpace(trackedHTML) == "" {
trackedHTML = html
}
trackedHTML = trackedHTML + fmt.Sprintf("<img src=\"%s\" width=\"1\" height=\"1\" style=\"display:none;\" alt=\"\" />", pixelURL)
m := mail.NewMessage()
// Properly encode UTF-8 From name
name := strings.TrimSpace(effFromName)
if i := strings.Index(name, "<"); i >= 0 {
name = strings.TrimSpace(name[:i])
// Build manage/unsubscribe URLs (besteffort)
manageURL := ""
if v, err := utils.GenerateSubscriberToken(strings.ToLower(strings.TrimSpace(to)), 60*24*30); err == nil && frontendBase != "" {
manageURL = frontendBase + "/newsletter/preferences?token=" + v
}
addr := strings.TrimSpace(effFrom)
if !strings.Contains(addr, "@") {
addr = strings.TrimSpace(s.config.SMTPFrom)
unsubscribeURL := ""
if frontendBase != "" {
unsubscribeURL = frontendBase + "/newsletter/unsubscribe/" + url.QueryEscape(strings.ToLower(strings.TrimSpace(to)))
}
if strings.Contains(strings.ToLower(name), "@") {
name = ""
// Render via SendEmail to ensure base wrapper and branding
ed := &EmailData{
Subject: subj,
To: []string{to},
Template: "newsletter",
Data: map[string]interface{}{
"Subject": subj,
"Content": trackedHTML,
"OpenPixelURL": pixelURL,
"ManageURL": manageURL,
"UnsubscribeURL": unsubscribeURL,
},
}
m.SetAddressHeader("From", addr, name)
m.SetHeader("To", to)
m.SetHeader("Subject", subj)
m.SetDateHeader("Date", time.Now())
m.SetHeader("X-Mailer", "Fotbal Club")
if d.Headers != nil {
for k, v := range d.Headers {
if len(v) > 0 {
m.SetHeader(k, v...)
}
}
}
m.SetBody("text/plain", "Pro zobrazení tohoto e-mailu použijte HTML klient.")
m.AddAlternative("text/html", trackedHTML)
// Retry send
var lastErr error
for i := 0; i < 3; i++ {
logger.Debug("SMTP newsletter send attempt %d: to=%s subject=%s", i+1, to, subj)
if err := dialer.DialAndSend(m); err == nil {
lastErr = nil
break
} else {
lastErr = err
logger.Error("SMTP newsletter send failed (attempt %d) to=%s: %v", i+1, to, err)
time.Sleep(time.Second * time.Duration(i+1))
}
}
if lastErr != nil {
errs = append(errs, fmt.Errorf("failed to send to %s: %w", to, lastErr))
if err := s.SendEmail(ed); err != nil {
errs = append(errs, fmt.Errorf("failed to send to %s: %w", to, err))
if s.db != nil && logRec.ID != 0 {
_ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Updates(map[string]interface{}{
"status": "failed",
"send_error": lastErr.Error(),
"send_error": err.Error(),
}).Error
}
}
if lastErr == nil && s.db != nil && logRec.ID != 0 {
} else if s.db != nil && logRec.ID != 0 {
_ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Update("status", "sent").Error
}
time.Sleep(100 * time.Millisecond)
}
if len(errs) > 0 {