diff --git a/error-check.md b/error-check.md index 35182f9..732a194 100644 --- a/error-check.md +++ b/error-check.md @@ -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 (won’t 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 (won’t 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 --- diff --git a/frontend/src/App.lazy.tsx b/frontend/src/App.lazy.tsx index d9207b9..b31154c 100644 --- a/frontend/src/App.lazy.tsx +++ b/frontend/src/App.lazy.tsx @@ -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 ; }; +// 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(null); + const [loading, setLoading] = useState(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 ; + if (role === 'editor') { + if (loading) return ; + return ; + } + return ; +}; + // 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 = () => { } /> } /> + {/* Admin index: allow both admins and editors; decide inside */} + + + + } + /> + {/* Editor-level content admin routes (accessible to editors and admins) */} }> } /> @@ -272,7 +331,6 @@ const AppLazy: React.FC = () => { {/* Admin routes */} }> - } /> } /> } /> } /> diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6471f81..fc8178c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -365,6 +365,20 @@ const App: React.FC = () => { return ; }; + // 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 ; + } + if (role === 'editor') { + // Default first allowed page for editors; configurable nav may change links + return ; + } + return ; + }; + // Premium-aware route elements const HomeRoute: React.FC = () => { const { data } = usePublicSettings(); @@ -478,13 +492,22 @@ const App: React.FC = () => { /> } /> + {/* Admin index: allow both admins and editors; decide inside */} + + + + } + /> + {/* Admin area (pages include AdminLayout themselves) */} }> - } /> } /> {/* moved to editor-accessible routes below */} } /> @@ -508,7 +531,6 @@ const App: React.FC = () => { } /> } /> } /> - } /> } /> } /> } /> @@ -573,6 +595,14 @@ const App: React.FC = () => { } /> + + + + } + /> {/* Not found route */} } /> diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 96f2904..38d1587 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -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 () => { diff --git a/frontend/src/components/banners/BannerDisplay.tsx b/frontend/src/components/banners/BannerDisplay.tsx index b64521b..ea9fc63 100644 --- a/frontend/src/components/banners/BannerDisplay.tsx +++ b/frontend/src/components/banners/BannerDisplay.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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" /> diff --git a/frontend/src/components/home/GallerySection.tsx b/frontend/src/components/home/GallerySection.tsx index 2f2cb44..ab95ada 100644 --- a/frontend/src/components/home/GallerySection.tsx +++ b/frontend/src/components/home/GallerySection.tsx @@ -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, @@ -93,6 +94,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 = {} 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) => { diff --git a/frontend/src/components/home/VideosSection.tsx b/frontend/src/components/home/VideosSection.tsx index 98ba6e4..03b51b6 100644 --- a/frontend/src/components/home/VideosSection.tsx +++ b/frontend/src/components/home/VideosSection.tsx @@ -77,16 +77,14 @@ const VideosSection: React.FC = ({ 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,38 +94,58 @@ const VideosSection: React.FC = ({ 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 - const manual = (settings?.videos_items || []).map((it, i) => { - const embedUrl = toEmbed(it.url); - return { - key: `${i}-${it.url}`, - title: it.title || `Video ${i+1}`, - embedUrl, - thumbnail: it.thumbnail_url, - date: it.uploaded_at, - videoId: extractVideoId(embedUrl), - }; + // 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 { + key: `${i}-${it.url}`, + title: it.title || `Video ${i + 1}`, + embedUrl, + 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); + return { + key: `${i}-${url}`, + title: `Video ${i + 1}`, + embedUrl, + videoId: extractVideoId(embedUrl), + } as RenderItem; + }); + 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(); + 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; }); - const legacy = (videos || settings?.videos || []).map((url, i) => { - const embedUrl = toEmbed(url as any); - return { - key: `${i}-${url}`, - title: `Video ${i+1}`, - embedUrl, - videoId: extractVideoId(embedUrl), - }; - }); - return (manual.length ? manual : legacy).slice(0, limit); + return sorted.slice(0, limit); }, [source, yt, settings?.videos_items, settings?.videos, videos, limit, titleOverrides]); if (!enabled || items.length === 0) return null; diff --git a/frontend/src/components/scoreboard/ScoreboardPreview.tsx b/frontend/src/components/scoreboard/ScoreboardPreview.tsx index 17be840..3667856 100644 --- a/frontend/src/components/scoreboard/ScoreboardPreview.tsx +++ b/frontend/src/components/scoreboard/ScoreboardPreview.tsx @@ -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 ( - + {timer} - + {left.logo ? home : null} {left.short} {left.score} – {right.score} - + {right.short} {right.logo ? away : null} @@ -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 ( @@ -217,4 +219,4 @@ function shadeColor(hex: string, percent: number) { } } -export default ScoreboardPreview; +export default React.memo(ScoreboardPreview); diff --git a/frontend/src/components/sweepstakes/SweepstakeWidget.tsx b/frontend/src/components/sweepstakes/SweepstakeWidget.tsx index 7d457e3..ca1bbb4 100644 --- a/frontend/src/components/sweepstakes/SweepstakeWidget.tsx +++ b/frontend/src/components/sweepstakes/SweepstakeWidget.tsx @@ -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 = () => {

Soutěž

- {s.rules_url && (Pravidla)} + {s.rules_url && (Pravidla)}
{s.image_url && ( // eslint-disable-next-line jsx-a11y/alt-text - + )}
{s.title}
@@ -122,12 +125,12 @@ const SweepstakeWidget: React.FC = () => {

Soutěž

- {s.rules_url && (Pravidla)} + {s.rules_url && (Pravidla)}
{s.image_url && ( // eslint-disable-next-line jsx-a11y/alt-text - + )}
{s.title}
@@ -142,12 +145,12 @@ const SweepstakeWidget: React.FC = () => {
{!isLogged ? (
Právě zde probíhá soutěž. Přihlaste se a zapojte se.
- ) : data?.has_entered ? ( - Jste zapojeni ✓ - ) : ( + ) : (data?.can_enter ?? false) ? ( + ) : ( + Už jste registrováni v soutěži ✓ )}
@@ -157,7 +160,7 @@ const SweepstakeWidget: React.FC = () => {

Výherci soutěže

- {s.rules_url && (Pravidla)} + {s.rules_url && (Pravidla)}
{winners.length === 0 ? (
Výherci budou vyhlášeni brzy.
@@ -189,7 +192,12 @@ const SweepstakeWidget: React.FC = () => { ))}
{iWon && ( -
Gratulujeme! Tato stránka rozpoznala, že patříte mezi výherce.
+
+
Vyhráli jste! Více informací najdete ve svém e-mailu.
+
+ Pokud potřebujete pomoc, kontaktujte nás. +
+
)}
)} diff --git a/frontend/src/pages/ArticleDetailPage.tsx b/frontend/src/pages/ArticleDetailPage.tsx index b4b4102..2a70174 100644 --- a/frontend/src/pages/ArticleDetailPage.tsx +++ b/frontend/src/pages/ArticleDetailPage.tsx @@ -1248,9 +1248,23 @@ const ArticleDetailPage: React.FC = () => { {items.map((ev: any) => ( - + - {ev.title} + {ev.title} {(() => { try { const d = new Date(ev.start_time); return d.toLocaleDateString('cs-CZ') + (ev.location ? ` • ${ev.location}` : ''); } catch { return ev.start_time; } })()} @@ -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 && ( - - - + )} {/* Attachments in sidebar */} @@ -1281,12 +1286,13 @@ const ArticleDetailPage: React.FC = () => { {(data as any).attachments.map((f: any, idx: number) => ( - - - {f.name || f.url} - - - + ))} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index b913a17..153e20a 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -102,6 +102,7 @@ const HomePage: React.FC = () => { // Index for the NEXT MATCH competition carousel const [nextCompIdx, setNextCompIdx] = useState(0); const [nextMatchLink, setNextMatchLink] = useState(undefined); + const [sidebarTop, setSidebarTop] = useState(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 = {}; + 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') && ( -
-
-
- {(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => ( - - ))} -
-
-
- )} + {getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
}> @@ -1828,7 +1831,7 @@ const HomePage: React.FC = () => { )} {/* Gallery */} - {isVisible('gallery', false) && ( + {isVisible('gallery', true) && (
{defer ? ( @@ -1898,9 +1901,7 @@ const HomePage: React.FC = () => {
{defer ? ( -
- -
+
) : (
diff --git a/frontend/src/pages/VideosPage.tsx b/frontend/src/pages/VideosPage.tsx index 56347c4..180398d 100644 --- a/frontend/src/pages/VideosPage.tsx +++ b/frontend/src/pages/VideosPage.tsx @@ -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,42 +105,61 @@ 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 - const manual = (settings?.videos_items || []).map((it, i) => { - const embedUrl = toEmbed(it.url); - return { - key: `${i}-${it.url}`, - title: it.title || `Video ${i + 1}`, - embedUrl, - thumbnail: it.thumbnail_url, - date: it.uploaded_at, - videoId: extractVideoId(embedUrl), - }; + // Build manual items (preferred) with legacy fallback + const manualItems = (() => { + const manual = (settings?.videos_items || []).map((it, i) => { + const embedUrl = toEmbed(it.url); + return { + key: `${i}-${it.url}`, + title: it.title || `Video ${i + 1}`, + embedUrl, + 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); + return { + key: `${i}-${url}`, + title: `Video ${i + 1}`, + embedUrl, + videoId: extractVideoId(embedUrl), + } as RenderItem; + }); + 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)); + const out: RenderItem[] = []; + const seen = new Set(); + 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; }); - const legacy = ((settings as any)?.videos || []).map((url: string, i: number) => { - const embedUrl = toEmbed(url); - return { - key: `${i}-${url}`, - title: `Video ${i + 1}`, - embedUrl, - videoId: extractVideoId(embedUrl), - }; - }); - return manual.length ? manual : legacy; - }, [source, yt, settings?.videos_items, settings, titleOverrides]); + return sorted; + }, [yt, settings?.videos_items, (settings as any)?.videos, titleOverrides]); const openVideo = (item: RenderItem) => { setSelectedVideo(item); diff --git a/frontend/src/pages/admin/AdminVideosPage.tsx b/frontend/src/pages/admin/AdminVideosPage.tsx index bf0a7a7..da79da3 100644 --- a/frontend/src/pages/admin/AdminVideosPage.tsx +++ b/frontend/src/pages/admin/AdminVideosPage.tsx @@ -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([]); - const [videosSource, setVideosSource] = useState<'auto'|'manual'>('manual'); + const videosSource: 'auto' = 'auto'; const [videosEnabled, setVideosEnabled] = useState(true); const toast = useToast(); + const { isOpen: isAddOpen, onOpen: onOpenAdd, onClose: onCloseAdd } = useDisclosure(); // YouTube Scraper API integration state const [channelInput, setChannelInput] = useState(''); @@ -47,6 +48,7 @@ const AdminVideosPage: React.FC = () => { const [filter, setFilter] = useState(''); // Title overrides for auto mode (video_id -> title) const [titleOverrides, setTitleOverrides] = useState>({}); + const [directUrl, setDirectUrl] = useState(''); // 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(); + 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'); - 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 - } + return out; + })(); + try { + 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.' }); } - toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` }); }; - const addItem = async () => { - setItems((prev) => [...prev, { ...emptyItem }]); - if (videosSource !== 'manual') { - setVideosSource('manual'); - try { - await updateAdminSettings({ videos_source: 'manual' }); - } catch { - // ignore - } + 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_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 { + 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 */} - - Zdroj videí: - - - - + + Zobrazit sekci Videa na titulní stránce @@ -363,57 +396,11 @@ const AdminVideosPage: React.FC = () => { {videosSource === 'auto' && ( - 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. )} - {videosSource !== 'auto' && ( - - Import z YouTube kanálu - - Použijte Scraper API. Zadejte handle (např. @FotbalKunovice) nebo URL kanálu a načtěte videa z karty „Videa“. - Služba: https://youtube.tdvorak.dev/ - - - - Kanál (handle nebo URL) - setChannelInput(e.target.value)} /> - - - - - {ytError && ( - - - {ytError} - - )} - {ytLoading && ( - Načítám videa… - )} - {!ytLoading && ytVideos.length > 0 && ( - - {ytVideos.map((v) => ( - - - {v.title} - toggleSelect(v.video_id)}> - Vybrat - - - {v.title} - - {v.length && {v.length}} - {v.published_text && {v.published_text}} - - - - - ))} - - )} - - )} + {/* Always-visible preview of effective videos */} @@ -436,186 +423,169 @@ const AdminVideosPage: React.FC = () => { Načítám videa… ) : ( <> - Počet videí: {autoVideos.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase())).length} + Počet videí: {combinedAutoPreview.count} - {autoVideos - .filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase())) - .map((v) => ( - - - {v.title} { - const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } }; - const idx = Number(el.dataset.fallbackIdx || '0'); - const id = v.video_id; - const chain = [ - `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', - ]; - if (idx < chain.length) { - el.src = chain[idx]; - el.dataset.fallbackIdx = String(idx + 1); - } - }} - /> - - {v.title} - - {v.published_date && {new Date(v.published_date).toLocaleDateString('cs-CZ')}} - + {combinedAutoPreview.list.map((it) => ( + + + {it.title} { + const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } }; + const idx = Number(el.dataset.fallbackIdx || '0'); + 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); + } + }} + /> + + + + {(it.source === 'auto' && it.video_id && (titleOverrides[it.video_id]?.trim()?.length ? titleOverrides[it.video_id] : it.title)) || it.title} + + {it.published_date && {new Date(it.published_date).toLocaleDateString('cs-CZ')}} + {it.source === 'manual' && Ručně} + + + {it.source === 'manual' && ( + } + 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.' }); + } + }} + /> + )} + + {it.source === 'auto' && it.video_id && ( Přepis názvu (volitelné) { const val = e.target.value; - setTitleOverrides(prev => ({ ...prev, [v.video_id]: val })); + setTitleOverrides(prev => ({ ...prev, [it.video_id!]: val })); }} /> - {!!(titleOverrides[v.video_id]?.length) && ( - - - - )} - - - - ))} + )} + + + + ))} - {autoVideos.length === 0 && ( - Žádná videa v cache. Zkontrolujte YouTube URL v nastavení a použijte „Aktualizovat cache“. + {combinedAutoPreview.count === 0 && ( + Zatím žádná videa. )} )} - ) : ( - <> - Počet videí: {items.length} - - {items.map((it, idx) => ( - - - {it.title { - 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); - } - }} - /> - - {it.title || `Video ${idx+1}`} - - {it.uploaded_at && {(new Date(it.uploaded_at)).toLocaleDateString('cs-CZ')}} - - - - - ))} - - {items.length === 0 && ( - Zatím žádná videa. - )} - - )} + ) : null} - - - - {loading ? ( - Načítání… - ) : videosSource === 'auto' ? ( - Automatický zdroj videí je aktivní. Pro ruční správu přepněte zdroj na „Ručně“. - ) : ( - - {items.map((it, idx) => ( - - - Video #{idx + 1} - - - - URL videa - updateField(idx, 'url', e.target.value)} placeholder="https://www.youtube.com/watch?v=..." /> - - - Thumbnail (volitelné) - updateField(idx, 'thumbnail_url', e.target.value)} placeholder="https://example.com/thumb.jpg" /> - - - Název (volitelné) - updateField(idx, 'title', e.target.value)} placeholder="Titulek videa" /> - - - Délka (volitelné) - updateField(idx, 'length', e.target.value)} placeholder="3:45" /> - - - Datum nahrání (volitelné) - - updateField(idx, 'uploaded_at', e.target.value)} /> - - - - - - - - - - - - - - - - - } onClick={() => removeItem(idx)} variant="outline" colorScheme="red" /> - - - ))} - {items.length === 0 && ( - Zatím žádná videa. Použijte tlačítko „Přidat video“. - )} - - )} + + + + Přidat video + + + + + Odkaz na video + Načíst z YouTube kanálu + + + + + + URL videa + setDirectUrl(e.target.value)} /> + + + + + + + + + + Použijte Scraper API. Zadejte handle (např. @FotbalKunovice) nebo URL kanálu a načtěte videa z karty „Videa“. Služba: youtube.tdvorak.dev + + + + Kanál (handle nebo URL) + setChannelInput(e.target.value)} /> + + + + + {ytError && ( + + + {ytError} + + )} + {ytLoading && ( + Načítám videa… + )} + {!ytLoading && ytVideos.length > 0 && ( + + {ytVideos.map((v) => ( + + + {v.title} + toggleSelect(v.video_id)}> + Vybrat + + + {v.title} + + {v.length && {v.length}} + {v.published_text && {v.published_text}} + + + + + ))} + + )} + + + + + + + + + + ); diff --git a/frontend/src/pages/admin/BannersAdminPage.tsx b/frontend/src/pages/admin/BannersAdminPage.tsx index e39d31f..7b878fb 100644 --- a/frontend/src/pages/admin/BannersAdminPage.tsx +++ b/frontend/src/pages/admin/BannersAdminPage.tsx @@ -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í)', diff --git a/frontend/src/pages/admin/EngagementAdminPage.tsx b/frontend/src/pages/admin/EngagementAdminPage.tsx index 28e0bd6..fcc71b8 100644 --- a/frontend/src/pages/admin/EngagementAdminPage.tsx +++ b/frontend/src/pages/admin/EngagementAdminPage.tsx @@ -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(true); + const [sizeList, setSizeList] = React.useState([]); + const [colorList, setColorList] = React.useState([]); + const [sizeCustom, setSizeCustom] = React.useState(''); + const [colorCustom, setColorCustom] = React.useState(''); + const [editItem, setEditItem] = React.useState(null); const editModal = useDisclosure(); const [editForm, setEditForm] = React.useState>({}); // 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(null); const [meta, setMeta] = React.useState>({}); @@ -119,33 +121,7 @@ const EngagementAdminPage: React.FC = () => { return m; }, [usersQ.data]); - // Reward template selector instead of many buttons - const [template, setTemplate] = React.useState('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 = { ...(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 = () => { Vytvořit novou odměnu - - - - Šablona odměny - - - - - {batchEnabled && ( - - )} - - + {/* Šablony odměn odstraněny */} @@ -345,13 +283,9 @@ const EngagementAdminPage: React.FC = () => { Typ odměny Ovlivňuje chování po uplatnění (např. nastavení avataru). @@ -364,36 +298,48 @@ const EngagementAdminPage: React.FC = () => { ~ {Math.round(Number(form.cost_points || 0) * 0.1)} Kč - {(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && ( - - Sklad - setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}> + + Množství/Sklad + + setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}> - - )} - - {(form.type === 'avatar_static' || form.type === 'avatar_animated') && ( - <> - - Obrázek URL - setForm({ ...form, image_url: e.target.value })} /> - Pro avatar uveďte URL obrázku. - - - handleUpload(e.target.files?.[0])} /> - + + Neomezeně + setForm(prev => ({ ...prev, stock: e.target.checked ? -1 : Math.max(0, Number(prev.stock) === -1 ? 0 : Number(prev.stock)||0) }))} /> + - - )} - + -1 = neomezeně, 0 = dočasně vyprodáno. Sklad platí pro ne-avatarové odměny. + + + <> + Obrázek URL + setForm({ ...form, image_url: e.target.value })} /> + Vložte URL nebo použijte tlačítko níže. Cesty z /uploads se načtou přes proxy. + + + handleUpload(e.target.files?.[0])} /> + + + + + + Neomezená platnost + { + const on = e.target.checked; + setValidUnlimited(on); + if (on) { setMetaField('valid_from', ''); setMetaField('valid_to', ''); } + }} /> + + Platnost od setMetaField('valid_from', e.target.value)} /> - + Platnost do setMetaField('valid_to', e.target.value)} /> + Když je zapnuto „Neomezená platnost“, datumy se nevyžadují a ignorují. {/* Metadata helpers */} @@ -411,12 +357,55 @@ const EngagementAdminPage: React.FC = () => { )} {form.type === 'merch_physical' && ( - SKUsetMetaField('sku', e.target.value)} /> - - VelikostsetMetaField('size', e.target.value)} placeholder="M / L / XL" /> - BarvasetMetaField('color', e.target.value)} /> - - PoznámkasetMetaField('note', e.target.value)} /> + + SKU + setMetaField('sku', e.target.value)} /> + 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. + + + Velikosti + setSizeList(vals as string[])}> + + {SIZE_OPTIONS.map((s)=> ( + {s} + ))} + + + + setSizeCustom(e.target.value)} /> + + + Vyberte z nabídky nebo zadejte vlastní hodnoty, oddělené čárkami (např. 122, 128). + + + Barvy + setColorList(vals as string[])}> + + {COLOR_OPTIONS.map((c)=> ( + {c} + ))} + + + + setColorCustom(e.target.value)} /> + + + Vyberte více možností nebo přidejte vlastní barvy (oddělené čárkami). + + + Poznámka + setMetaField('note', e.target.value)} /> + )} {form.type === 'merch_digital' && ( @@ -447,18 +436,16 @@ const EngagementAdminPage: React.FC = () => { - {(form.type === 'avatar_static' || form.type === 'avatar_animated') && ( - - Náhled - - {form.image_url ? ( - {form.name} - ) : ( - Bez obrázku - )} - + + Náhled + + {form.image_url ? ( + {form.name} + ) : ( + Bez obrázku + )} - )} + @@ -483,7 +470,7 @@ const EngagementAdminPage: React.FC = () => { Název Typ Body - Sklad + Množství/Sklad Obrázek Platnost Aktivní @@ -497,7 +484,7 @@ const EngagementAdminPage: React.FC = () => { {r.name} {r.type} - updateMut.mutate({ id: r.id, body: { cost_points: Number.isFinite(n) ? n : 0 } })}> + updateMut.mutate({ id: r.id, body: { cost_points: Number.isFinite(n) ? n : 0 } })}> @@ -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 } })} > - {r.image_url ? {r.name} : '-'} + {r.image_url ? {r.name} : '-'} {(() => { const m = (r.metadata || {}) as any; @@ -531,14 +518,27 @@ const EngagementAdminPage: React.FC = () => { updateMut.mutate({ id: r.id, body: { active: e.target.checked } })} /> - } onClick={() => { setEditItem(r); setEditForm(r); setEditMeta(r.metadata || {}); editModal.onOpen(); }} /> - {r.type !== 'avatar_upload_unlock' && ( + } 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_') && ( } onClick={() => deleteMut.mutate(r.id)} /> )} @@ -619,74 +619,125 @@ const EngagementAdminPage: React.FC = () => { Název - setEditForm({ ...editForm, name: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} /> + setEditForm({ ...editForm, name: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} /> Typ - setEditForm({ ...editForm, type: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}> + - Body - setEditForm({ ...editForm, cost_points: Number.isFinite(n)? n : 0 })}> + setEditForm({ ...editForm, cost_points: Number.isFinite(n)? n : 0 })} isDisabled={!editItem || (!!editItem.type && !editItem.type.startsWith('avatar_') && false)}> ~ {Math.round(Number(editForm.cost_points || 0) * 0.1)} Kč - Sklad - setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })} isDisabled={editItem?.type === 'avatar_upload_unlock'}> - - + Množství/Sklad + + setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })}> + + + + Neomezeně + 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_')} /> + + + -1 = neomezeně, 0 = vyprodáno. Obrázek URL - setEditForm({ ...editForm, image_url: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} /> + setEditForm({ ...editForm, image_url: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} /> + Vložte URL z /uploads nebo nahrávací tlačítko (proxy na frontend funguje). handleUploadEdit(e.target.files?.[0])} /> - + {/* Edit metadata helpers (structured) */} { (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && ( {editForm.type === 'merch_coupon' && ( <> - Kód kuponusetEditMetaField('coupon_code', e.target.value)} /> - PoznámkasetEditMetaField('note', e.target.value)} /> + Kód kuponusetEditMetaField('coupon_code', e.target.value)} /> + PoznámkasetEditMetaField('note', e.target.value)} /> )} {editForm.type === 'merch_physical' && ( <> - SKUsetEditMetaField('sku', e.target.value)} /> - - VelikostsetEditMetaField('size', e.target.value)} /> - BarvasetEditMetaField('color', e.target.value)} /> - - PoznámkasetEditMetaField('note', e.target.value)} /> + + SKU + setEditMetaField('sku', e.target.value)} /> + Interní kód produktu (volitelné). + + + Velikosti + { + const arr = vals as string[]; + setEditMeta(prev => ({ ...(prev as any), __size_list: arr } as any)); + }}> + + {SIZE_OPTIONS.map((s)=> ( + {s} + ))} + + + + setEditMetaField('__size_custom', e.target.value)} /> + + + Vyberte z nabídky nebo přidejte vlastní hodnoty (oddělené čárkami). + + + Barvy + { + const arr = vals as string[]; + setEditMeta(prev => ({ ...(prev as any), __color_list: arr } as any)); + }}> + + {COLOR_OPTIONS.map((c)=> ( + {c} + ))} + + + + setEditMetaField('__color_custom', e.target.value)} /> + + + Vyberte více možností nebo přidejte vlastní barvy (oddělené čárkami). + + PoznámkasetEditMetaField('note', e.target.value)} /> )} {editForm.type === 'merch_digital' && ( <> - Licenční klíčsetEditMetaField('license_key', e.target.value)} /> - Stažení (URL)setEditMetaField('download_url', e.target.value)} /> - PoznámkasetEditMetaField('note', e.target.value)} /> + Licenční klíčsetEditMetaField('license_key', e.target.value)} /> + Stažení (URL)setEditMetaField('download_url', e.target.value)} /> + PoznámkasetEditMetaField('note', e.target.value)} /> )} {editForm.type === 'custom' && ( - - - - - - - - - )} + ); }; diff --git a/frontend/src/pages/admin/GalleryAdminPage.tsx b/frontend/src/pages/admin/GalleryAdminPage.tsx index cb33308..b6c5d9d 100644 --- a/frontend/src/pages/admin/GalleryAdminPage.tsx +++ b/frontend/src/pages/admin/GalleryAdminPage.tsx @@ -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(50); const [adding, setAdding] = useState(false); - const fetchAlbums = async () => { + const fetchAlbums = async (): Promise => { 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 = {} 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 = () => { {coverPhoto ? ( {album.title} = ({ }) => { const hasChildren = item.children && item.children.length > 0; const indentPx = level * 32; + const isCategory = item.type === 'dropdown'; return ( @@ -375,12 +376,19 @@ const NavItemCard: React.FC = ({ - {/* 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 && ( {(provided) => ( - - {item.children!.map((child, childIndex) => ( + + {hasChildren && isExpanded && item.children!.map((child, childIndex) => ( {(dragProvided) => ( @@ -410,6 +418,10 @@ const NavItemCard: React.FC = ({ )} ))} + {/* Provide a minimal drop zone even when collapsed or empty */} + {!hasChildren && ( + + )} {provided.placeholder} )} @@ -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 = () => { )} + {isAdminNav && editingNav?.type !== 'dropdown' && ( + + Povolit editorům + setEditingNav({ ...editingNav!, allow_editor: e.target.checked })} + /> + + )} + Viditelné 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 @@ -229,6 +236,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(); @@ -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() { Příjemci: {count} + {typePreview[t]?.subject ? ( + + {typePreview[t]!.subject} + + ) : null} @@ -1028,28 +1050,29 @@ export default function NewsletterAdminPage() { Náhled se zobrazí zde') }} /> + + Náhled: + Náhled se zobrazí zde') }} + /> + )} - - Náhled: - Náhled se zobrazí zde') : (previewHtml || 'Náhled se zobrazí zde')) }} - /> - - + - Náhled se zobrazí zde') : 'Náhled se zobrazí zde') }} /> + Náhled se zobrazí zde') : 'Náhled se zobrazí zde') }} /> Příjemci diff --git a/frontend/src/pages/admin/ScoreboardAdminPage.tsx b/frontend/src/pages/admin/ScoreboardAdminPage.tsx index 6601bd0..ce0b4fd 100644 --- a/frontend/src/pages/admin/ScoreboardAdminPage.tsx +++ b/frontend/src/pages/admin/ScoreboardAdminPage.tsx @@ -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(null); const [loading, setLoading] = useState(true); + const deferredState = useDeferredValue(state); const toast = useToast(); const [activeTab, setActiveTab] = useState(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([]); + const [homeColorBusy, setHomeColorBusy] = useState(false); + const [awayColorBusy, setAwayColorBusy] = useState(false); + const [isPickingColor, setIsPickingColor] = useState(false); + const saveDebounceRef = useRef(undefined); + const pendingPatchRef = useRef>({}); + const setPartialDebounced = (patch: Partial) => { + 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) => { + 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({ @@ -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 = { 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 */} - + @@ -547,6 +621,22 @@ const ScoreboardAdminPage: React.FC = () => { await setPartial({ homeLogo: e.target.value }); }} /> + Logo hostů (URL) @@ -556,6 +646,22 @@ const ScoreboardAdminPage: React.FC = () => { await setPartial({ awayLogo: e.target.value }); }} /> + Délka poločasu (min) @@ -586,19 +692,51 @@ const ScoreboardAdminPage: React.FC = () => { Barva domácích - setPartial({ primaryColor: e.target.value })} /> + setIsPickingColor(true)} + onPointerUp={() => setIsPickingColor(false)} + onBlur={() => setIsPickingColor(false)} + onChange={(e) => queueSaveOnly({ primaryColor: e.target.value })} + /> Barva hostů - setPartial({ secondaryColor: e.target.value })} /> + setIsPickingColor(true)} + onPointerUp={() => setIsPickingColor(false)} + onBlur={() => setIsPickingColor(false)} + onChange={(e) => queueSaveOnly({ secondaryColor: e.target.value })} + /> Barva textu domácích - setPartial({ homeTextColor: e.target.value })} /> + setIsPickingColor(true)} + onPointerUp={() => setIsPickingColor(false)} + onBlur={() => setIsPickingColor(false)} + onChange={(e) => queueSaveOnly({ homeTextColor: e.target.value })} + /> Barva textu hostů - setPartial({ awayTextColor: e.target.value })} /> + setIsPickingColor(true)} + onPointerUp={() => setIsPickingColor(false)} + onBlur={() => setIsPickingColor(false)} + onChange={(e) => queueSaveOnly({ awayTextColor: e.target.value })} + /> QR interval (minuty) @@ -646,6 +784,16 @@ const ScoreboardAdminPage: React.FC = () => { + diff --git a/frontend/src/pages/admin/SweepstakeVisualPage.tsx b/frontend/src/pages/admin/SweepstakeVisualPage.tsx index 8568184..b1b2b19 100644 --- a/frontend/src/pages/admin/SweepstakeVisualPage.tsx +++ b/frontend/src/pages/admin/SweepstakeVisualPage.tsx @@ -22,7 +22,7 @@ const SweepstakeVisualPage: React.FC = () => { const toast = useToast(); const [data, setData] = useState(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(true); const [soundOn, setSoundOn] = useState(true); @@ -37,6 +37,16 @@ const SweepstakeVisualPage: React.FC = () => { const imgCacheRef = useRef>({}); const [prizes, setPrizes] = useState([]); + // Roulette scroller state + const railRef = useRef(null); + const [stripItems, setStripItems] = useState([]); + const [scrollPx, setScrollPx] = useState(0); + const [rouletteKey, setRouletteKey] = useState(0); // force re-render/reflow per run + const [weightingOn, setWeightingOn] = useState(true); + const [speed, setSpeed] = useState<'slow'|'normal'|'fast'>('normal'); + const [drama, setDrama] = useState(3); + const [transitionMs, setTransitionMs] = useState(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(); + 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 ( @@ -300,17 +366,26 @@ const SweepstakeVisualPage: React.FC = () => { - + + + - {form.rules_url && ()} + {form.rules_url && ()} setForm({ ...form, rules_url: e.target.value })} /> @@ -409,7 +447,7 @@ const SweepstakesAdminPage: React.FC = () => { 100}> Počet výherců - setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}> + setForm({ ...form, total_prizes: v })}> Max. 100 výherců @@ -424,7 +462,7 @@ const SweepstakesAdminPage: React.FC = () => { Max. účastí / uživatel - setForm({ ...form, max_entries_per_user: Number(v) || 1 })}> + setForm({ ...form, max_entries_per_user: v })}> @@ -436,20 +474,28 @@ const SweepstakesAdminPage: React.FC = () => { diff --git a/frontend/src/services/adminMatches.ts b/frontend/src/services/adminMatches.ts index b409874..282912c 100644 --- a/frontend/src/services/adminMatches.ts +++ b/frontend/src/services/adminMatches.ts @@ -47,7 +47,15 @@ export async function patchMatchOverride(externalMatchId: string, payload: Parti body.date_time_override = d.toISOString(); } } - return (await api.patch(`/admin/match-overrides/${encodeURIComponent(externalMatchId)}`, body)).data; + 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) { diff --git a/frontend/src/services/engagement.ts b/frontend/src/services/engagement.ts index cdb3a4e..b83ad57 100644 --- a/frontend/src/services/engagement.ts +++ b/frontend/src/services/engagement.ts @@ -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; diff --git a/frontend/src/services/navigation.ts b/frontend/src/services/navigation.ts index f73794c..53b5d99 100644 --- a/frontend/src/services/navigation.ts +++ b/frontend/src/services/navigation.ts @@ -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 => { + 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 => { const response = await api.get(`/admin/social-links`); diff --git a/frontend/src/services/scoreboard.ts b/frontend/src/services/scoreboard.ts index 0ebb78d..61243bd 100644 --- a/frontend/src/services/scoreboard.ts +++ b/frontend/src/services/scoreboard.ts @@ -90,9 +90,19 @@ export async function getScoreboardState(): Promise { } export async function saveScoreboardState(state: Partial): Promise { - const current = await getScoreboardState(); - const next = { ...current, ...state } as ScoreboardState; - localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + // 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)); diff --git a/frontend/src/services/sweepstakes.ts b/frontend/src/services/sweepstakes.ts index 7038450..bfc45ab 100644 --- a/frontend/src/services/sweepstakes.ts +++ b/frontend/src/services/sweepstakes.ts @@ -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 { diff --git a/frontend/src/styles/custom-editor.css b/frontend/src/styles/custom-editor.css index f1a0609..8125b76 100644 --- a/frontend/src/styles/custom-editor.css +++ b/frontend/src/styles/custom-editor.css @@ -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; diff --git a/frontend/src/styles/home-style-pack.css b/frontend/src/styles/home-style-pack.css index 4cef3b9..4553c05 100644 --- a/frontend/src/styles/home-style-pack.css +++ b/frontend/src/styles/home-style-pack.css @@ -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)); } diff --git a/frontend/src/utils/colors.ts b/frontend/src/utils/colors.ts index 356ed39..eab4fbd 100644 --- a/frontend/src/utils/colors.ts +++ b/frontend/src/utils/colors.ts @@ -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 diff --git a/frontend/src/utils/engagementHelpers.ts b/frontend/src/utils/engagementHelpers.ts index f6d0a81..a059a6c 100644 --- a/frontend/src/utils/engagementHelpers.ts +++ b/frontend/src/utils/engagementHelpers.ts @@ -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; diff --git a/internal/config/config.go b/internal/config/config.go index 5ee2036..c139192 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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", ""), diff --git a/internal/controllers/base_controller.go b/internal/controllers/base_controller.go index bb0c747..0d3c717 100644 --- a/internal/controllers/base_controller.go +++ b/internal/controllers/base_controller.go @@ -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 diff --git a/internal/controllers/comment_controller.go b/internal/controllers/comment_controller.go index d69335f..682c6c0 100644 --- a/internal/controllers/comment_controller.go +++ b/internal/controllers/comment_controller.go @@ -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) - 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 { + // 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 := 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}) } diff --git a/internal/controllers/contact_controller.go b/internal/controllers/contact_controller.go index 7de0079..43781a5 100644 --- a/internal/controllers/contact_controller.go +++ b/internal/controllers/contact_controller.go @@ -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("

Děkujeme za přihlášení. Spravujte své preference zde.

", 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"}) diff --git a/internal/controllers/engagement_controller.go b/internal/controllers/engagement_controller.go index acaed44..c447539 100644 --- a/internal/controllers/engagement_controller.go +++ b/internal/controllers/engagement_controller.go @@ -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" @@ -23,61 +23,82 @@ type EngagementController struct { // parseMetaTime tries to parse time from metadata value which can be string (RFC3339 or YYYY-MM-DD) or numeric unix seconds. 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 } - case float64: - // JSON numbers decode to float64 - if t <= 0 { return time.Time{} } - return time.Unix(int64(t), 0) - case int64: - if t <= 0 { return time.Time{} } - return time.Unix(t, 0) - case int: - if t <= 0 { return time.Time{} } - return time.Unix(int64(t), 0) - } - return 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 + } + case float64: + // JSON numbers decode to float64 + if t <= 0 { + return time.Time{} + } + return time.Unix(int64(t), 0) + case int64: + if t <= 0 { + return time.Time{} + } + return time.Unix(t, 0) + case int: + if t <= 0 { + return time.Time{} + } + return time.Unix(int64(t), 0) + } + return time.Time{} } func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController { - return &EngagementController{DB: db, Email: es} + return &EngagementController{DB: db, Email: es} } // Admin: adjust points for a user (positive or negative) // POST /api/v1/admin/engagement/adjust { user_id, delta, reason?, meta? } func (ec *EngagementController) AdminAdjustPoints(c *gin.Context) { var body struct { - UserID uint `json:"user_id"` - Delta int64 `json:"delta"` - Reason string `json:"reason"` - Meta map[string]interface{} `json:"meta"` - CurrentPassword string `json:"current_password"` + UserID uint `json:"user_id"` + Delta int64 `json:"delta"` + Reason string `json:"reason"` + Meta map[string]interface{} `json:"meta"` + 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) @@ -97,17 +118,17 @@ func (ec *EngagementController) GetProfile(c *gin.Context) { var achCount int64 _ = ec.DB.Model(&models.UserAchievement{}).Where("user_id = ?", userID).Count(&achCount).Error c.JSON(http.StatusOK, gin.H{ - "user_id": userID, - "points": up.Points, - "level": up.Level, - "xp": up.XP, - "username": up.Username, - "avatar_url": up.AvatarURL, - "animated_avatar_url": up.AnimatedAvatarURL, - "avatar_upload_unlocked": up.AvatarUploadUnlocked, + "user_id": userID, + "points": up.Points, + "level": up.Level, + "xp": up.XP, + "username": up.Username, + "avatar_url": up.AvatarURL, + "animated_avatar_url": up.AnimatedAvatarURL, + "avatar_upload_unlocked": up.AvatarUploadUnlocked, "animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked, - "achievements": achCount, - "engagement_disabled": c.GetString("userRole") == "admin", + "achievements": achCount, + "engagement_disabled": c.GetString("userRole") == "admin", }) } @@ -162,8 +183,8 @@ func (ec *EngagementController) PatchAvatar(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(uint) var body struct { - AvatarURL *string `json:"avatar_url"` - AnimatedAvatarURL *string `json:"animated_avatar_url"` + AvatarURL *string `json:"avatar_url"` + AnimatedAvatarURL *string `json:"animated_avatar_url"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) @@ -219,59 +240,73 @@ func (ec *EngagementController) PatchAvatar(c *gin.Context) { // GET /api/v1/engagement/rewards (public) func (ec *EngagementController) GetRewards(c *gin.Context) { - var unlock models.RewardItem - if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil { - unlock = models.RewardItem{ - Name: "Odemknout vlastní avatar (upload)", - Type: "avatar_upload_unlock", - CostPoints: 50, - ImageURL: "", - Stock: -1, - Active: true, - } - _ = 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 - } - } - var items []models.RewardItem - q := ec.DB.Where("active = ?", true) - if err := q.Order("created_at DESC").Find(&items).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"}) - return - } - // Filter by optional validity window in metadata (valid_from, valid_to). Also accept legacy expires_at as valid_to. - now := time.Now() - filtered := make([]models.RewardItem, 0, len(items)) - for _, it := range items { - // Mandatory unlock reward is always available - if strings.EqualFold(strings.TrimSpace(it.Type), "avatar_upload_unlock") { - filtered = append(filtered, it) - continue - } - 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 v, ok := it.Metadata["valid_to"]; ok { - 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 startPtr != nil && now.Before(*startPtr) { continue } - if endPtr != nil && now.After(*endPtr) { continue } - filtered = append(filtered, it) - } - c.JSON(http.StatusOK, filtered) + var unlock models.RewardItem + if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil { + unlock = models.RewardItem{ + Name: "Odemknout vlastní avatar (upload)", + Type: "avatar_upload_unlock", + CostPoints: 50, + ImageURL: "", + Stock: -1, + Active: true, + } + _ = 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 + } + } + var items []models.RewardItem + q := ec.DB.Where("active = ?", true) + if err := q.Order("created_at DESC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"}) + return + } + // Filter by optional validity window in metadata (valid_from, valid_to). Also accept legacy expires_at as valid_to. + now := time.Now() + filtered := make([]models.RewardItem, 0, len(items)) + for _, it := range items { + // Mandatory unlock reward is always available + if strings.EqualFold(strings.TrimSpace(it.Type), "avatar_upload_unlock") { + filtered = append(filtered, it) + continue + } + 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 v, ok := it.Metadata["valid_to"]; ok { + 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 startPtr != nil && now.Before(*startPtr) { + continue + } + if endPtr != nil && now.After(*endPtr) { + continue + } + filtered = append(filtered, it) + } + c.JSON(http.StatusOK, filtered) } // POST /api/v1/engagement/redeem (auth) @@ -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) { @@ -356,9 +397,9 @@ func (ec *EngagementController) Redeem(c *gin.Context) { } } red := models.RewardRedemption{ - UserID: userID, - RewardID: item.ID, - Status: "approved", + UserID: userID, + RewardID: item.ID, + Status: "approved", } if strings.HasPrefix(item.Type, "merch_") || item.Type == "custom" { red.Status = "pending" @@ -368,7 +409,7 @@ func (ec *EngagementController) Redeem(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create redemption"}) return } - _ = tx.Create(&models.PointsTransaction{ UserID: userID, Delta: -item.CostPoints, XPDelta: 0, Reason: "redeem", Meta: datatypes.JSONMap{"reward_id": item.ID, "reward_type": item.Type} }).Error + _ = tx.Create(&models.PointsTransaction{UserID: userID, Delta: -item.CostPoints, XPDelta: 0, Reason: "redeem", Meta: datatypes.JSONMap{"reward_id": item.ID, "reward_type": item.Type}}).Error if item.Type == "avatar_static" { _ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("avatar_url", item.ImageURL).Error } @@ -396,11 +437,11 @@ func (ec *EngagementController) Redeem(c *gin.Context) { To: []string{strings.TrimSpace(user.Email)}, Template: "reward_redeemed_user", Data: map[string]interface{}{ - "RewardName": item.Name, - "RewardType": item.Type, - "Points": item.CostPoints, - "Status": red.Status, - "RedeemedAt": redeemedAt, + "RewardName": item.Name, + "RewardType": item.Type, + "Points": item.CostPoints, + "Status": red.Status, + "RedeemedAt": redeemedAt, "UserFirstName": strings.TrimSpace(user.FirstName), "UserLastName": strings.TrimSpace(user.LastName), "UserEmail": strings.TrimSpace(user.Email), @@ -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{ @@ -492,480 +539,591 @@ func (ec *EngagementController) GetAchievements(c *gin.Context) { // Admin: list rewards // GET /api/v1/admin/engagement/rewards func (ec *EngagementController) AdminListRewards(c *gin.Context) { - var items []models.RewardItem - var unlock models.RewardItem - if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil { - unlock = models.RewardItem{ - Name: "Odemknout vlastní avatar (upload)", - Type: "avatar_upload_unlock", - CostPoints: 50, - ImageURL: "", - Stock: -1, - Active: true, - } - _ = 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 } - } - 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 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.StatusOK, gin.H{"items": items}) + var items []models.RewardItem + var unlock models.RewardItem + if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil { + unlock = models.RewardItem{ + Name: "Odemknout vlastní avatar (upload)", + Type: "avatar_upload_unlock", + CostPoints: 50, + ImageURL: "", + Stock: -1, + Active: true, + } + _ = 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 + } + } + 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 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.StatusOK, gin.H{"items": items}) } // Admin: create reward // POST /api/v1/admin/engagement/rewards func (ec *EngagementController) AdminCreateReward(c *gin.Context) { - var body struct{ - Name string `json:"name"` - Type string `json:"type"` - CostPoints int64 `json:"cost_points"` - ImageURL string `json:"image_url"` - Stock int `json:"stock"` - Active *bool `json:"active"` - 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 - } - 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 } - c.JSON(http.StatusOK, item) + var body struct { + Name string `json:"name"` + Type string `json:"type"` + CostPoints int64 `json:"cost_points"` + ImageURL string `json:"image_url"` + Stock int `json:"stock"` + Active *bool `json:"active"` + 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 + } + // 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 + } + c.JSON(http.StatusOK, item) } // Admin: update reward // PUT /api/v1/admin/engagement/rewards/:id func (ec *EngagementController) AdminUpdateReward(c *gin.Context) { - id := c.Param("id") - var body struct{ - Name *string `json:"name"` - Type *string `json:"type"` - CostPoints *int64 `json:"cost_points"` - ImageURL *string `json:"image_url"` - Stock *int `json:"stock"` - 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 } - // 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 - } - 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 - } - 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 - } - } - 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 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.StatusOK, gin.H{"ok": true}) + id := c.Param("id") + var body struct { + Name *string `json:"name"` + Type *string `json:"type"` + CostPoints *int64 `json:"cost_points"` + ImageURL *string `json:"image_url"` + Stock *int `json:"stock"` + 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 + } + // 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 + } + 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 + } + 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 + } + } + // 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 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.StatusOK, gin.H{"ok": true}) } // Admin: delete reward // DELETE /api/v1/admin/engagement/rewards/:id func (ec *EngagementController) AdminDeleteReward(c *gin.Context) { - id := c.Param("id") - // 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 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.StatusOK, gin.H{"ok": true}) + id := c.Param("id") + // Disallow deleting the mandatory reward + var existing models.RewardItem + if err := ec.DB.First(&existing, id).Error; err == nil { + 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.StatusOK, gin.H{"ok": true}) } // Admin: list redemptions // GET /api/v1/admin/engagement/redemptions 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 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.StatusOK, gin.H{"items": items}) + var items []models.RewardRedemption + q := ec.DB.Model(&models.RewardRedemption{}) + 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.StatusOK, gin.H{"items": items}) } // Admin: update redemption status (approve/reject/fulfill) // 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 } - action := strings.ToLower(strings.TrimSpace(body.Action)) - var newStatus string - switch action { - case "approve": newStatus = "approved" - case "reject": newStatus = "rejected" - case "fulfill": newStatus = "fulfilled" - default: - 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 - } - // If rejecting a pending manual redemption, refund points and restore stock in a transaction - if newStatus == "rejected" { - // Load reward to know cost/stock - var reward models.RewardItem - 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 } - // 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 } - // 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 } - } 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 } - } - } 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 - } - } - // Notify user about final decision for manual rewards (best-effort) - if (newStatus == "fulfilled" || newStatus == "rejected") && ec.Email != nil { - var user models.User - _ = ec.DB.First(&user, red.UserID).Error - var reward models.RewardItem - _ = ec.DB.First(&reward, red.RewardID).Error - if strings.TrimSpace(user.Email) != "" { - _ = ec.Email.SendEmail(&email.EmailData{ - Subject: "Aktualizace stavu uplatněné odměny", - To: []string{strings.TrimSpace(user.Email)}, - Template: "reward_redeemed_user", - Data: map[string]interface{}{ - "RewardName": reward.Name, - "RewardType": reward.Type, - "Points": reward.CostPoints, - "Status": newStatus, - "RedeemedAt": time.Now().Format(time.RFC3339), - "UserFirstName": strings.TrimSpace(user.FirstName), - "UserLastName": strings.TrimSpace(user.LastName), - "UserEmail": strings.TrimSpace(user.Email), - }, - }) - } - } - c.JSON(http.StatusOK, gin.H{"ok": true, "status": newStatus}) + 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 + } + action := strings.ToLower(strings.TrimSpace(body.Action)) + var newStatus string + switch action { + case "approve": + newStatus = "approved" + case "reject": + newStatus = "rejected" + case "fulfill": + newStatus = "fulfilled" + default: + 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 + } + // If rejecting a pending manual redemption, refund points and restore stock in a transaction + if newStatus == "rejected" { + // Load reward to know cost/stock + var reward models.RewardItem + 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 + } + // 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 + } + // 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 + } + } 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 + } + } + } 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 + } + } + // Notify user about final decision for manual rewards (best-effort) + if (newStatus == "fulfilled" || newStatus == "rejected") && ec.Email != nil { + var user models.User + _ = ec.DB.First(&user, red.UserID).Error + var reward models.RewardItem + _ = ec.DB.First(&reward, red.RewardID).Error + if strings.TrimSpace(user.Email) != "" { + _ = ec.Email.SendEmail(&email.EmailData{ + Subject: "Aktualizace stavu uplatněné odměny", + To: []string{strings.TrimSpace(user.Email)}, + Template: "reward_redeemed_user", + Data: map[string]interface{}{ + "RewardName": reward.Name, + "RewardType": reward.Type, + "Points": reward.CostPoints, + "Status": newStatus, + "RedeemedAt": time.Now().Format(time.RFC3339), + "UserFirstName": strings.TrimSpace(user.FirstName), + "UserLastName": strings.TrimSpace(user.LastName), + "UserEmail": strings.TrimSpace(user.Email), + }, + }) + } + } + c.JSON(http.StatusOK, gin.H{"ok": true, "status": newStatus}) } // GET /api/v1/engagement/transactions (auth) // Query: limit (default 50, max 200), reason? func (ec *EngagementController) GetMyTransactions(c *gin.Context) { - uid, _ := c.Get("userID") - userID := uid.(uint) - // Admins: engagement hidden – return empty list - if c.GetString("userRole") == "admin" { - c.JSON(http.StatusOK, gin.H{"items": []models.PointsTransaction{}}) - return - } - 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 } - } - } - q := ec.DB.Model(&models.PointsTransaction{}).Where("user_id = ?", userID) - if r := strings.TrimSpace(c.Query("reason")); r != "" { - q = q.Where("reason = ?", r) - } - 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.StatusOK, gin.H{"items": items}) + uid, _ := c.Get("userID") + userID := uid.(uint) + // Admins: engagement hidden – return empty list + if c.GetString("userRole") == "admin" { + c.JSON(http.StatusOK, gin.H{"items": []models.PointsTransaction{}}) + return + } + 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 + } + } + } + q := ec.DB.Model(&models.PointsTransaction{}).Where("user_id = ?", userID) + if r := strings.TrimSpace(c.Query("reason")); r != "" { + q = q.Where("reason = ?", r) + } + 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.StatusOK, gin.H{"items": items}) } // POST /api/v1/engagement/checkin (auth) // Awards daily check-in points (cap 1/day via service caps); Admins do not earn points func (ec *EngagementController) Checkin(c *gin.Context) { - uid, _ := c.Get("userID") - userID := uid.(uint) - // Admins: engagement disabled (no-op) - if c.GetString("userRole") == "admin" { - svc := services.NewEngagementService(ec.DB) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) - return - } - // Fast check if already checked in today - now := time.Now() - startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - var cnt int64 - _ = ec.DB.Model(&models.PointsTransaction{}). - Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "daily_checkin", startOfDay). - Count(&cnt).Error - already := cnt > 0 - svc := services.NewEngagementService(ec.DB) - if !already { - _, _ = svc.AwardPointsCapped(userID, 8, "daily_checkin", map[string]interface{}{"at": now.Format(time.RFC3339)}) - } - // Ensure profile for response - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": !already, "points": up.Points, "level": up.Level, "xp": up.XP}) + uid, _ := c.Get("userID") + userID := uid.(uint) + // Admins: engagement disabled (no-op) + if c.GetString("userRole") == "admin" { + svc := services.NewEngagementService(ec.DB) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) + return + } + // Fast check if already checked in today + now := time.Now() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + var cnt int64 + _ = ec.DB.Model(&models.PointsTransaction{}). + Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "daily_checkin", startOfDay). + Count(&cnt).Error + already := cnt > 0 + svc := services.NewEngagementService(ec.DB) + if !already { + _, _ = svc.AwardPointsCapped(userID, 8, "daily_checkin", map[string]interface{}{"at": now.Format(time.RFC3339)}) + } + // Ensure profile for response + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": !already, "points": up.Points, "level": up.Level, "xp": up.XP}) } // POST /api/v1/engagement/article-read (auth) // Awards small points for unique article reads (cap 3/day + dedupe per article); Admins do not earn points func (ec *EngagementController) ArticleRead(c *gin.Context) { - uid, _ := c.Get("userID") - userID := uid.(uint) - // Admins: engagement disabled (no-op) - if c.GetString("userRole") == "admin" { - svc := services.NewEngagementService(ec.DB) - up, _ := svc.EnsureProfile(userID) - 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"` } - if err := c.ShouldBindJSON(&body); err != nil || body.ArticleID == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) - return - } - // Dedupe per article: check recent transactions with meta.article_id == body.ArticleID - var txs []models.PointsTransaction - _ = ec.DB.Where("user_id = ? AND reason = ?", userID, "article_read").Order("created_at DESC").Limit(200).Find(&txs).Error - for _, t := range txs { - if t.Meta != nil { - if v, ok := t.Meta["article_id"]; ok { - switch vv := v.(type) { - case string: - if strings.TrimSpace(vv) == strconv.FormatUint(uint64(body.ArticleID), 10) { - svc := services.NewEngagementService(ec.DB) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) - return - } - case float64: - if uint(vv) == body.ArticleID { - svc := services.NewEngagementService(ec.DB) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) - return - } - } - } - } - } - svc := services.NewEngagementService(ec.DB) - _, _ = svc.AwardPointsCapped(userID, 2, "article_read", map[string]interface{}{"article_id": strconv.FormatUint(uint64(body.ArticleID), 10)}) - up, _ := svc.EnsureProfile(userID) - c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": true, "points": up.Points, "level": up.Level, "xp": up.XP}) + uid, _ := c.Get("userID") + userID := uid.(uint) + // Admins: engagement disabled (no-op) + if c.GetString("userRole") == "admin" { + svc := services.NewEngagementService(ec.DB) + up, _ := svc.EnsureProfile(userID) + 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"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.ArticleID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + // Dedupe per article: check recent transactions with meta.article_id == body.ArticleID + var txs []models.PointsTransaction + _ = ec.DB.Where("user_id = ? AND reason = ?", userID, "article_read").Order("created_at DESC").Limit(200).Find(&txs).Error + for _, t := range txs { + if t.Meta != nil { + if v, ok := t.Meta["article_id"]; ok { + switch vv := v.(type) { + case string: + if strings.TrimSpace(vv) == strconv.FormatUint(uint64(body.ArticleID), 10) { + svc := services.NewEngagementService(ec.DB) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) + return + } + case float64: + if uint(vv) == body.ArticleID { + svc := services.NewEngagementService(ec.DB) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": false, "points": up.Points, "level": up.Level, "xp": up.XP}) + return + } + } + } + } + } + svc := services.NewEngagementService(ec.DB) + _, _ = svc.AwardPointsCapped(userID, 2, "article_read", map[string]interface{}{"article_id": strconv.FormatUint(uint64(body.ArticleID), 10)}) + up, _ := svc.EnsureProfile(userID) + c.JSON(http.StatusOK, gin.H{"ok": true, "awarded": true, "points": up.Points, "level": up.Level, "xp": up.XP}) } // GET /api/v1/engagement/leaderboard (auth) // Query: metric=points|level|xp, limit (default 20, max 100) func (ec *EngagementController) GetLeaderboard(c *gin.Context) { - metric := strings.ToLower(strings.TrimSpace(c.Query("metric"))) - if metric == "" { - metric = "points" - } - limit := 20 - if v := strings.TrimSpace(c.Query("limit")); v != "" { - if n, err := strconv.Atoi(v); err == nil { - if n > 0 && n <= 100 { - limit = n - } - } - } + metric := strings.ToLower(strings.TrimSpace(c.Query("metric"))) + if metric == "" { + metric = "points" + } + limit := 20 + if v := strings.TrimSpace(c.Query("limit")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + if n > 0 && n <= 100 { + limit = n + } + } + } - type row struct { - UserID uint - FirstName string - LastName string - Username string - Role string - Points int64 - Level int - XP int64 - AvatarURL string - AnimatedAvatarURL string - } - q := ec.DB.Table("user_profiles AS up"). - Select("up.user_id, u.first_name, u.last_name, up.username, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url"). - Joins("JOIN users u ON u.id = up.user_id") - switch metric { - case "xp": - q = q.Order("up.xp DESC, up.points DESC, up.level DESC") - case "level": - q = q.Order("up.level DESC, up.xp DESC, up.points DESC") - default: - q = q.Order("up.points DESC, up.level DESC, up.xp DESC") - } - q = q.Limit(limit) + type row struct { + UserID uint + FirstName string + LastName string + Username string + Role string + Points int64 + Level int + XP int64 + AvatarURL string + AnimatedAvatarURL string + } + q := ec.DB.Table("user_profiles AS up"). + Select("up.user_id, u.first_name, u.last_name, up.username, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url"). + Joins("JOIN users u ON u.id = up.user_id") + switch metric { + case "xp": + q = q.Order("up.xp DESC, up.points DESC, up.level DESC") + case "level": + q = q.Order("up.level DESC, up.xp DESC, up.points DESC") + default: + q = q.Order("up.points DESC, up.level DESC, up.xp DESC") + } + q = q.Limit(limit) - var rows []row - if err := q.Scan(&rows).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"}) - return - } + var rows []row + if err := q.Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"}) + return + } - items := make([]gin.H, 0, len(rows)) - for i, r := range rows { - items = append(items, gin.H{ - "rank": i + 1, - "user_id": r.UserID, - "first_name": r.FirstName, - "last_name": r.LastName, - "username": r.Username, - "role": r.Role, - "points": r.Points, - "level": r.Level, - "xp": r.XP, - "avatar_url": r.AvatarURL, - "animated_avatar_url": r.AnimatedAvatarURL, - }) - } - c.JSON(http.StatusOK, gin.H{"items": items}) + items := make([]gin.H, 0, len(rows)) + for i, r := range rows { + items = append(items, gin.H{ + "rank": i + 1, + "user_id": r.UserID, + "first_name": r.FirstName, + "last_name": r.LastName, + "username": r.Username, + "role": r.Role, + "points": r.Points, + "level": r.Level, + "xp": r.XP, + "avatar_url": r.AvatarURL, + "animated_avatar_url": r.AnimatedAvatarURL, + }) + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // GET /api/v1/admin/engagement/leaderboard (admin) // Query: metric=points|level|xp, limit (default 50, max 1000) func (ec *EngagementController) AdminGetLeaderboard(c *gin.Context) { - metric := strings.ToLower(strings.TrimSpace(c.Query("metric"))) - if metric == "" { - metric = "points" - } - limit := 50 - if v := strings.TrimSpace(c.Query("limit")); v != "" { - if n, err := strconv.Atoi(v); err == nil { - if n > 0 && n <= 1000 { - limit = n - } - } - } + metric := strings.ToLower(strings.TrimSpace(c.Query("metric"))) + if metric == "" { + metric = "points" + } + limit := 50 + if v := strings.TrimSpace(c.Query("limit")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + if n > 0 && n <= 1000 { + limit = n + } + } + } - type row struct { - UserID uint - FirstName string - LastName string - Email string - Role string - Points int64 - Level int - XP int64 - AvatarURL string - AnimatedAvatarURL string - } - q := ec.DB.Table("user_profiles AS up"). - Select("up.user_id, u.first_name, u.last_name, u.email, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url"). - Joins("JOIN users u ON u.id = up.user_id") - switch metric { - case "xp": - q = q.Order("up.xp DESC, up.points DESC, up.level DESC") - case "level": - q = q.Order("up.level DESC, up.xp DESC, up.points DESC") - default: - q = q.Order("up.points DESC, up.level DESC, up.xp DESC") - } - q = q.Limit(limit) + type row struct { + UserID uint + FirstName string + LastName string + Email string + Role string + Points int64 + Level int + XP int64 + AvatarURL string + AnimatedAvatarURL string + } + q := ec.DB.Table("user_profiles AS up"). + Select("up.user_id, u.first_name, u.last_name, u.email, u.role, up.points, up.level, up.xp, up.avatar_url, up.animated_avatar_url"). + Joins("JOIN users u ON u.id = up.user_id") + switch metric { + case "xp": + q = q.Order("up.xp DESC, up.points DESC, up.level DESC") + case "level": + q = q.Order("up.level DESC, up.xp DESC, up.points DESC") + default: + q = q.Order("up.points DESC, up.level DESC, up.xp DESC") + } + q = q.Limit(limit) - var rows []row - if err := q.Scan(&rows).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"}) - return - } - items := make([]gin.H, 0, len(rows)) - for i, r := range rows { - items = append(items, gin.H{ - "rank": i + 1, - "user_id": r.UserID, - "first_name": r.FirstName, - "last_name": r.LastName, - "email": r.Email, - "role": r.Role, - "points": r.Points, - "level": r.Level, - "xp": r.XP, - "avatar_url": r.AvatarURL, - "animated_avatar_url": r.AnimatedAvatarURL, - }) - } - c.JSON(http.StatusOK, gin.H{"items": items}) + var rows []row + if err := q.Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load leaderboard"}) + return + } + items := make([]gin.H, 0, len(rows)) + for i, r := range rows { + items = append(items, gin.H{ + "rank": i + 1, + "user_id": r.UserID, + "first_name": r.FirstName, + "last_name": r.LastName, + "email": r.Email, + "role": r.Role, + "points": r.Points, + "level": r.Level, + "xp": r.XP, + "avatar_url": r.AvatarURL, + "animated_avatar_url": r.AnimatedAvatarURL, + }) + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // 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 } - 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 - } - // Optionally include user basic info - var u models.User - _ = ec.DB.Select("id, first_name, last_name, email, role").Where("id = ?", userIDStr).First(&u).Error - c.JSON(http.StatusOK, gin.H{ - "user_id": up.UserID, - "first_name": strings.TrimSpace(u.FirstName), - "last_name": strings.TrimSpace(u.LastName), - "email": strings.TrimSpace(u.Email), - "role": u.Role, - "points": up.Points, - "level": up.Level, - "xp": up.XP, - "username": up.Username, - "avatar_url": up.AvatarURL, - "animated_avatar_url": up.AnimatedAvatarURL, - "avatar_upload_unlocked": up.AvatarUploadUnlocked, - "animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked, - }) + userIDStr := strings.TrimSpace(c.Param("user_id")) + 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 + } + // Optionally include user basic info + var u models.User + _ = ec.DB.Select("id, first_name, last_name, email, role").Where("id = ?", userIDStr).First(&u).Error + c.JSON(http.StatusOK, gin.H{ + "user_id": up.UserID, + "first_name": strings.TrimSpace(u.FirstName), + "last_name": strings.TrimSpace(u.LastName), + "email": strings.TrimSpace(u.Email), + "role": u.Role, + "points": up.Points, + "level": up.Level, + "xp": up.XP, + "username": up.Username, + "avatar_url": up.AvatarURL, + "animated_avatar_url": up.AnimatedAvatarURL, + "avatar_upload_unlocked": up.AvatarUploadUnlocked, + "animated_avatar_upload_unlocked": up.AnimatedAvatarUploadUnlocked, + }) } // Admin: list points transactions with optional filters // 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) } - limit := 100 - if v := strings.TrimSpace(c.Query("limit")); v != "" { - 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.StatusOK, gin.H{"items": items}) + 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) + } + limit := 100 + if v := strings.TrimSpace(c.Query("limit")); v != "" { + 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.StatusOK, gin.H{"items": items}) } diff --git a/internal/controllers/gallery_controller.go b/internal/controllers/gallery_controller.go index 0582189..4958d6e 100644 --- a/internal/controllers/gallery_controller.go +++ b/internal/controllers/gallery_controller.go @@ -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) @@ -242,13 +245,13 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) { } logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos)) - + // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) // Don't fail the request, just log the error } - + c.JSON(http.StatusOK, gin.H{ "message": "Album fetched and saved successfully", "album": albumData, @@ -300,13 +303,13 @@ func (gc *GalleryController) DeleteAlbum(c *gin.Context) { } logger.Info("Deleted album: %s", albumID) - + // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) // Don't fail the request, just log the error } - + c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"}) } @@ -316,27 +319,27 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) { var settings struct { GalleryURL string `json:"gallery_url"` } - + if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil { logger.Error("Failed to load settings: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"}) return } - + zoneramaURL := strings.TrimSpace(settings.GalleryURL) if zoneramaURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"}) return } - + // Validate it's a Zonerama URL if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") { c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"}) return } - + logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL) - + // Call the refresh service in a goroutine to avoid blocking go func() { if err := services.RefreshZoneramaNow(zoneramaURL); err != nil { @@ -349,7 +352,7 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) { } } }() - + c.JSON(http.StatusOK, gin.H{ "message": "Zonerama refresh started", "url": zoneramaURL, diff --git a/internal/controllers/navigation_controller.go b/internal/controllers/navigation_controller.go index a5786a5..3e67fd2 100644 --- a/internal/controllers/navigation_controller.go +++ b/internal/controllers/navigation_controller.go @@ -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 } diff --git a/internal/controllers/shortlink_controller.go b/internal/controllers/shortlink_controller.go index e34256f..014ec22 100644 --- a/internal/controllers/shortlink_controller.go +++ b/internal/controllers/shortlink_controller.go @@ -29,66 +29,68 @@ type ShortLinkController struct { // Restrictions: only allows shortening links pointing to this site (request host) // or to the configured FrontendBaseURL. Intended for visitor share/copy flows. func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) { - var body struct { - TargetURL string `json:"target_url"` - Title string `json:"title"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - target, err := parseTarget(body.TargetURL) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) - return - } - tu, _ := url.Parse(target) - if tu == nil || tu.Host == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) - return - } - // 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] } - return h - } - allowed := stripPort(tu.Host) == stripPort(reqHost) - if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" { - if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" { - if stripPort(fu.Host) == stripPort(tu.Host) { - allowed = true - } - } - } - if !allowed { - c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"}) - return - } + var body struct { + TargetURL string `json:"target_url"` + Title string `json:"title"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + target, err := parseTarget(body.TargetURL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) + return + } + tu, _ := url.Parse(target) + if tu == nil || tu.Host == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) + return + } + // 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] + } + return h + } + allowed := stripPort(tu.Host) == stripPort(reqHost) + if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" { + if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" { + if stripPort(fu.Host) == stripPort(tu.Host) { + allowed = true + } + } + } + if !allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"}) + return + } - // Deterministic code from URL so repeated calls return same shortlink - code := "p-" + codeFromHash(target, 7) - link := models.ShortLink{ - Code: code, - TargetURL: target, - Title: strings.TrimSpace(body.Title), - Active: true, - } - if err := s.DB.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "code"}}, - DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}), - }).Create(&link).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()}) - return - } - var saved models.ShortLink - if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil { - saved = link - } - scheme := getScheme(c) - host := c.Request.Host - shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code) - c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved}) + // Deterministic code from URL so repeated calls return same shortlink + code := "p-" + codeFromHash(target, 7) + link := models.ShortLink{ + Code: code, + TargetURL: target, + Title: strings.TrimSpace(body.Title), + Active: true, + } + if err := s.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "code"}}, + DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}), + }).Create(&link).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()}) + return + } + var saved models.ShortLink + if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil { + saved = link + } + scheme := getScheme(c) + host := c.Request.Host + shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code) + c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved}) } func NewShortLinkController(db *gorm.DB) *ShortLinkController { @@ -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) @@ -137,20 +141,24 @@ func codeFromHash(s string, n int) string { } func sanitizeCode(in string) string { - s := strings.TrimSpace(in) - if s == "" { return "" } - // filter allowed runes - rb := make([]rune, 0, len(s)) - for _, ch := range s { - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' { - rb = append(rb, ch) - } - } - if len(rb) == 0 { return "" } - if len(rb) > 16 { - rb = rb[:16] - } - return string(rb) + s := strings.TrimSpace(in) + if s == "" { + return "" + } + // filter allowed runes + rb := make([]rune, 0, len(s)) + for _, ch := range s { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' { + rb = append(rb, ch) + } + } + if len(rb) == 0 { + return "" + } + if len(rb) > 16 { + rb = rb[:16] + } + return string(rb) } func getScheme(c *gin.Context) string { @@ -174,11 +182,20 @@ 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 } - 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) { @@ -274,23 +291,25 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) { return } code := sanitizeCode(strings.TrimSpace(body.Code)) - if code == "" { - for i := 0; i < 5; i++ { - cnd, _ := randCode(7) - var cnt int64 - s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt) - if cnt == 0 { - code = cnd - break - } - } - } + if code == "" { + for i := 0; i < 5; i++ { + cnd, _ := randCode(7) + var cnt int64 + s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt) + if cnt == 0 { + code = cnd + break + } + } + } if code == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"}) 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 } - start := time.Now().AddDate(0,0,-30) - type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` } + 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"` + } 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). diff --git a/internal/controllers/sweepstakes_controller.go b/internal/controllers/sweepstakes_controller.go index 3d9976a..5f25a06 100644 --- a/internal/controllers/sweepstakes_controller.go +++ b/internal/controllers/sweepstakes_controller.go @@ -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"` @@ -48,384 +55,589 @@ func (sc *SweepstakesController) PublicVisualData(c *gin.Context) { Joins("LEFT JOIN user_profiles up ON up.user_id = u.id"). Where("e.sweepstake_id = ?", id) _ = q.Scan(&entries).Error - c.JSON(http.StatusOK, gin.H{ "sweepstake": s, "entries": entries, "winners": winners }) + c.JSON(http.StatusOK, gin.H{"sweepstake": s, "entries": entries, "winners": winners}) } // Admin: set or change prize for a specific winner // 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 } - // 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 } - 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 } - c.JSON(http.StatusOK, gin.H{"ok": true}) + 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 + } + // 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 + } + 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 + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: update winner status (claim/delivered/pending) // PATCH /api/v1/admin/sweepstakes/:id/winners/:winner_id { "claim_status": "claimed|delivered|pending", "claim_note":"..." } func (sc *SweepstakesController) AdminUpdateWinner(c *gin.Context) { - wid := strings.TrimSpace(c.Param("winner_id")) - var body struct{ - 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 } - st := strings.ToLower(strings.TrimSpace(body.ClaimStatus)) - if st == "" { st = "pending" } - switch st { - case "pending","claimed","delivered": - default: - 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 } - // Update fields - updates := map[string]interface{}{ "claim_status": st } - 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 { - var p models.SweepstakePrize - if err := sc.DB.First(&p, *w.PrizeID).Error; err == nil { - if p.Kind == "points" || p.Kind == "xp" || p.Kind == "points_xp" { - 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 - } - if pointsDelta != 0 || xpDelta != 0 { - _, _ = svc.AwardPointsAndXP(w.UserID, pointsDelta, xpDelta, "sweepstake_prize", map[string]interface{}{"prize_id": p.ID, "sweepstake_id": w.SweepstakeID}) - } - now := time.Now() - updates["awarded_at"] = &now - } - } - } - 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}) + wid := strings.TrimSpace(c.Param("winner_id")) + var body struct { + 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 + } + st := strings.ToLower(strings.TrimSpace(body.ClaimStatus)) + if st == "" { + st = "pending" + } + switch st { + case "pending", "claimed", "delivered": + default: + 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 + } + // Update fields + updates := map[string]interface{}{"claim_status": st} + 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 { + var p models.SweepstakePrize + if err := sc.DB.First(&p, *w.PrizeID).Error; err == nil { + if p.Kind == "points" || p.Kind == "xp" || p.Kind == "points_xp" { + 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 + } + if pointsDelta != 0 || xpDelta != 0 { + _, _ = svc.AwardPointsAndXP(w.UserID, pointsDelta, xpDelta, "sweepstake_prize", map[string]interface{}{"prize_id": p.ID, "sweepstake_id": w.SweepstakeID}) + } + now := time.Now() + updates["awarded_at"] = &now + } + } + } + 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}) } // Admin: list prizes // GET /api/v1/admin/sweepstakes/:id/prizes 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.StatusOK, gin.H{"items": items}) + 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.StatusOK, gin.H{"items": items}) } // Admin: create prize // POST /api/v1/admin/sweepstakes/:id/prizes 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 } - var body struct{ - Name string `json:"name"` - Description string `json:"description"` - ImageURL string `json:"image_url"` - Value string `json:"value"` - Quantity int `json:"quantity"` - DisplayOrder int `json:"display_order"` - Kind string `json:"kind"` - Points int64 `json:"points"` - 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 - } - // 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 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 } - c.JSON(http.StatusOK, p) + 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 + } + var body struct { + Name string `json:"name"` + Description string `json:"description"` + ImageURL string `json:"image_url"` + Value string `json:"value"` + Quantity int `json:"quantity"` + DisplayOrder int `json:"display_order"` + Kind string `json:"kind"` + Points int64 `json:"points"` + 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 + } + // 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 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 + } + c.JSON(http.StatusOK, p) } // Admin: update prize // PUT /api/v1/admin/sweepstakes/:id/prizes/:prize_id 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 } - 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 } } - // Validate kind if present - if v, ok := upd["kind"]; ok { - sv := strings.ToLower(strings.TrimSpace(toString(v))) - switch sv { - case "physical","points","xp","points_xp": - upd["kind"] = sv - default: - 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 } - c.JSON(http.StatusOK, gin.H{"ok": true}) + 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 + } + 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 + } + } + // Validate kind if present + if v, ok := upd["kind"]; ok { + sv := strings.ToLower(strings.TrimSpace(toString(v))) + switch sv { + case "physical", "points", "xp", "points_xp": + upd["kind"] = sv + default: + 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 + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: delete prize // 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 } - c.JSON(http.StatusOK, gin.H{"ok": true}) + 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 + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: reorder prizes // 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 } - 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.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return } - c.JSON(http.StatusOK, gin.H{"ok": true}) + 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 + } + 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.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: visualization data for sweepstake (participants and winners) // GET /api/v1/admin/sweepstakes/:id/visual 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 - } - // 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"` } - _ = 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 { - UserID uint `json:"user_id"` - DisplayName string `json:"display_name"` - AvatarURL string `json:"avatar_url"` - } - var entries []entryRow - q := sc.DB.Table("sweepstake_entries AS e"). - Select("e.user_id, TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS display_name, COALESCE(up.animated_avatar_url, up.avatar_url, '') AS avatar_url"). - Joins("JOIN users u ON u.id = e.user_id"). - Joins("LEFT JOIN user_profiles up ON up.user_id = u.id"). - Where("e.sweepstake_id = ?", id) - _ = q.Scan(&entries).Error - c.JSON(http.StatusOK, gin.H{ - "sweepstake": s, - "entries": entries, - "winners": winners, - }) + 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 + } + // 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"` + } + _ = 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 { + UserID uint `json:"user_id"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + } + var entries []entryRow + q := sc.DB.Table("sweepstake_entries AS e"). + Select("e.user_id, TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS display_name, COALESCE(up.animated_avatar_url, up.avatar_url, '') AS avatar_url"). + Joins("JOIN users u ON u.id = e.user_id"). + Joins("LEFT JOIN user_profiles up ON up.user_id = u.id"). + Where("e.sweepstake_id = ?", id) + _ = q.Scan(&entries).Error + c.JSON(http.StatusOK, gin.H{ + "sweepstake": s, + "entries": entries, + "winners": winners, + }) } // Admin: list sweepstakes with optional status filter 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 } - c.JSON(http.StatusOK, gin.H{"items": items}) + 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 + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // Admin: create sweepstake func (sc *SweepstakesController) AdminCreate(c *gin.Context) { - var body struct{ - Title string `json:"title"` - Description string `json:"description"` - ImageURL string `json:"image_url"` - RulesURL string `json:"rules_url"` - StartAt time.Time `json:"start_at"` - EndAt time.Time `json:"end_at"` - PickerStyle string `json:"picker_style"` - TotalPrizes int `json:"total_prizes"` - PrizeSummary string `json:"prize_summary"` - EntryCostPoints int `json:"entry_cost_points"` - EntryFeeCZK float64 `json:"entry_fee_czk"` - 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 - } - item := models.Sweepstake{ - Title: strings.TrimSpace(body.Title), - Description: strings.TrimSpace(body.Description), - ImageURL: strings.TrimSpace(body.ImageURL), - 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)), - 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), - 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 } - c.JSON(http.StatusOK, item) + var body struct { + Title string `json:"title"` + Description string `json:"description"` + ImageURL string `json:"image_url"` + RulesURL string `json:"rules_url"` + StartAt time.Time `json:"start_at"` + EndAt time.Time `json:"end_at"` + PickerStyle string `json:"picker_style"` + TotalPrizes int `json:"total_prizes"` + PrizeSummary string `json:"prize_summary"` + EntryCostPoints int `json:"entry_cost_points"` + EntryFeeCZK float64 `json:"entry_fee_czk"` + 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 + } + item := models.Sweepstake{ + Title: strings.TrimSpace(body.Title), + Description: strings.TrimSpace(body.Description), + ImageURL: strings.TrimSpace(body.ImageURL), + 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)), + 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), + 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 + } + c.JSON(http.StatusOK, item) } // Admin: update sweepstake 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 } - 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 } - // Clamp total_prizes if provided - if v, ok := upd["total_prizes"]; ok { - // Coerce to integer first - vv := 1 - switch t := v.(type) { - case int: - vv = t - case int64: - vv = int(t) - case float64: - vv = int(t) - case string: - 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 } - 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 } - c.JSON(http.StatusOK, gin.H{"ok": true}) + 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 + } + 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 + } + // Clamp total_prizes if provided + if v, ok := upd["total_prizes"]; ok { + // Coerce to integer first + vv := 1 + switch t := v.(type) { + case int: + vv = t + case int64: + vv = int(t) + case float64: + vv = int(t) + case string: + 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 + } + 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 + } + 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 } - c.JSON(http.StatusOK, gin.H{"ok": true}) + 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 + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Protected: enter sweepstake (deduct points if needed, enforce max entries) func (sc *SweepstakesController) Enter(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - 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 } - now := time.Now() - 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 } - 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 } - costPoints := s.EntryCostPoints - 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 } - 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 } - 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 } - } else { - 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}) + id := strings.TrimSpace(c.Param("id")) + 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 + } + now := time.Now() + 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 + } + 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 + } + costPoints := s.EntryCostPoints + 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 + } + 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 + } + 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 + } + } else { + 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}) } // Protected: mark visual played time for current user's entry func (sc *SweepstakesController) MarkVisualPlayed(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - uid, _ := c.Get("userID") - 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 } - _ = sc.DB.Model(&models.SweepstakeEntry{}).Where("id = ?", e.ID).Update("visual_played_at", &now).Error - c.JSON(http.StatusOK, gin.H{"ok": true}) + id := strings.TrimSpace(c.Param("id")) + uid, _ := c.Get("userID") + 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 + } + _ = sc.DB.Model(&models.SweepstakeEntry{}).Where("id = ?", e.ID).Update("visual_played_at", &now).Error + c.JSON(http.StatusOK, gin.H{"ok": true}) } // Protected: list my winnings 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 } - c.JSON(http.StatusOK, gin.H{"items": items}) + 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 + } + c.JSON(http.StatusOK, gin.H{"items": items}) } // Public: get current visible sweepstake (upcoming/active/finalized within visibility window) func (sc *SweepstakesController) GetCurrent(c *gin.Context) { - now := time.Now() - var s models.Sweepstake - q := sc.DB.Where("start_at <= ? AND (visibility_until IS NULL OR visibility_until >= ?)", now, now).Order("start_at DESC") - if err := q.First(&s).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusOK, gin.H{"sweepstake": nil}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load sweepstake"}) - return - } - state := "upcoming" - 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 } - hasEntered := false - visualPlayedAt := (*time.Time)(nil) - if uid, ok := c.Get("userID"); ok && uid != nil { - 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 - visualPlayedAt = e.VisualPlayedAt - } - } - c.JSON(http.StatusOK, gin.H{ - "sweepstake": s, - "prizes": prizes, - "winners": winners, - "state": state, - "has_entered": hasEntered, - "visual_played_at": visualPlayedAt, - }) + now := time.Now() + var s models.Sweepstake + q := sc.DB.Where("start_at <= ? AND (visibility_until IS NULL OR visibility_until >= ?)", now, now).Order("start_at DESC") + if err := q.First(&s).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusOK, gin.H{"sweepstake": nil}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load sweepstake"}) + return + } + state := "upcoming" + 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 + } + 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)).Order("id ASC").First(&e).Error; err == nil { + visualPlayedAt = e.VisualPlayedAt + } + } + c.JSON(http.StatusOK, gin.H{ + "sweepstake": s, + "prizes": prizes, + "winners": winners, + "state": state, + "has_entered": hasEntered, + "visual_played_at": visualPlayedAt, + "my_entries_count": myEntriesCount, + "can_enter": canEnter, + }) } // Admin: list entries 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,47 +656,81 @@ 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 { - switch t := v.(type) { - case string: - return t - case []byte: - return string(t) - default: - return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(fmt.Sprintf("%v", v)), "\n", " ")) - } + switch t := v.(type) { + case string: + return t + case []byte: + return string(t) + default: + return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(fmt.Sprintf("%v", v)), "\n", " ")) + } } func toNonNegInt64(v interface{}) int64 { - switch n := v.(type) { - case int64: - if n < 0 { return 0 }; return n - case int: - if n < 0 { return 0 }; return int64(n) - case float64: - if n < 0 { return 0 }; return int64(n) - case float32: - if n < 0 { return 0 }; return int64(n) - case string: - if strings.TrimSpace(n) == "" { return 0 } - if f, err := strconv.ParseFloat(n, 64); err == nil { - if f < 0 { return 0 } - return int64(f) - } - return 0 - default: - return 0 - } + switch n := v.(type) { + case int64: + if n < 0 { + return 0 + } + return n + case int: + if n < 0 { + return 0 + } + return int64(n) + case float64: + if n < 0 { + return 0 + } + return int64(n) + case float32: + if n < 0 { + return 0 + } + return int64(n) + case string: + if strings.TrimSpace(n) == "" { + return 0 + } + if f, err := strconv.ParseFloat(n, 64); err == nil { + if f < 0 { + return 0 + } + return int64(f) + } + return 0 + default: + return 0 + } } diff --git a/internal/helpers/engagement_helpers.go b/internal/helpers/engagement_helpers.go index 5818274..e0fd6da 100644 --- a/internal/helpers/engagement_helpers.go +++ b/internal/helpers/engagement_helpers.go @@ -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 { diff --git a/internal/models/models.go b/internal/models/models.go index 371c417..caf0fed 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -88,17 +88,17 @@ type Article struct { OGImageURL string `json:"og_image_url"` // Optional: link to external content or embedded media ExternalLink string `json:"external_link"` - ViewCount int `gorm:"default:0;index" json:"view_count"` - ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes - UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session) + ViewCount int `gorm:"default:0;index" json:"view_count"` + ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes + UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session) // Store the category name directly to simplify queries (denormalized) CategoryName string `json:"category_name"` - Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...] + Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...] // Gallery association (optional) - GalleryAlbumID string `json:"gallery_album_id"` - GalleryAlbumURL string `json:"gallery_album_url"` + GalleryAlbumID string `json:"gallery_album_id"` + GalleryAlbumURL string `json:"gallery_album_url"` // Stored as JSON string or comma-separated list; frontend normalizes - GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"` + GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"` // YouTube video association (optional) YouTubeVideoID string `json:"youtube_video_id"` YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"` @@ -108,10 +108,10 @@ type Article struct { // Removed omitempty to always include in JSON (even if null) MatchLink *ArticleMatchLink `gorm:"-" json:"match_link"` // Computed helpers (not persisted) - CategorySlug string `gorm:"-" json:"category_slug,omitempty"` - CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"` - NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"` - URL string `gorm:"-" json:"url,omitempty"` + CategorySlug string `gorm:"-" json:"category_slug,omitempty"` + CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"` + NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"` + URL string `gorm:"-" json:"url,omitempty"` } // ArticleTeamLink represents a link from an article to a team identified by an external FACR ID @@ -143,7 +143,7 @@ type Team struct { ShortName string Description string LogoURL string `json:"logo_url"` - IsActive bool `gorm:"default:true"` + IsActive bool `gorm:"default:true"` } // Player represents a football player @@ -184,15 +184,15 @@ type Sponsor struct { // VideoTitleOverride represents a per-video title override (for auto YouTube source) type VideoTitleOverride struct { - VideoID string `json:"video_id"` - Title string `json:"title"` + VideoID string `json:"video_id"` + Title string `json:"title"` } // CustomNavLink represents a simple custom navigation link stored in settings.custom_nav type CustomNavLink struct { - Label string `json:"label"` - URL string `json:"url"` - External bool `json:"external"` + Label string `json:"label"` + URL string `json:"url"` + External bool `json:"external"` } type Settings struct { @@ -257,7 +257,7 @@ type Settings struct { // FrontendBaseURL: e.g. https://club.example.com FrontendBaseURL string `json:"frontend_base_url"` // APIBaseURL: full API root, e.g. https://api.example.com/api/v1 or https://backend.example.com/api/v1 - APIBaseURL string `json:"api_base_url"` + APIBaseURL string `json:"api_base_url"` // Social profiles FacebookURL string `json:"facebook_url"` @@ -279,10 +279,10 @@ type Settings struct { VideosItemsJSON string `gorm:"type:text" json:"-"` // Title overrides for auto-fetched videos (stored as JSON array of {video_id,title}) - VideosOverridesJSON string `gorm:"type:text" json:"-"` - VideosOverrides []VideoTitleOverride `gorm:"-" json:"videos_overrides,omitempty"` + VideosOverridesJSON string `gorm:"type:text" json:"-"` + VideosOverrides []VideoTitleOverride `gorm:"-" json:"videos_overrides,omitempty"` // Derived helper for API responses (map form used by frontend/admin): video_id -> title - VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"` + VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"` // Merch module configuration MerchModuleEnabled bool `json:"merch_module_enabled"` @@ -313,25 +313,25 @@ type Settings struct { NewsletterQuietEnd int `json:"newsletter_quiet_end"` // 0-23 // Contact/Location information for map - ContactAddress string `json:"contact_address"` - ContactCity string `json:"contact_city"` - ContactZip string `json:"contact_zip"` - ContactCountry string `json:"contact_country"` - ContactPhone string `json:"contact_phone"` - ContactEmail string `json:"contact_email"` + ContactAddress string `json:"contact_address"` + ContactCity string `json:"contact_city"` + ContactZip string `json:"contact_zip"` + ContactCountry string `json:"contact_country"` + ContactPhone string `json:"contact_phone"` + ContactEmail string `json:"contact_email"` // Contact form auto-forwarding - ContactForwardEnabled bool `json:"contact_forward_enabled"` - ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails - LocationLatitude float64 `json:"location_latitude"` - LocationLongitude float64 `json:"location_longitude"` - MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"` - MapStyle string `json:"map_style"` - ShowMapOnHomepage bool `json:"show_map_on_homepage"` + ContactForwardEnabled bool `json:"contact_forward_enabled"` + ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails + LocationLatitude float64 `json:"location_latitude"` + LocationLongitude float64 `json:"location_longitude"` + MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"` + MapStyle string `json:"map_style"` + ShowMapOnHomepage bool `json:"show_map_on_homepage"` // Homepage matches display configuration FinishedMatchDisplayDays int `gorm:"default:2" json:"finished_match_display_days"` - StorageQuotaMB int `json:"storage_quota_mb"` - StorageWarnThreshold int `json:"storage_warn_threshold"` + StorageQuotaMB int `json:"storage_quota_mb"` + StorageWarnThreshold int `json:"storage_warn_threshold"` StorageCriticalThreshold int `json:"storage_critical_threshold"` // External error-review integration @@ -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 == "" { @@ -416,14 +415,14 @@ func (Club) TableName() string { // ContactCategory represents a category for organizing contacts (e.g., "Management", "Coaches", "Office") type ContactCategory struct { - ID uint `gorm:"primarykey" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` - Name string `gorm:"not null;uniqueIndex" json:"name"` - Description string `json:"description"` - DisplayOrder int `gorm:"default:0" json:"display_order"` - IsActive bool `gorm:"default:true" json:"is_active"` + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` + Name string `gorm:"not null;uniqueIndex" json:"name"` + Description string `json:"description"` + DisplayOrder int `gorm:"default:0" json:"display_order"` + IsActive bool `gorm:"default:true" json:"is_active"` } // TableName specifies the table name for the ContactCategory model @@ -433,20 +432,20 @@ func (ContactCategory) TableName() string { // Contact represents a contact person (e.g., coach, manager, office staff) type Contact struct { - ID uint `gorm:"primarykey" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` - CategoryID *uint `gorm:"index" json:"category_id,omitempty"` - Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` - Name string `gorm:"not null" json:"name"` - Position string `json:"position"` // e.g., "Head Coach", "President" - Email string `json:"email"` - Phone string `json:"phone"` - ImageURL string `json:"image_url"` - Description string `gorm:"type:text" json:"description"` - DisplayOrder int `gorm:"default:0" json:"display_order"` - IsActive bool `gorm:"default:true" json:"is_active"` + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` + CategoryID *uint `gorm:"index" json:"category_id,omitempty"` + Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Name string `gorm:"not null" json:"name"` + Position string `json:"position"` // e.g., "Head Coach", "President" + Email string `json:"email"` + Phone string `json:"phone"` + ImageURL string `json:"image_url"` + Description string `gorm:"type:text" json:"description"` + DisplayOrder int `gorm:"default:0" json:"display_order"` + IsActive bool `gorm:"default:true" json:"is_active"` } // TableName specifies the table name for the Contact model diff --git a/internal/models/navigation.go b/internal/models/navigation.go index 9d71551..6cf0fc5 100644 --- a/internal/models/navigation.go +++ b/internal/models/navigation.go @@ -17,21 +17,24 @@ const ( // NavigationItem represents a single navigation menu item type NavigationItem struct { gorm.Model - Label string `gorm:"not null" json:"label"` - URL string `json:"url,omitempty"` - Icon string `json:"icon,omitempty"` - Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"` - PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar' - PageID *uint `json:"page_id,omitempty"` // optional reference to specific content - Visible bool `gorm:"not null;default:true" json:"visible"` - DisplayOrder int `gorm:"not null;default:0" json:"display_order"` - ParentID *uint `json:"parent_id,omitempty"` - Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"` - Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"` - Target string `gorm:"default:'_self'" json:"target"` // _self or _blank - CSSClass string `json:"css_class,omitempty"` - RequiresAuth bool `gorm:"default:false" json:"requires_auth"` - RequiresAdmin bool `gorm:"default:false" json:"requires_admin"` + Label string `gorm:"not null" json:"label"` + URL string `json:"url,omitempty"` + Icon string `json:"icon,omitempty"` + Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"` + PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar' + PageID *uint `json:"page_id,omitempty"` // optional reference to specific content + Visible bool `gorm:"not null;default:true" json:"visible"` + DisplayOrder int `gorm:"not null;default:0" json:"display_order"` + ParentID *uint `json:"parent_id,omitempty"` + Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"` + Target string `gorm:"default:'_self'" json:"target"` // _self or _blank + 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 @@ -44,7 +47,7 @@ func (n *NavigationItem) GetURL() string { if n.URL != "" { return n.URL } - + // Map page types to URLs for frontend if n.Type == NavTypePage && n.PageType != "" { pageURLMap := map[string]string{ @@ -66,47 +69,47 @@ func (n *NavigationItem) GetURL() string { return url } } - + // Map admin page types to URLs if (n.Type == NavTypeInternal || n.Type == NavTypePage) && n.PageType != "" && n.RequiresAdmin { adminURLMap := map[string]string{ - "dashboard": "/admin", - "analytics": "/admin/analytika", - "teams": "/admin/tymy", - "matches": "/admin/zapasy", - "activities": "/admin/aktivity", - "players": "/admin/hraci", - "articles": "/admin/clanky", - "categories": "/admin/kategorie", - "about": "/admin/o-klubu", - "videos": "/admin/videa", - "gallery": "/admin/galerie", - "scoreboard": "/admin/scoreboard", - "scoreboard_remote": "/admin/scoreboard/remote", - "clothing": "/admin/obleceni", - "sponsors": "/admin/sponzori", - "banners": "/admin/bannery", - "messages": "/admin/zpravy", - "contacts": "/admin/kontakty", - "newsletter": "/admin/newsletter", - "polls": "/admin/ankety", - "comments": "/admin/komentare", - "sweepstakes": "/admin/sweepstakes", - "navigation": "/admin/navigace", + "dashboard": "/admin", + "analytics": "/admin/analytika", + "teams": "/admin/tymy", + "matches": "/admin/zapasy", + "activities": "/admin/aktivity", + "players": "/admin/hraci", + "articles": "/admin/clanky", + "categories": "/admin/kategorie", + "about": "/admin/o-klubu", + "videos": "/admin/videa", + "gallery": "/admin/galerie", + "scoreboard": "/admin/scoreboard", + "scoreboard_remote": "/admin/scoreboard/remote", + "clothing": "/admin/obleceni", + "sponsors": "/admin/sponzori", + "banners": "/admin/bannery", + "messages": "/admin/zpravy", + "contacts": "/admin/kontakty", + "newsletter": "/admin/newsletter", + "polls": "/admin/ankety", + "comments": "/admin/komentare", + "sweepstakes": "/admin/sweepstakes", + "navigation": "/admin/navigace", "competition_aliases": "/admin/aliasy-soutezi", - "prefetch": "/admin/prefetch", - "users": "/admin/uzivatele", - "settings": "/admin/nastaveni", - "shortlinks": "/admin/shortlinks", - "files": "/admin/soubory", - "docs": "/admin/docs", - "engagement": "/admin/engagement", + "prefetch": "/admin/prefetch", + "users": "/admin/uzivatele", + "settings": "/admin/nastaveni", + "shortlinks": "/admin/shortlinks", + "files": "/admin/soubory", + "docs": "/admin/docs", + "engagement": "/admin/engagement", } if url, ok := adminURLMap[n.PageType]; ok { return url } } - + return "#" } @@ -130,7 +133,7 @@ func (s *SocialLink) GetIconName() string { if s.Icon != "" { return s.Icon } - + iconMap := map[string]string{ "facebook": "FaFacebook", "instagram": "FaInstagram", @@ -141,10 +144,10 @@ func (s *SocialLink) GetIconName() string { "discord": "FaDiscord", "twitch": "FaTwitch", } - + if icon, ok := iconMap[s.Platform]; ok { return icon } - + return "FaLink" } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 5ba5349..b083562 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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) diff --git a/internal/services/newsletter_automation.go b/internal/services/newsletter_automation.go index 4f221f4..4ed6005 100644 --- a/internal/services/newsletter_automation.go +++ b/internal/services/newsletter_automation.go @@ -19,10 +19,10 @@ import ( // NewsletterAutomation handles all automated newsletter sending type NewsletterAutomation struct { - db *gorm.DB - emailSvc email.EmailService - cacheDir string - lastWeekly time.Time + db *gorm.DB + emailSvc email.EmailService + cacheDir string + lastWeekly time.Time lastMatchCheck time.Time } @@ -38,12 +38,12 @@ func NewNewsletterAutomation(db *gorm.DB, emailSvc email.EmailService) *Newslett // Start begins the newsletter automation loop func (na *NewsletterAutomation) Start() { log.Printf("[newsletter-automation] Starting automated newsletter service") - + // Run initial check after 1 minute time.AfterFunc(1*time.Minute, func() { na.RunCycle() }) - + // Then run every 15 minutes ticker := time.NewTicker(15 * time.Minute) go func() { @@ -59,18 +59,18 @@ func (na *NewsletterAutomation) RunCycle() { log.Printf("[newsletter-automation] Skipped: disabled in settings") return } - + log.Printf("[newsletter-automation] Running cycle...") - + // Check for weekly digest na.checkWeeklyDigest() - + // Check for upcoming matches (reminders) na.checkUpcomingMatches() - + // Check for finished matches (results) na.checkFinishedMatches() - + log.Printf("[newsletter-automation] Cycle complete") } @@ -79,40 +79,40 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er if !na.isEnabled() { return fmt.Errorf("newsletter automation is disabled") } - + // Check if already sent var existing models.BlogNotification if err := na.db.Where("article_id = ?", article.ID).First(&existing).Error; err == nil { log.Printf("[newsletter-automation] Blog notification already sent for article %d", article.ID) return nil } - + // Get subscribers interested in blogs subs := na.getSubscribersForType("blogs", article.CategoryName) if len(subs) == 0 { log.Printf("[newsletter-automation] No subscribers for blog notifications") return nil } - + // Build email content subject := fmt.Sprintf("Nový článek: %s", article.Title) baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") articleURL := fmt.Sprintf("%s/news/%s", baseFE, article.Slug) - + html := na.buildBlogNotificationHTML(article, articleURL) - + // Send to each subscriber recipients := make([]string, 0, len(subs)) for _, sub := range subs { recipients = append(recipients, sub.Email) } - + err := na.sendNewsletterToRecipients(recipients, subject, html, "blog_release") if err != nil { logger.Error("[newsletter-automation] Failed to send blog notification: %v", err) return err } - + // Record notification notif := models.BlogNotification{ ArticleID: article.ID, @@ -121,7 +121,7 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er CreatedAt: time.Now(), } na.db.Create(¬if) - + log.Printf("[newsletter-automation] Blog notification sent for article %d to %d recipients", article.ID, len(recipients)) return nil } @@ -129,11 +129,11 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er func (na *NewsletterAutomation) checkWeeklyDigest() { var settings models.Settings na.db.First(&settings) - + if !settings.EnableWeekly { return } - + // Get configured day and hour targetDay := strings.ToLower(strings.TrimSpace(settings.NewsletterWeeklyDay)) if targetDay == "" { @@ -143,47 +143,47 @@ func (na *NewsletterAutomation) checkWeeklyDigest() { if targetHour < 0 || targetHour > 23 { targetHour = 9 // Default to 9 AM } - + now := time.Now() currentDay := strings.ToLower(now.Weekday().String()[:3]) currentHour := now.Hour() - + // Check if it's the right day and hour if currentDay != targetDay || currentHour != targetHour { return } - + // Check if already sent today if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() { return } - + // Get all subscribers interested in weekly digest subs := na.getSubscribersForType("weekly", "") if len(subs) == 0 { log.Printf("[newsletter-automation] No subscribers for weekly digest") return } - + log.Printf("[newsletter-automation] Sending weekly digest to %d subscribers", len(subs)) - + // Build weekly content for each subscriber based on their preferences for _, sub := range subs { prefs := na.parsePreferences(sub) subject, html := BuildNewsletterDigest(na.cacheDir, prefs) - + if strings.TrimSpace(html) == "" { continue } - + err := na.sendNewsletterToRecipients([]string{sub.Email}, subject, html, "weekly") if err != nil { logger.Error("[newsletter-automation] Failed to send weekly digest to %s: %v", sub.Email, err) } - + time.Sleep(200 * time.Millisecond) // Rate limiting } - + na.lastWeekly = now log.Printf("[newsletter-automation] Weekly digest sent") } @@ -191,35 +191,57 @@ func (na *NewsletterAutomation) checkWeeklyDigest() { 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 } - + // Load match data from cache facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json")) matches := facrAllMatches(facr) - + 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) { continue } - + 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) } - + // Check for day-of reminder (match starts in 0-6 hours) if hoursUntil <= 6 && hoursUntil > 0 { na.sendMatchReminder(match, "reminder_day", 0) @@ -234,13 +256,13 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, notifType).First(&existing).Error; err == nil { return } - + // Get subscribers interested in matches and this competition subs := na.getSubscribersForType("matches", match.Competition) if len(subs) == 0 { return } - + // Build email content var subject string if notifType == "reminder_48h" { @@ -248,20 +270,20 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, } else { subject = fmt.Sprintf("Zápas dnes: %s vs %s", match.Home, match.Away) } - + html := na.buildMatchReminderHTML(match, notifType) - + recipients := make([]string, 0, len(subs)) for _, sub := range subs { recipients = append(recipients, sub.Email) } - + err := na.sendNewsletterToRecipients(recipients, subject, html, "match_reminder") if err != nil { logger.Error("[newsletter-automation] Failed to send match reminder: %v", err) return } - + // Record notification notif := models.MatchNotification{ MatchID: matchKey, @@ -271,62 +293,91 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, CreatedAt: time.Now(), } na.db.Create(¬if) - + log.Printf("[newsletter-automation] Match reminder sent: %s (%s) to %d recipients", matchKey, notifType, len(recipients)) } 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 + + // 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")) + matches := facrAllMatches(facr) + + 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 } - } 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") + 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:00–22:00 => quiet when between start and end + inQuiet = currentHour >= quietStart && currentHour < quietEnd + } else { + // Cross-midnight interval, e.g., 22:00–08: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 } } } - - // Load match data - facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json")) - matches := facrAllMatches(facr) - - now := time.Now() - lookback := 6 * time.Hour // Check matches finished in last 6 hours - + for _, match := range matches { if match.Score == "" || !strings.Contains(match.Score, ":") { continue // No score yet } - + matchTime := parseDateTimeISO(match.Date, match.Time) if matchTime.IsZero() || matchTime.After(now) { continue } - + // Check if match finished recently timeSinceMatch := now.Sub(matchTime) if timeSinceMatch > lookback { continue } - + na.sendMatchResult(match) } } @@ -338,27 +389,27 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) { if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, "result").First(&existing).Error; err == nil { return } - + // Get subscribers interested in results subs := na.getSubscribersForType("scores", match.Competition) if len(subs) == 0 { return } - + subject := fmt.Sprintf("Výsledek: %s %s %s", match.Home, match.Score, match.Away) html := na.buildMatchResultHTML(match) - + recipients := make([]string, 0, len(subs)) for _, sub := range subs { recipients = append(recipients, sub.Email) } - + err := na.sendNewsletterToRecipients(recipients, subject, html, "match_result") if err != nil { logger.Error("[newsletter-automation] Failed to send match result: %v", err) return } - + // Record notification notif := models.MatchNotification{ MatchID: matchKey, @@ -368,7 +419,7 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) { CreatedAt: time.Now(), } na.db.Create(¬if) - + log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients)) } @@ -384,7 +435,7 @@ func (na *NewsletterAutomation) isEnabled() bool { func (na *NewsletterAutomation) getSubscribersForType(contentType, category string) []models.NewsletterSubscription { var subs []models.NewsletterSubscription na.db.Where("is_active = ?", true).Find(&subs) - + filtered := make([]models.NewsletterSubscription, 0) for _, sub := range subs { // Check if subscriber wants this content type @@ -409,7 +460,7 @@ func (na *NewsletterAutomation) getSubscribersForType(contentType, category stri filtered = append(filtered, sub) } } - + return filtered } @@ -420,7 +471,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti Competitions: []string{}, Frequency: "daily", } - + // Parse content types if v, ok := sub.Preferences["blogs"].(bool); ok && v { prefs.ContentTypes = append(prefs.ContentTypes, "blogs") @@ -434,7 +485,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti if v, ok := sub.Preferences["scores"].(bool); ok && v { prefs.ContentTypes = append(prefs.ContentTypes, "scores") } - + // Parse categories/competitions if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" { for _, c := range strings.Split(cats, ",") { @@ -443,7 +494,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti } } } - + return prefs } @@ -453,12 +504,12 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string, Content: htmlContent, Recipients: recipients, } - + err := na.emailSvc.SendNewsletter(data) if err != nil { return err } - + // Log sent newsletter contentIDsJSON, _ := json.Marshal([]string{}) logEntry := models.NewsletterSentLog{ @@ -470,42 +521,44 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string, CreatedAt: time.Now(), } na.db.Create(&logEntry) - + return nil } func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string { - // Short description: prefer excerpt; otherwise derive from content - desc := strings.TrimSpace(article.Excerpt) - if desc == "" { - plain := utils.SanitizeString(article.Content) - if len(plain) > 260 { - cut := 240 - if cut < len(plain) { - for cut < len(plain) && plain[cut] != ' ' { - cut++ - } - } - if cut > len(plain) { cut = len(plain) } - plain = strings.TrimSpace(plain[:cut]) + "…" - } - desc = plain - } + // Short description: prefer excerpt; otherwise derive from content + desc := strings.TrimSpace(article.Excerpt) + if desc == "" { + plain := utils.SanitizeString(article.Content) + if len(plain) > 260 { + cut := 240 + if cut < len(plain) { + for cut < len(plain) && plain[cut] != ' ' { + cut++ + } + } + if cut > len(plain) { + cut = len(plain) + } + plain = strings.TrimSpace(plain[:cut]) + "…" + } + desc = plain + } - // Category badge (if available) - cat := strings.TrimSpace(article.CategoryName) - var catHTML string - if cat != "" { - catHTML = fmt.Sprintf(`
%s
`, htmlEsc(cat)) - } + // Category badge (if available) + cat := strings.TrimSpace(article.CategoryName) + var catHTML string + if cat != "" { + catHTML = fmt.Sprintf(`
%s
`, htmlEsc(cat)) + } - // Cover image (optional) - var imgHTML string - if strings.TrimSpace(article.ImageURL) != "" { - imgHTML = fmt.Sprintf(`
cover
`, htmlEsc(article.ImageURL)) - } + // Cover image (optional) + var imgHTML string + if strings.TrimSpace(article.ImageURL) != "" { + imgHTML = fmt.Sprintf(`
cover
`, htmlEsc(article.ImageURL)) + } - html := fmt.Sprintf(` + html := fmt.Sprintf(`

Nový článek na webu

@@ -518,18 +571,18 @@ func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Articl
`, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL) - return html + return html } func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string { - var intro string - if notifType == "reminder_48h" { - intro = "Připomínáme nadcházející zápas:" - } else { - intro = "Zápas je dnes!" - } - - html := fmt.Sprintf(` + var intro string + if notifType == "reminder_48h" { + intro = "Připomínáme nadcházející zápas:" + } else { + intro = "Zápas je dnes!" + } + + html := fmt.Sprintf(`

%s

@@ -541,7 +594,7 @@ func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType st
`, intro, htmlEsc(match.Home), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Time), htmlEsc(match.Competition)) - + return html } @@ -557,6 +610,6 @@ func (na *NewsletterAutomation) buildMatchResultHTML(match Match) string {
`, htmlEsc(match.Home), htmlEsc(match.Score), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Competition)) - + return html } diff --git a/internal/services/prefetch_service.go b/internal/services/prefetch_service.go index 443ab58..1c3598f 100644 --- a/internal/services/prefetch_service.go +++ b/internal/services/prefetch_service.go @@ -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) diff --git a/internal/services/sweepstakes.go b/internal/services/sweepstakes.go index 4dee8b2..ea94d26 100644 --- a/internal/services/sweepstakes.go +++ b/internal/services/sweepstakes.go @@ -13,7 +13,7 @@ import ( "fotbal-club/pkg/email" "gorm.io/gorm" - "gorm.io/gorm/clause" + "gorm.io/gorm/clause" ) // SweepstakesService encapsulates business logic for sweepstakes @@ -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) } @@ -151,9 +172,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri vis := cur.EndAt.Add(72 * time.Hour) if err := tx.Model(&models.Sweepstake{}).Where("id = ?", cur.ID).Updates(map[string]interface{}{ "winners_selected_at": now, - "visibility_until": vis, - "draw_seed": effSeed, - "status": "finalized", + "visibility_until": vis, + "draw_seed": effSeed, + "status": "finalized", }).Error; err != nil { return err } @@ -163,15 +184,21 @@ 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)}, Template: "sweepstake_winner_user", Data: map[string]interface{}{ - "Title": cur.Title, + "Title": cur.Title, "PrizeName": w.PrizeName, - "EndsAt": cur.EndAt.Format(time.RFC1123), + "EndsAt": endsAtCz, }, }) } @@ -179,14 +206,16 @@ 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", To: []string{adminTo}, Template: "sweepstake_winner_admin", Data: map[string]interface{}{ - "Title": cur.Title, + "Title": cur.Title, "WinnersCount": len(winners), }, }) @@ -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 +} diff --git a/pkg/email/service.go b/pkg/email/service.go index 1c9da95..92cdbd1 100644 --- a/pkg/email/service.go +++ b/pkg/email/service.go @@ -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) + } else { + clubLogo = "https://via.placeholder.com/400x400.png?text=Logo" } } - if clubLogo == "" { - clubLogo = "https://via.placeholder.com/400x400.png?text=Logo" - } primaryColor := strings.TrimSpace(set.PrimaryColor) if primaryColor == "" { primaryColor = "#1e3a8a" @@ -870,24 +870,26 @@ 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 != "" { - recipients = append(recipients, 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{}) dedup := make([]string, 0, len(recipients)) @@ -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("\"\"", 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 (best‑effort) + 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 {