-
+
{b.name}
- {b.website_url && (
- {b.website_url}
+ {(b as any).click_url && (
+ {(b as any).click_url}
)}
@@ -323,7 +332,7 @@ const BannersAdminPage: React.FC = () => {
Odkaz (po kliku)
- setEditing((prev) => ({ ...(prev as any), website_url: e.target.value }))} placeholder="https://partner.cz" />
+ setEditing((prev) => ({ ...(prev as any), click_url: e.target.value }))} placeholder="https://partner.cz" />
{/* Image resolution info */}
{imageResolution && (
@@ -430,7 +439,7 @@ const BannersAdminPage: React.FC = () => {
Obrázek banneru
{/* Preview */}
- {editing?.logo_url && (() => {
+ {(editing as any)?.image_url && (() => {
const preset = getPreset((editing as any)?.placement);
const previewWidth = preset ? Math.min(preset.width, 600) : 300;
const previewHeight = preset ? (previewWidth / preset.aspectRatio) : 150;
@@ -446,7 +455,7 @@ const BannersAdminPage: React.FC = () => {
bg={inputBg}
>
{
isLoading={uploadingImage}
loadingText="Nahrávání..."
>
- {editing?.logo_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
+ {(editing as any)?.image_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
{
{uploadingImage && }
- {!editing?.logo_url && (
+ {!((editing as any)?.image_url) && (
Nahrajte obrázek pro automatické doporučení umístění
diff --git a/frontend/src/pages/admin/ContactsAdminPage.tsx b/frontend/src/pages/admin/ContactsAdminPage.tsx
index 3d4c0a9..05bf197 100644
--- a/frontend/src/pages/admin/ContactsAdminPage.tsx
+++ b/frontend/src/pages/admin/ContactsAdminPage.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Button,
@@ -33,7 +33,6 @@ import {
Badge,
HStack,
VStack,
- useDisclosure,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
@@ -41,11 +40,10 @@ import {
AlertDialogContent,
AlertDialogOverlay,
SimpleGrid,
- Divider,
FormHelperText,
useColorModeValue,
} from '@chakra-ui/react';
-import { FiEdit, FiTrash2, FiPlus, FiUser } from 'react-icons/fi';
+import { FiEdit, FiTrash2, FiPlus, FiUser, FiUpload } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import {
getContacts,
@@ -56,13 +54,12 @@ import {
getContactCategories,
ContactCategory,
} from '../../services/contactInfo';
-import api, { uploadImage } from '../../services/api';
+import { uploadImage } from '../../services/api';
import { getImageUrl } from '../../utils/imageUtils';
import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings';
import MapLinkImporter from '../../components/admin/MapLinkImporter';
import { MapCoordinates } from '../../utils/mapUrlParser';
-import ContactMap from '../../components/home/ContactMap';
-import MapStyleSelector from '../../components/admin/MapStyleSelector';
+import { getFacrTablesCache } from '../../services/facr/cache';
const ContactsAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
@@ -100,6 +97,8 @@ const ContactsAdminPage: React.FC = () => {
const [uploadingImage, setUploadingImage] = useState(false);
const [settings, setSettings] = useState({});
const [savingSettings, setSavingSettings] = useState(false);
+ const [facrCompetitions, setFacrCompetitions] = useState([]);
+ const fileInputRef = React.useRef(null);
useEffect(() => {
loadData();
@@ -109,12 +108,14 @@ const ContactsAdminPage: React.FC = () => {
const loadData = async () => {
setLoading(true);
try {
- const [contactsData, categoriesData] = await Promise.all([
+ const [contactsData, categoriesData, facrData] = await Promise.all([
getContacts(),
getContactCategories(),
+ getFacrTablesCache(),
]);
setContacts(contactsData);
setCategories(categoriesData);
+ setFacrCompetitions(Array.isArray(facrData?.competitions) ? facrData!.competitions : []);
} catch (error) {
toast({
title: 'Chyba při načítání',
@@ -127,6 +128,31 @@ const ContactsAdminPage: React.FC = () => {
}
};
+ const clubCompetitionNames = useMemo(() => {
+ try {
+ const names = new Set();
+ for (const comp of facrCompetitions || []) {
+ const n = String(comp?.name || '').trim();
+ if (n) names.add(n);
+ }
+ return Array.from(names);
+ } catch {
+ return [] as string[];
+ }
+ }, [facrCompetitions]);
+
+ const filteredContactCategories = useMemo(() => {
+ try {
+ if (!Array.isArray(categories)) return [] as ContactCategory[];
+ if ((clubCompetitionNames || []).length === 0) return categories;
+ const setNames = new Set(clubCompetitionNames.map((s) => String(s)));
+ const filtered = categories.filter((c) => setNames.has(String(c.name)));
+ return filtered.length > 0 ? filtered : categories;
+ } catch {
+ return categories;
+ }
+ }, [categories, clubCompetitionNames]);
+
// Contact handlers
const openContactModal = (contact?: Contact) => {
if (contact) {
@@ -526,6 +552,9 @@ const ContactsAdminPage: React.FC = () => {
currentLongitude={settings.location_longitude}
currentZoom={settings.map_zoom_level}
mapStyle={settings.map_style || 'positron'}
+ onMapStyleChange={(value: string) => {
+ setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
+ }}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
clubName={settings.club_name}
@@ -598,43 +627,7 @@ const ContactsAdminPage: React.FC = () => {
-
- {
- setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
- }}
- clubPrimaryColor={settings.primary_color}
- clubSecondaryColor={settings.accent_color}
- showPreview={true}
- />
-
-
- {/* Live Map Preview with Current Coordinates */}
- {settings.location_latitude && settings.location_longitude && (
-
-
-
- Náhled vaší mapy
- Aktuální poloha
-
-
- Toto je náhled mapy s vaší aktuální polohou a vybraným stylem. Takto se zobrazí návštěvníkům na webu.
-
-
-
-
- )}
+ {/* Map style selection is integrated into the section above; single unified preview */}
@@ -700,13 +693,13 @@ const ContactsAdminPage: React.FC = () => {
}
>
Bez přiřazení
- {categories.map((cat) => (
+ {filteredContactCategories.map((cat) => (
{cat.name}
))}
- Přiřaďte kontakt ke konkrétní kategorii
+ Přiřaďte kontakt ke konkrétní kategorii (podle soutěží klubu)
@@ -730,10 +723,26 @@ const ContactsAdminPage: React.FC = () => {
Fotografie
+
+ }
+ variant="outline"
+ colorScheme="blue"
+ onClick={() => fileInputRef.current?.click()}
+ isLoading={uploadingImage}
+ >
+ Nahrát fotografii
+
+ {contactForm.image_url && (
+ Nahráno
+ )}
+
{contactForm.image_url && (
@@ -757,17 +766,6 @@ const ContactsAdminPage: React.FC = () => {
/>
-
- Pořadí zobrazení
-
- setContactForm({ ...contactForm, display_order: parseInt(e.target.value) || 0 })
- }
- />
-
-
Aktivní
{
const toast = useToast();
@@ -187,9 +188,8 @@ const FilesAdminPage: React.FC = () => {
};
const getImageUrl = (url: string) => {
- if (url.startsWith('http')) return url;
- const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
- return `${origin}${url}`;
+ const full = assetUrl(url);
+ return full || url;
};
// Mime type options
diff --git a/frontend/src/pages/admin/MessagesAdminPage.tsx b/frontend/src/pages/admin/MessagesAdminPage.tsx
index 208d337..6ea9cf3 100644
--- a/frontend/src/pages/admin/MessagesAdminPage.tsx
+++ b/frontend/src/pages/admin/MessagesAdminPage.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useEffect } from 'react';
import {
Box,
Button,
@@ -54,6 +54,7 @@ import {
import Pagination from '../../components/common/Pagination';
import MessageDetailModal from '../../components/admin/MessageDetailModal';
import ConfirmationDialog from '../../components/common/ConfirmationDialog';
+import { useAuth } from '../../contexts/AuthContext';
export default function MessagesAdminPage() {
const cardBg = useColorModeValue('white', 'gray.800');
@@ -88,6 +89,8 @@ export default function MessagesAdminPage() {
} = useDisclosure();
const [forwardAllEmail, setForwardAllEmail] = useState('');
+ const [saveForwardDefault, setSaveForwardDefault] = useState(true);
+ const { user } = useAuth();
const [selectedMessage, setSelectedMessage] = useState(null);
const toast = useToast();
@@ -148,7 +151,8 @@ export default function MessagesAdminPage() {
});
const forwardAllMutation = useMutation({
- mutationFn: forwardAllMessages,
+ mutationFn: (payload: { emails: string | string[]; saveDefault?: boolean }) =>
+ forwardAllMessages(payload.emails, { saveDefault: payload.saveDefault }),
onSuccess: (data) => {
toast({
title: 'Zprávy se přeposílají',
@@ -207,9 +211,20 @@ export default function MessagesAdminPage() {
});
return;
}
- forwardAllMutation.mutate(forwardAllEmail);
+ forwardAllMutation.mutate({ emails: forwardAllEmail, saveDefault: saveForwardDefault });
};
+ useEffect(() => {
+ if (isForwardAllOpen) {
+ // Prefill with current user's email if empty
+ if (!forwardAllEmail && user?.email) {
+ setForwardAllEmail(user.email);
+ }
+ // Default to saving as auto-forward unless user opts out
+ setSaveForwardDefault(true);
+ }
+ }, [isForwardAllOpen, user?.email]);
+
const handleSelectAll = (e: React.ChangeEvent) => {
if (e.target.checked) {
setSelectedMessages(data?.data.map((msg) => msg.id) || []);
@@ -482,11 +497,19 @@ export default function MessagesAdminPage() {
E-mailová adresa
setForwardAllEmail(e.target.value)}
/>
+
+ setSaveForwardDefault(e.target.checked)}
+ >
+ Uložit jako výchozí (automaticky přeposílat nové zprávy)
+
+
diff --git a/frontend/src/pages/admin/NavigationAdminPage.tsx b/frontend/src/pages/admin/NavigationAdminPage.tsx
index 0e1ea70..68cf088 100644
--- a/frontend/src/pages/admin/NavigationAdminPage.tsx
+++ b/frontend/src/pages/admin/NavigationAdminPage.tsx
@@ -45,6 +45,7 @@ import {
Flex,
Textarea,
Collapse,
+ Icon,
} from '@chakra-ui/react';
import AdminLayout from '../../layouts/AdminLayout';
import {
@@ -68,6 +69,26 @@ import {
FaLinkedin,
FaDiscord,
FaTwitch,
+ FaHome,
+ FaInfoCircle,
+ FaCalendarAlt,
+ FaFutbol,
+ FaUsers,
+ FaTable,
+ FaNewspaper,
+ FaVideo,
+ FaCamera,
+ FaSearch,
+ FaBars,
+ FaCog,
+ FaHandshake,
+ FaEnvelope,
+ FaUserShield,
+ FaFolder,
+ FaBook,
+ FaTshirt,
+ FaLink,
+ FaPoll,
} from 'react-icons/fa';
// Using simple up/down buttons instead of drag-drop for better compatibility
import {
@@ -137,6 +158,31 @@ const SOCIAL_PLATFORMS = [
{ value: 'twitch', label: 'Twitch', icon: FaTwitch },
];
+const NAV_ICON_OPTIONS = [
+ { value: 'FaHome', label: 'Domů', icon: FaHome },
+ { value: 'FaInfoCircle', label: 'O klubu', icon: FaInfoCircle },
+ { value: 'FaCalendarAlt', label: 'Kalendář', icon: FaCalendarAlt },
+ { value: 'FaFutbol', label: 'Hráči', icon: FaFutbol },
+ { value: 'FaUsers', label: 'Týmy', icon: FaUsers },
+ { value: 'FaTable', label: 'Tabulky', icon: FaTable },
+ { value: 'FaNewspaper', label: 'Články', icon: FaNewspaper },
+ { value: 'FaVideo', label: 'Videa', icon: FaVideo },
+ { value: 'FaCamera', label: 'Galerie', icon: FaCamera },
+ { value: 'FaHandshake', label: 'Sponzoři', icon: FaHandshake },
+ { value: 'FaEnvelope', label: 'Kontakt', icon: FaEnvelope },
+ { value: 'FaSearch', label: 'Hledat', icon: FaSearch },
+ { value: 'FaBars', label: 'Menu', icon: FaBars },
+ { value: 'FaLink', label: 'Odkaz', icon: FaLink },
+ { value: 'FaCog', label: 'Nastavení', icon: FaCog },
+ { value: 'FaPoll', label: 'Ankety', icon: FaPoll },
+ { value: 'FaUserShield', label: 'Uživatelé', icon: FaUserShield },
+ { value: 'FaFolder', label: 'Soubory', icon: FaFolder },
+ { value: 'FaBook', label: 'Stránka', icon: FaBook },
+ { value: 'FaTshirt', label: 'Oblečení', icon: FaTshirt },
+];
+
+const ICON_COMPONENTS: Record = Object.fromEntries(NAV_ICON_OPTIONS.map(opt => [opt.value, opt.icon]));
+
// NavItemCard component for hierarchical display
interface NavItemCardProps {
item: NavigationItem;
@@ -153,6 +199,8 @@ interface NavItemCardProps {
borderColor: string;
hoverBg: string;
level?: number;
+ onChildMoveUp?: (parentId: number, index: number) => void;
+ onChildMoveDown?: (parentId: number, index: number) => void;
}
const NavItemCard: React.FC = ({
@@ -170,6 +218,8 @@ const NavItemCard: React.FC = ({
borderColor,
hoverBg,
level = 0,
+ onChildMoveUp,
+ onChildMoveDown,
}) => {
const hasChildren = item.children && item.children.length > 0;
const indentPx = level * 32;
@@ -299,14 +349,14 @@ const NavItemCard: React.FC = ({
{/* Render children if expanded */}
{hasChildren && isExpanded && (
- {item.children!.map((child) => (
+ {item.children!.map((child, childIndex) => (
{}}
- onMoveDown={() => {}}
+ index={childIndex}
+ total={item.children!.length}
+ onMoveUp={() => onChildMoveUp && onChildMoveUp(item.id!, childIndex)}
+ onMoveDown={() => onChildMoveDown && onChildMoveDown(item.id!, childIndex)}
onEdit={() => onEdit()}
onDelete={() => onDelete()}
onAddChild={() => {}}
@@ -316,6 +366,8 @@ const NavItemCard: React.FC = ({
borderColor={borderColor}
hoverBg={hoverBg}
level={level + 1}
+ onChildMoveUp={onChildMoveUp}
+ onChildMoveDown={onChildMoveDown}
/>
))}
@@ -352,13 +404,9 @@ const NavigationAdminPage = () => {
getAllNavigationItems(),
getAllSocialLinks(),
]);
-
- console.log('Načtená navigace:', navData);
- console.log('Načtené sociální odkazy:', socialData);
-
+
// Auto-seed if navigation is empty
if (!navData || navData.length === 0) {
- console.log('Navigace je prázdná, automaticky vytváříme výchozí navigaci...');
try {
const seedResult = await seedDefaultNavigation();
if (seedResult.seeded) {
@@ -408,6 +456,43 @@ const NavigationAdminPage = () => {
}
};
+ const moveChildNavItem = async (parentId: number, index: number, direction: 'up' | 'down') => {
+ const moveWithin = async (
+ list: NavigationItem[],
+ setList: React.Dispatch>
+ ): Promise => {
+ const parentIdx = list.findIndex((it) => it.id === parentId);
+ if (parentIdx === -1) return false;
+ const parent = list[parentIdx];
+ const children = Array.isArray(parent.children) ? [...parent.children] : [];
+ if (children.length === 0) return true;
+ if (direction === 'up' && index === 0) return true;
+ if (direction === 'down' && index === children.length - 1) return true;
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
+ [children[index], children[targetIndex]] = [children[targetIndex], children[index]];
+
+ const updatedParent: NavigationItem = { ...parent, children };
+ const updated = [...list];
+ updated[parentIdx] = updatedParent;
+ setList(updated);
+
+ const orders = children.map((c, idx) => ({ id: c.id!, display_order: idx }));
+ try {
+ await reorderNavigationItems(orders);
+ toast({ title: 'Pořadí aktualizováno', status: 'success', duration: 2000 });
+ } catch (err) {
+ toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
+ loadData();
+ }
+ return true;
+ };
+
+ const doneFront = await moveWithin(navItems, setNavItems);
+ if (!doneFront) {
+ await moveWithin(adminNavItems, setAdminNavItems);
+ }
+ };
+
const moveNavItem = async (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === navItems.length - 1) return;
@@ -811,6 +896,8 @@ const NavigationAdminPage = () => {
cardBg={cardBg}
borderColor={borderColor}
hoverBg={hoverBg}
+ onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
+ onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
/>
))
)}
@@ -860,6 +947,8 @@ const NavigationAdminPage = () => {
cardBg={cardBg}
borderColor={borderColor}
hoverBg={hoverBg}
+ onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
+ onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
/>
))}
@@ -1026,6 +1115,25 @@ const NavigationAdminPage = () => {
)}
+
+ Ikona
+ setEditingNav({ ...editingNav!, icon: e.target.value || undefined })}
+ >
+ Bez ikony
+ {NAV_ICON_OPTIONS.map(opt => (
+ {opt.label}
+ ))}
+
+ {editingNav?.icon && (
+
+
+ {editingNav.icon}
+
+ )}
+
+
{editingNav?.parent_id && (
@@ -1043,14 +1151,7 @@ const NavigationAdminPage = () => {
/>
-
- CSS třída (volitelné)
- setEditingNav({ ...editingNav!, icon: e.target.value })}
- placeholder="custom-class"
- />
-
+
{editingNav?.type === 'external' && (
diff --git a/frontend/src/pages/admin/NewsletterAdminPage.tsx b/frontend/src/pages/admin/NewsletterAdminPage.tsx
index c101612..6da73ed 100644
--- a/frontend/src/pages/admin/NewsletterAdminPage.tsx
+++ b/frontend/src/pages/admin/NewsletterAdminPage.tsx
@@ -50,6 +50,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { cs } from 'date-fns/locale';
import AdminLayout from '../../layouts/AdminLayout';
+import { usePublicSettings } from '../../hooks/usePublicSettings';
+import { facrApi } from '../../services/facr/facrApi';
import {
getNewsletterSubscribers,
sendNewsletter,
@@ -143,6 +145,29 @@ export default function NewsletterAdminPage() {
const [sendNowLoading, setSendNowLoading] = useState(false);
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
+
+ // Helpers for competitions multi-select handling
+ const selectedCompCodes = React.useMemo(() => {
+ return new Set((competitions || '').split(',').map((s) => s.trim()).filter(Boolean));
+ }, [competitions]);
+ const toggleComp = (code: string, on: boolean) => {
+ const next = new Set(selectedCompCodes);
+ if (on) next.add(code); else next.delete(code);
+ setCompetitions(Array.from(next).join(', '));
+ };
+ const clearComps = () => setCompetitions('');
+ const selectAllComps = () => setCompetitions(compOptions.map(o => o.code).join(', '));
+
+ const detailsSelectedCompCodes = React.useMemo(() => {
+ return new Set((detailsCompetitions || '').split(',').map((s) => s.trim()).filter(Boolean));
+ }, [detailsCompetitions]);
+ const toggleDetailsComp = (code: string, on: boolean) => {
+ const next = new Set(detailsSelectedCompCodes);
+ if (on) next.add(code); else next.delete(code);
+ setDetailsCompetitions(Array.from(next).join(', '));
+ };
+ const detailsClearComps = () => setDetailsCompetitions('');
+ const detailsSelectAllComps = () => setDetailsCompetitions(compOptions.map(o => o.code).join(', '));
const recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
return subscribers
@@ -212,6 +237,24 @@ export default function NewsletterAdminPage() {
const queryClient = useQueryClient();
const isMobile = useBreakpointValue({ base: true, md: false });
+ // Load club competitions for nicer dropdowns (FACR)
+ const { data: publicSettings } = usePublicSettings();
+ const clubId = publicSettings?.club_id;
+ const clubType = (publicSettings?.club_type as 'football' | 'futsal') || 'football';
+ const { data: clubCompetitions = [] } = useQuery({
+ queryKey: ['facr', 'competitions', clubId, clubType],
+ queryFn: async () => {
+ if (!clubId) return [] as Array<{ code?: string; id?: string; name?: string }>;
+ const comps = await facrApi.getClubCompetitions(clubId, clubType);
+ return comps || [];
+ },
+ enabled: !!clubId,
+ });
+ const compOptions = (clubCompetitions as any[]).map((c) => ({
+ code: String(c?.code || c?.id || ''),
+ name: String(c?.name || c?.code || c?.id || ''),
+ })).filter((o) => o.code);
+
// Admin settings (for scheduling)
const settingsQuery = useQuery({
queryKey: ['admin', 'settings'],
@@ -222,10 +265,11 @@ export default function NewsletterAdminPage() {
const [enableMatchReminders, setEnableMatchReminders] = useState(!!settings?.enable_match_reminders);
const [enableResults, setEnableResults] = useState(!!settings?.enable_results);
const [weeklyDay, setWeeklyDay] = useState(settings?.newsletter_weekly_day || 'sun');
- const [weeklyHour, setWeeklyHour] = useState(typeof settings?.newsletter_weekly_hour === 'number' ? (settings!.newsletter_weekly_hour as number) : 18);
+ const toTimeString = (h?: number) => (String(typeof h === 'number' ? Math.max(0, Math.min(23, h)) : 18).padStart(2, '0')) + ':00';
+ const [weeklyTime, setWeeklyTime] = useState(toTimeString(settings?.newsletter_weekly_hour as number | undefined));
const [reminderLead, setReminderLead] = useState(typeof settings?.newsletter_reminder_lead_hours === 'number' ? (settings!.newsletter_reminder_lead_hours as number) : 48);
- const [quietStart, setQuietStart] = useState(typeof settings?.newsletter_quiet_start === 'number' ? (settings!.newsletter_quiet_start as number) : 22);
- const [quietEnd, setQuietEnd] = useState(typeof settings?.newsletter_quiet_end === 'number' ? (settings!.newsletter_quiet_end as number) : 7);
+ const [quietStartTime, setQuietStartTime] = useState(toTimeString(settings?.newsletter_quiet_start as number | undefined));
+ const [quietEndTime, setQuietEndTime] = useState(toTimeString(settings?.newsletter_quiet_end as number | undefined));
// Sync local state when settings load
useEffect(() => {
@@ -234,22 +278,27 @@ export default function NewsletterAdminPage() {
setEnableMatchReminders(!!settings.enable_match_reminders);
setEnableResults(!!settings.enable_results);
setWeeklyDay(settings.newsletter_weekly_day || 'sun');
- setWeeklyHour(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18);
+ setWeeklyTime(toTimeString(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18));
setReminderLead(typeof settings.newsletter_reminder_lead_hours === 'number' ? settings.newsletter_reminder_lead_hours! : 48);
- setQuietStart(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22);
- setQuietEnd(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7);
+ setQuietStartTime(toTimeString(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22));
+ setQuietEndTime(toTimeString(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7));
}, [settings]);
+ const parseHour = (t: string) => {
+ const m = /^\s*(\d{1,2})(?::(\d{1,2}))?/.exec(t || '');
+ const h = m ? parseInt(m[1], 10) : 18;
+ return Math.max(0, Math.min(23, isNaN(h) ? 18 : h));
+ };
const saveScheduleMutation = useMutation({
mutationFn: () => updateAdminSettings({
enable_weekly: enableWeekly,
enable_match_reminders: enableMatchReminders,
enable_results: enableResults,
newsletter_weekly_day: weeklyDay,
- newsletter_weekly_hour: weeklyHour,
+ newsletter_weekly_hour: parseHour(weeklyTime),
newsletter_reminder_lead_hours: reminderLead,
- newsletter_quiet_start: quietStart,
- newsletter_quiet_end: quietEnd,
+ newsletter_quiet_start: parseHour(quietStartTime),
+ newsletter_quiet_end: parseHour(quietEndTime),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
@@ -621,9 +670,9 @@ export default function NewsletterAdminPage() {
Neděle
-
- Hodina
- setWeeklyHour(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
+
+ Čas odeslání
+ setWeeklyTime(e.target.value)} />
@@ -648,13 +697,13 @@ export default function NewsletterAdminPage() {
setEnableResults(e.target.checked)} />
-
+
Tiché hodiny od
- setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
+ setQuietStartTime(e.target.value)} />
-
+
Tiché hodiny do
- setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
+ setQuietEndTime(e.target.value)} />
E-maily s výsledky se neposílají v tomto intervalu.
@@ -918,8 +967,28 @@ export default function NewsletterAdminPage() {
<>
Filtr soutěží (volitelné)
- setCompetitions(e.target.value)} />
- Čárkou oddělený seznam kódů soutěží.
+ {compOptions.length > 0 ? (
+
+
+ Vybrat vše
+ Zrušit vše
+
+ {compOptions.map((o) => {
+ const checked = selectedCompCodes.has(o.code.toLowerCase()) || selectedCompCodes.has(o.code);
+ return (
+
+ {o.name}
+ toggleComp(o.code, e.target.checked)} />
+
+ );
+ })}
+
+ ) : (
+ <>
+ setCompetitions(e.target.value)} />
+ Čárkou oddělený seznam kódů soutěží.
+ >
+ )}
{
@@ -1009,9 +1078,27 @@ export default function NewsletterAdminPage() {
-
+
Filtr soutěží (volitelné)
- setDetailsCompetitions(e.target.value)} />
+ {compOptions.length > 0 ? (
+
+
+ Vybrat vše
+ Zrušit vše
+
+ {compOptions.map((o) => {
+ const checked = detailsSelectedCompCodes.has(o.code.toLowerCase()) || detailsSelectedCompCodes.has(o.code);
+ return (
+
+ {o.name}
+ toggleDetailsComp(o.code, e.target.checked)} />
+
+ );
+ })}
+
+ ) : (
+ setDetailsCompetitions(e.target.value)} />
+ )}
{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled
{activeType && typePreview[activeType]?.subject && (
diff --git a/frontend/src/pages/admin/PlayersAdminPage.tsx b/frontend/src/pages/admin/PlayersAdminPage.tsx
index 114dc13..5bbade0 100644
--- a/frontend/src/pages/admin/PlayersAdminPage.tsx
+++ b/frontend/src/pages/admin/PlayersAdminPage.tsx
@@ -42,7 +42,7 @@ import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '..
import { uploadFile } from '../../services/articles';
import { translateNationality } from '../../utils/nationality';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
-import { API_URL } from '../../services/api';
+import { assetUrl } from '../../utils/url';
type Editing = Partial & { id?: number };
@@ -51,16 +51,7 @@ const PlayersAdminPage: React.FC = () => {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast();
- const normalizeImageUrl = (url?: string) => {
- if (!url || url === '') return '/logo192.png';
- // If it's already absolute, return as-is
- if (/^https?:\/\//i.test(url)) return url;
- // If it's an uploads path, prefix with API origin
- const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
- if (url.startsWith('/uploads/')) return `${origin}${url}`;
- // Fallback: treat as relative to origin
- return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
- };
+
// Hoisted helper: convert country code to flag emoji
function countryCodeToEmoji(cc: string) {
@@ -231,7 +222,6 @@ const PlayersAdminPage: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const JERSEY_MIN = 0;
- const JERSEY_MAX = 99;
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
@@ -315,14 +305,12 @@ const PlayersAdminPage: React.FC = () => {
return;
}
const tooBig = (
- typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
- ) || (
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
) || (
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
);
if (tooBig) {
- toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
+ toast({ title: 'Neplatná čísla', description: `Maxima: výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Require date of birth: all three values must be selected
@@ -337,7 +325,7 @@ const PlayersAdminPage: React.FC = () => {
};
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
if (editing.position) payload.position = editing.position;
- if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) {
+ if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number >= 0) {
payload.jersey_number = editing.jersey_number;
}
if (editing.nationality) payload.nationality = editing.nationality;
@@ -391,7 +379,7 @@ const PlayersAdminPage: React.FC = () => {
{
- {formatDobPreview(dobParts)}
+ {formatDobPreview(dobParts)}{calculateAgeFromParts(dobParts) != null ? ` — ${calculateAgeFromParts(dobParts)} let` : ''}
@@ -466,12 +454,11 @@ const PlayersAdminPage: React.FC = () => {
- JERSEY_MAX}>
+
Číslo dresu
- setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}>
+ setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) && v >= 0 ? v : undefined }))}>
- Maximální číslo dresu je {JERSEY_MAX}.
@@ -547,7 +534,7 @@ const PlayersAdminPage: React.FC = () => {
Fotka
-
+
}>Nahrát
{
return `${dd}.${mm}.${yyyy}`;
}
+ function calculateAgeFromParts(parts: { day: string; month: string; year: string }): number | null {
+ if (!parts.day || !parts.month || !parts.year) return null;
+ const y = Number(parts.year);
+ const m = Number(parts.month);
+ const d = Number(parts.day);
+ if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
+ const today = new Date();
+ let age = today.getFullYear() - y;
+ const month = today.getMonth() + 1;
+ const day = today.getDate();
+ if (month < m || (month === m && day < d)) age--;
+ return age;
+ }
+
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
function updateDobPart(part: 'day'|'month'|'year', value: string) {
setDobParts((prev) => {
diff --git a/frontend/src/pages/admin/TeamsAdminPage.tsx b/frontend/src/pages/admin/TeamsAdminPage.tsx
index 0cb91a2..824f0fb 100644
--- a/frontend/src/pages/admin/TeamsAdminPage.tsx
+++ b/frontend/src/pages/admin/TeamsAdminPage.tsx
@@ -54,7 +54,53 @@ import { assetUrl } from '../../utils/url';
import { useEffect, useMemo, useRef, useState } from 'react';
import { API_URL } from '../../services/api';
+function normalize(s: string): string {
+ let out = String(s || '');
+ // Normalize diacritics and case
+ out = out
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase();
+ // Unify various dash characters to a simple hyphen
+ out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
+ // Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
+ out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
+ // Remove organization phrases/prefixes anywhere (keep core locality/name)
+ const orgPhrases = [
+ 'fotbalovy klub',
+ 'sportovni klub',
+ 'telovychovna jednota',
+ 'skolni sportovni klub',
+ 'fotbal',
+ 'futsal',
+ ];
+ for (const phrase of orgPhrases) {
+ const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
+ out = out.replace(re, ' ');
+ }
+ // Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
+ out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
+ // Remove punctuation except hyphen
+ out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
+ // Collapse multiple spaces and trim
+ out = out.replace(/\s+/g, ' ').trim();
+ return out;
+}
+// Derive FACR team UUID from the logo URL if team_id is missing in the row
+// Example: https://is1.fotbal.cz/media/kluby//_crop.jpg
+function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
+ try {
+ const u = String(url || '');
+ if (!u) return undefined;
+ const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
+ return m ? m[0].toLowerCase() : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+
type TableRow = {
rank?: string;
team?: string;
@@ -82,6 +128,9 @@ const TeamsAdminPage = () => {
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
// Backend origin (used to resolve relative URLs like /uploads/...)
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
+ const theadBg = useColorModeValue('gray.50', 'gray.700');
+ const rowHoverBg = useColorModeValue('gray.50', 'gray.700');
+ const searchBg = useColorModeValue('white', 'gray.800');
// Load public/admin overrides map to apply on cache-fed view
const { data: overrides = {} } = useQuery({
@@ -120,38 +169,6 @@ const TeamsAdminPage = () => {
.catch((err) => console.error('Failed to fetch sport logos:', err))
.finally(() => setSportLogosLoading(false));
}, [competitions]);
- const normalize = (s: string) => {
- let out = String(s || '');
- // Normalize diacritics and case
- out = out
- .normalize('NFD')
- .replace(/[\u0300-\u036f]/g, '')
- .toLowerCase();
- // Unify various dash characters to a simple hyphen
- out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
- // Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
- out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
- // Remove organization phrases/prefixes anywhere (keep core locality/name)
- const orgPhrases = [
- 'fotbalovy klub',
- 'sportovni klub',
- 'telovychovna jednota',
- 'skolni sportovni klub',
- 'fotbal',
- 'futsal',
- ];
- for (const phrase of orgPhrases) {
- const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
- out = out.replace(re, ' ');
- }
- // Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
- out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
- // Remove punctuation except hyphen
- out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
- // Collapse multiple spaces and trim
- out = out.replace(/\s+/g, ' ').trim();
- return out;
- };
const byName: Record = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record = {};
@@ -161,18 +178,6 @@ const TeamsAdminPage = () => {
return idx;
}, [byName]);
- // Derive FACR team UUID from the logo URL if team_id is missing in the row
- // Example: https://is1.fotbal.cz/media/kluby//_crop.jpg
- const deriveTeamIdFromLogoUrl = (url?: string): string | undefined => {
- try {
- const u = String(url || '');
- if (!u) return undefined;
- const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
- return m ? m[0].toLowerCase() : undefined;
- } catch {
- return undefined;
- }
- };
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 0: Admin override by team ID
@@ -561,7 +566,7 @@ const TeamsAdminPage = () => {
}}
>
-
+
#
Tým
@@ -576,7 +581,7 @@ const TeamsAdminPage = () => {
{rowsFiltered.map((r, idx) => (
-
+
{r.rank}
@@ -687,7 +692,7 @@ const TeamsAdminPage = () => {
{searchResults.length > 0 && (
{
await api.post(`/admin/contact-messages/${id}/forward`, { to_email: toEmail });
};
-export const forwardAllMessages = async (toEmail: string) => {
- const response = await api.post('/admin/contact-messages/forward-all', { to_email: toEmail });
+export const forwardAllMessages = async (
+ emails: string | string[],
+ options?: { saveDefault?: boolean }
+) => {
+ let payload: any = {};
+ if (Array.isArray(emails)) {
+ const arr = emails.map((e) => String(e || '').trim()).filter(Boolean);
+ payload = arr.length > 1 ? { to_emails: arr } : { to_email: arr[0] || '' };
+ } else {
+ const parts = String(emails || '')
+ .split(/[;\,\s]+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ payload = parts.length > 1 ? { to_emails: parts } : { to_email: parts[0] || '' };
+ }
+ if (options?.saveDefault) {
+ payload.save_default = true;
+ }
+ const response = await api.post('/admin/contact-messages/forward-all', payload);
return response.data;
};
diff --git a/frontend/src/services/banners.ts b/frontend/src/services/banners.ts
new file mode 100644
index 0000000..81e5118
--- /dev/null
+++ b/frontend/src/services/banners.ts
@@ -0,0 +1,38 @@
+import api from './api';
+
+export interface Banner {
+ id: number | string;
+ name: string;
+ image_url?: string;
+ click_url?: string;
+ placement?: string;
+ width?: number;
+ height?: number;
+ is_active?: boolean;
+ display_order?: number;
+}
+
+export async function getBanners(params?: { active?: boolean; placement?: string }): Promise {
+ const res = await api.get('/banners', { params });
+ const body = res.data;
+ const list = Array.isArray(body) ? body : (Array.isArray(body?.data) ? body.data : []);
+ return (list || []).map((b: any) => ({
+ ...b,
+ id: b.id ?? b.ID ?? b.Id ?? b.iD,
+ }));
+}
+
+export async function createBanner(payload: { name: string; image_url?: string; click_url?: string; placement?: string; width?: number; height?: number; is_active?: boolean; display_order?: number }) {
+ const res = await api.post('/banners', payload);
+ return res.data;
+}
+
+export async function updateBanner(id: number | string, payload: Partial<{ name: string; image_url?: string; click_url?: string; placement?: string; width?: number; height?: number; is_active?: boolean; display_order?: number }>) {
+ const res = await api.put(`/banners/${id}`, payload);
+ return res.data;
+}
+
+export async function deleteBanner(id: number | string) {
+ const res = await api.delete<{ zprava: string }>(`/banners/${id}`);
+ return res.data;
+}
diff --git a/frontend/src/services/imageProcessing.ts b/frontend/src/services/imageProcessing.ts
index 9f42646..d034e3f 100644
--- a/frontend/src/services/imageProcessing.ts
+++ b/frontend/src/services/imageProcessing.ts
@@ -1,4 +1,5 @@
import api from './api';
+import { assetUrl } from '../utils/url';
export interface ImageProcessRequest {
image_url: string;
@@ -44,7 +45,11 @@ export interface ImageProcessResponse {
*/
export const processImage = async (request: ImageProcessRequest): Promise => {
const response = await api.post('/image-processing/process', request);
- return response.data;
+ const data = response.data || {};
+ if (data && typeof data.url === 'string') {
+ data.url = assetUrl(data.url) || data.url;
+ }
+ return data;
};
/**
@@ -52,7 +57,11 @@ export const processImage = async (request: ImageProcessRequest): Promise => {
const response = await api.post('/image-processing/quick-edit', request);
- return response.data;
+ const data = response.data || {};
+ if (data && typeof data.url === 'string') {
+ data.url = assetUrl(data.url) || data.url;
+ }
+ return data;
};
/**
@@ -79,8 +88,11 @@ export const cropAndUpload = async (
'Content-Type': 'multipart/form-data',
},
});
-
- return response.data;
+ const data = response.data || {};
+ if (data && typeof data.url === 'string') {
+ data.url = assetUrl(data.url) || data.url;
+ }
+ return data;
};
/**
diff --git a/frontend/src/services/navigation.ts b/frontend/src/services/navigation.ts
index 8d6b4b5..a7e3855 100644
--- a/frontend/src/services/navigation.ts
+++ b/frontend/src/services/navigation.ts
@@ -1,5 +1,4 @@
-import axios from 'axios';
-import { API_URL as API_BASE_URL } from './api';
+import api, { API_URL as API_BASE_URL } from './api';
export interface NavigationItem {
id?: number;
@@ -30,86 +29,64 @@ export interface SocialLink {
// Public endpoints
export const getNavigationItems = async (): Promise => {
- const response = await axios.get(`${API_BASE_URL}/navigation`);
+ const response = await api.get(`/navigation`);
return response.data;
};
export const getSocialLinks = async (): Promise => {
- const response = await axios.get(`${API_BASE_URL}/social-links`);
+ const response = await api.get(`/social-links`);
return response.data;
};
// Admin endpoints
export const getAllNavigationItems = async (): Promise => {
- const response = await axios.get(`${API_BASE_URL}/admin/navigation`, {
- withCredentials: true,
- });
+ const response = await api.get(`/admin/navigation`);
return response.data;
};
export const createNavigationItem = async (item: Partial): Promise => {
- const response = await axios.post(`${API_BASE_URL}/admin/navigation`, item, {
- withCredentials: true,
- });
+ const response = await api.post(`/admin/navigation`, item);
return response.data;
};
export const updateNavigationItem = async (id: number, item: Partial): Promise => {
- const response = await axios.put(`${API_BASE_URL}/admin/navigation/${id}`, item, {
- withCredentials: true,
- });
+ const response = await api.put(`/admin/navigation/${id}`, item);
return response.data;
};
export const deleteNavigationItem = async (id: number): Promise => {
- await axios.delete(`${API_BASE_URL}/admin/navigation/${id}`, {
- withCredentials: true,
- });
+ await api.delete(`/admin/navigation/${id}`);
};
export const reorderNavigationItems = async (orders: { id: number; display_order: number }[]): Promise => {
- await axios.post(`${API_BASE_URL}/admin/navigation/reorder`, orders, {
- withCredentials: true,
- });
+ await api.post(`/admin/navigation/reorder`, orders);
};
// Social links admin endpoints
export const getAllSocialLinks = async (): Promise => {
- const response = await axios.get(`${API_BASE_URL}/admin/social-links`, {
- withCredentials: true,
- });
+ const response = await api.get(`/admin/social-links`);
return response.data;
};
export const createSocialLink = async (link: Partial): Promise => {
- const response = await axios.post(`${API_BASE_URL}/admin/social-links`, link, {
- withCredentials: true,
- });
+ const response = await api.post(`/admin/social-links`, link);
return response.data;
};
export const updateSocialLink = async (id: number, link: Partial): Promise => {
- const response = await axios.put(`${API_BASE_URL}/admin/social-links/${id}`, link, {
- withCredentials: true,
- });
+ const response = await api.put(`/admin/social-links/${id}`, link);
return response.data;
};
export const deleteSocialLink = async (id: number): Promise => {
- await axios.delete(`${API_BASE_URL}/admin/social-links/${id}`, {
- withCredentials: true,
- });
+ await api.delete(`/admin/social-links/${id}`);
};
export const reorderSocialLinks = async (orders: { id: number; display_order: number }[]): Promise => {
- await axios.post(`${API_BASE_URL}/admin/social-links/reorder`, orders, {
- withCredentials: true,
- });
+ await api.post(`/admin/social-links/reorder`, orders);
};
export const seedDefaultNavigation = async (): Promise<{ message: string; count: number; seeded: boolean }> => {
- const response = await axios.post(`${API_BASE_URL}/admin/navigation/seed`, {}, {
- withCredentials: true,
- });
+ const response = await api.post(`/admin/navigation/seed`, {});
return response.data;
};
diff --git a/frontend/src/services/pageElements.ts b/frontend/src/services/pageElements.ts
index 4d3daa2..0b28889 100644
--- a/frontend/src/services/pageElements.ts
+++ b/frontend/src/services/pageElements.ts
@@ -116,14 +116,14 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Layout - Rozvržení
{ name: 'style-pack', label: 'Styl balíček', description: 'Globální vizuální balíček pro celou stránku', icon: FaCube, category: 'layout', defaultVariant: 'default' },
{ name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' },
- { name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'brand' },
+ { name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'minimal' },
{ name: 'hero', label: 'Hlavní Sekce', description: 'Hlavní obsahová oblast s úvodním obsahem', icon: FaBullseye, category: 'layout', defaultVariant: 'grid' },
{ name: 'footer', label: 'Patička', description: 'Spodní část stránky s odkazy a kontakty', icon: FaMapSigns, category: 'layout', defaultVariant: 'standard' },
{ name: 'sidebar', label: 'Boční Panel', description: 'Boční sloupec s doplňkovým obsahem', icon: FaColumns, category: 'layout', defaultVariant: 'right' },
{ name: 'banner', label: 'Banner', description: 'Reklamní nebo informační banner', icon: FaFlag, category: 'layout', defaultVariant: 'top' },
// Content - Obsah
- { name: 'news', label: 'Novinky', description: 'Nejnovější články a zprávy', icon: FaNewspaper, category: 'content', defaultVariant: 'grid' },
+ { name: 'news', label: 'Novinky', description: 'Nejnovější články a zprávy', icon: FaNewspaper, category: 'content', defaultVariant: 'grid_one' },
{ name: 'matches', label: 'Zápasy', description: 'Nadcházející a poslední zápasy', icon: FaFutbol, category: 'content', defaultVariant: 'compact' },
{ name: 'matches-slider', label: 'Zápasy (slider)', description: 'Přehled zápasů podle soutěže ve slideru', icon: FaFutbol, category: 'content', defaultVariant: 'carousel' },
{ name: 'team', label: 'Tým', description: 'Hráči a realizační tým', icon: FaUsers, category: 'content', defaultVariant: 'grid' },
@@ -190,6 +190,8 @@ export const ELEMENT_VARIANTS: Record = {
{ value: 'sparta_featured_carousel', label: 'Sparta Featured Carousel', description: 'Hero header s pozadím, článek s kategoriemi, thumbnail navigace, auto-swap' },
],
news: [
+ { value: 'grid_one', label: 'Mřížka (1 sloupec)', description: 'Jednosloupcová mřížka bez tabulek vpravo (skryje sekci Tabulky)' },
+ { value: 'grid_two', label: 'Mřížka (2 sloupce)', description: 'Aktuality vlevo a Tabulky vpravo (pokud jsou k dispozici)' },
{ value: 'grid', label: 'Mřížka', description: 'Rozložení karet v mřížce' },
{ value: 'scroller', label: 'Posuvník', description: 'Horizontální posuvník' },
{ value: 'hero_carousel', label: 'Hero Karusel', description: 'Jeden článek najednou. Tlačítko: ZJISTIT VÍCE (vlevo dole). Numerace: 01 02 03 (vpravo dole). Auto-swap' },
diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts
index 0c98bf2..e12d206 100644
--- a/frontend/src/services/settings.ts
+++ b/frontend/src/services/settings.ts
@@ -116,6 +116,8 @@ export type AdminSettings = PublicSettings & {
location_longitude?: number;
map_zoom_level?: number;
show_map_on_homepage?: boolean;
+ frontend_base_url?: string;
+ api_base_url?: string;
// Homepage matches display configuration
finished_match_display_days?: number; // Number of days to show finished matches with scores on homepage
};
diff --git a/frontend/src/services/setup.ts b/frontend/src/services/setup.ts
index 9b6ffba..7843471 100644
--- a/frontend/src/services/setup.ts
+++ b/frontend/src/services/setup.ts
@@ -15,6 +15,9 @@ export type SetupInitializePayload = {
club_name?: string;
club_logo_url?: string;
club_url?: string;
+ // deployment bases
+ frontend_base_url?: string;
+ api_base_url?: string;
frontpage_style?: 'unified' | 'magazine' | 'pro' | 'edge';
primary_color?: string;
secondary_color?: string;
diff --git a/frontend/src/styles/home-style-pack.css b/frontend/src/styles/home-style-pack.css
index dfea54e..dcdc2c5 100644
--- a/frontend/src/styles/home-style-pack.css
+++ b/frontend/src/styles/home-style-pack.css
@@ -258,4 +258,4 @@ body.style-pack-modern [data-element="team"] .player-card {
[data-element="poll"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
/* Newsletter */
-[data-element="newsletter"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); background: var(--card-bg, #fff); }
+[data-element="newsletter"] .card { border-radius: var(--pack-radius, 12px); }
diff --git a/frontend/src/utils/imageUtils.ts b/frontend/src/utils/imageUtils.ts
index 4e59935..96b990b 100644
--- a/frontend/src/utils/imageUtils.ts
+++ b/frontend/src/utils/imageUtils.ts
@@ -1,18 +1,10 @@
-import { API_URL } from '../services/api';
+import { assetUrl } from './url';
/**
* Get the full URL for an image, handling both absolute and relative URLs
*/
export const getImageUrl = (imageUrl: string | undefined | null): string | undefined => {
if (!imageUrl) return undefined;
-
- // If already an absolute URL, return as-is
- if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
- return imageUrl;
- }
-
- // Otherwise, prepend the backend base URL
- const backendUrl = API_URL.replace('/api/v1', '');
- const path = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
- return `${backendUrl}${path}`;
+ const url = assetUrl(imageUrl);
+ return url || imageUrl || undefined;
};
diff --git a/frontend/src/utils/url.ts b/frontend/src/utils/url.ts
index 5a31ac1..8399263 100644
--- a/frontend/src/utils/url.ts
+++ b/frontend/src/utils/url.ts
@@ -1,31 +1,69 @@
-// Utility to resolve asset URLs. If the input starts with /uploads, resolve it against the API base URL
-// so that in development the images are loaded from the backend (e.g., http://localhost:8080/uploads/...)
-// and in production it resolves relative to the deployed backend origin.
+import { API_URL } from '../services/api';
+
+// Prefer explicit asset base when provided (e.g., http://127.0.0.1:8080)
+const ASSET_BASE = process.env.REACT_APP_ASSET_BASE_URL;
+const API_BASE = process.env.REACT_APP_API_BASE_URL;
+
+// Compute backend origin from API_URL (works with absolute or relative '/api/v1').
+export function getBackendOrigin(): string {
+ try {
+ if (typeof window !== 'undefined') {
+ const host = window.location.hostname;
+ const port = window.location.port;
+ if (/^(localhost|127\.0\.0\.1)$/i.test(host) && port === '3000') {
+ return `${window.location.protocol}//${host}:8080`;
+ }
+ }
+ // 1) If REACT_APP_ASSET_BASE_URL is set, use its origin
+ if (ASSET_BASE && ASSET_BASE.trim() !== '') {
+ const u = new URL(ASSET_BASE, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
+ const winHost = typeof window !== 'undefined' ? window.location.hostname : '';
+ if ((/^(localhost|127\.0\.0\.1)$/i.test(u.hostname)) && winHost && !/^(localhost|127\.0\.0\.1)$/i.test(winHost)) {
+ return typeof window !== 'undefined' ? window.location.origin : u.origin;
+ }
+ return u.origin;
+ }
+ // 2) Derive from API base or API_URL (works with absolute or relative '/api/v1')
+ const src = (API_BASE && API_BASE.trim() !== '') ? API_BASE : API_URL;
+ const base = new URL(src, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
+ if ((/^(localhost|127\.0\.0\.1)$/i.test(base.hostname)) && typeof window !== 'undefined' && window.location.port === '3000') {
+ return `${base.protocol}//${base.hostname}:8080`;
+ }
+ return base.origin;
+ } catch {
+ return typeof window !== 'undefined' ? window.location.origin : '';
+ }
+}
+
+// Utility to resolve asset URLs robustly.
+// - For '/uploads' and '/dist' paths, always prefix with backend origin
+// - For absolute URLs pointing to localhost/127.0.0.1, rewrite origin to backend origin if the path is '/uploads' or '/dist'
+// - For data: URIs, return as-is
export function assetUrl(pathOrUrl?: string | null): string | undefined {
if (!pathOrUrl) return undefined;
try {
- // If already absolute (http/https/data), return as-is
- if (/^(?:https?:)?\/\//i.test(pathOrUrl) || /^data:/i.test(pathOrUrl)) {
- return pathOrUrl;
- }
- // Known backend-served asset paths (/uploads, optionally /dist)
- if (pathOrUrl.startsWith('/uploads') || pathOrUrl.startsWith('/dist')) {
- const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || '';
- if (explicit && !explicit.startsWith('/')) {
- const baseUrl = new URL(explicit, typeof window !== 'undefined' ? window.location.origin : undefined);
- baseUrl.pathname = '/';
- return new URL(pathOrUrl, baseUrl).toString();
+ const val = String(pathOrUrl);
+ const backendOrigin = getBackendOrigin();
+ // data URI
+ if (/^data:/i.test(val)) return val;
+
+ // Absolute URL
+ if (/^(?:https?:)?\/\//i.test(val)) {
+ const u = new URL(val, window.location.origin);
+ const isLocalHost = /^(localhost|127\.0\.0\.1)(:\d+)?$/i.test(u.host);
+ const isBackendAsset = u.pathname.startsWith('/uploads') || u.pathname.startsWith('/dist');
+ if (isLocalHost && isBackendAsset) {
+ return new URL(u.pathname + u.search + u.hash, backendOrigin + '/').toString();
}
- if (process.env.NODE_ENV !== 'production') {
- try {
- const devOrigin = 'http://127.0.0.1:8080';
- return new URL(pathOrUrl, devOrigin).toString();
- } catch {}
- }
- return pathOrUrl;
+ return u.toString();
}
- // Otherwise return as-is (relative or other paths)
- return pathOrUrl;
+
+ // Relative URL
+ if (val.startsWith('/uploads') || val.startsWith('/dist')) {
+ return new URL(val, backendOrigin + '/').toString();
+ }
+ // Otherwise leave as-is (component may resolve relative to current origin)
+ return val;
} catch {
return pathOrUrl || undefined;
}
diff --git a/internal/controllers/base_controller.go b/internal/controllers/base_controller.go
index 8a6bd9a..d5c0199 100644
--- a/internal/controllers/base_controller.go
+++ b/internal/controllers/base_controller.go
@@ -2266,8 +2266,6 @@ func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
c.JSON(http.StatusOK, item)
}
-// ProxyImage streams a remote image to the client to avoid browser CORS restrictions for Canvas operations
-// GET /api/v1/proxy/image?url=
func (bc *BaseController) ProxyImage(c *gin.Context) {
raw := c.Query("url")
if raw == "" {
@@ -2287,8 +2285,15 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request init failed"})
return
}
- // Some CDNs require a UA
- req.Header.Set("User-Agent", "fotbal-club/1.0 (+https://localhost)")
+ // Use realistic browser headers - some CDNs block unknown clients
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36")
+ req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
+ req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
+ // Set a benign referer tied to the target host to satisfy anti-hotlink checks
+ if u.Host != "" {
+ ref := u.Scheme + "://" + u.Host + "/"
+ req.Header.Set("Referer", ref)
+ }
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"})
@@ -2366,6 +2371,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
ClubLogoURL string `json:"club_logo_url"`
ClubURL string `json:"club_url"`
+ // Optional bases
+ FrontendBaseURL string `json:"frontend_base_url"`
+ APIBaseURL string `json:"api_base_url"`
+
// Social profiles (optional)
FacebookURL string `json:"facebook_url"`
InstagramURL string `json:"instagram_url"`
@@ -2473,6 +2482,45 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
if body.FrontpageStyle != "" {
s.FrontpageStyle = body.FrontpageStyle
}
+
+ // Detect and persist base URLs
+ if v := strings.TrimSpace(body.APIBaseURL); v != "" {
+ s.APIBaseURL = v
+ }
+ if v := strings.TrimSpace(body.FrontendBaseURL); v != "" {
+ s.FrontendBaseURL = v
+ }
+ // If not provided, infer from current request and proxy headers
+ {
+ scheme := "http"
+ if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
+ scheme = "https"
+ }
+ host := c.Request.Host
+ if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
+ parts := strings.Split(xf, ",")
+ if len(parts) > 0 {
+ if h := strings.TrimSpace(parts[0]); h != "" { host = h }
+ }
+ }
+ if !strings.Contains(host, ":") {
+ if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
+ if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
+ host = host + ":" + xfp
+ }
+ }
+ }
+ if strings.TrimSpace(s.APIBaseURL) == "" {
+ s.APIBaseURL = scheme + "://" + host + "/api/v1"
+ }
+ if strings.TrimSpace(s.FrontendBaseURL) == "" {
+ if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" {
+ s.FrontendBaseURL = origin
+ } else {
+ s.FrontendBaseURL = scheme + "://" + host
+ }
+ }
+ }
// SMTP overrides from initial setup
if body.SMTP != nil {
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
@@ -2594,7 +2642,11 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, outPath)
}(s)
- go services.PrefetchOnce(getPrefetchBaseURL())
+ {
+ base := strings.TrimSpace(s.APIBaseURL)
+ if base == "" { base = getPrefetchBaseURL() }
+ go services.PrefetchOnce(strings.TrimRight(base, "/"))
+ }
if strings.TrimSpace(s.YoutubeURL) != "" {
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL)
}
@@ -2603,7 +2655,7 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
}
- c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena"})
+ c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL})
return
}
@@ -2666,6 +2718,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
From string `json:"from"`
UseTLS *bool `json:"use_tls"`
} `json:"smtp"`
+
+ // Optional bases
+ FrontendBaseURL string `json:"frontend_base_url"`
+ APIBaseURL string `json:"api_base_url"`
}
var body reqBody
if err := c.ShouldBindJSON(&body); err != nil {
@@ -2806,6 +2862,44 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
if body.FrontpageStyle != "" {
s.FrontpageStyle = body.FrontpageStyle
}
+
+ // Persist base URLs (prefer request body, otherwise infer)
+ if v := strings.TrimSpace(body.APIBaseURL); v != "" {
+ s.APIBaseURL = v
+ }
+ if v := strings.TrimSpace(body.FrontendBaseURL); v != "" {
+ s.FrontendBaseURL = v
+ }
+ {
+ scheme := "http"
+ if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
+ scheme = "https"
+ }
+ host := c.Request.Host
+ if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
+ parts := strings.Split(xf, ",")
+ if len(parts) > 0 {
+ if h := strings.TrimSpace(parts[0]); h != "" { host = h }
+ }
+ }
+ if !strings.Contains(host, ":") {
+ if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
+ if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
+ host = host + ":" + xfp
+ }
+ }
+ }
+ if strings.TrimSpace(s.APIBaseURL) == "" {
+ s.APIBaseURL = scheme + "://" + host + "/api/v1"
+ }
+ if strings.TrimSpace(s.FrontendBaseURL) == "" {
+ if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" {
+ s.FrontendBaseURL = origin
+ } else {
+ s.FrontendBaseURL = scheme + "://" + host
+ }
+ }
+ }
// SMTP overrides from initial setup
if body.SMTP != nil {
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
@@ -2940,12 +3034,13 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
logger.Info("Starting initial data prefetch and setup operations in background...")
// Run all setup operations in a single background goroutine
- go func(settingsID uint, youtubeURL, galleryURL, adminEmail string) {
+ go func(settingsID uint, youtubeURL, galleryURL, adminEmail string, apiBase string) {
defer func() { _ = recover() }()
// 1. Trigger prefetch (matches, standings, etc.)
- baseURL := getPrefetchBaseURL()
- services.PrefetchOnce(baseURL)
+ baseURL := strings.TrimSpace(apiBase)
+ if baseURL == "" { baseURL = getPrefetchBaseURL() }
+ services.PrefetchOnce(strings.TrimRight(baseURL, "/"))
logger.Info("Background prefetch completed")
// Auto-populate competition aliases from FACR data
@@ -2984,10 +3079,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
}
logger.Info("All background setup operations completed")
- }(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email)
+ }(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email, s.APIBaseURL)
logger.Info("SetupInitialize finished successfully - background operations running")
- c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully"})
+ c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL})
}
// UpdateSettings updates settings (upsert singleton)
@@ -3103,6 +3198,10 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
// Homepage matches display configuration
FinishedMatchDisplayDays *int `json:"finished_match_display_days"`
+
+ // Deployment base URLs (optional, for domain/IP change)
+ FrontendBaseURL *string `json:"frontend_base_url"`
+ APIBaseURL *string `json:"api_base_url"`
}
var body reqBody
if err := c.ShouldBindJSON(&body); err != nil {
@@ -3404,6 +3503,14 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays
}
+ // Deployment base URLs
+ if body.FrontendBaseURL != nil {
+ s.FrontendBaseURL = strings.TrimSpace(*body.FrontendBaseURL)
+ }
+ if body.APIBaseURL != nil {
+ s.APIBaseURL = strings.TrimSpace(*body.APIBaseURL)
+ }
+
if s.ID == 0 {
if err := bc.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
@@ -3425,10 +3532,11 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
}
logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel)
// Best-effort: trigger prefetch so cached settings.json and dependent files update immediately
- go func() {
- base := getPrefetchBaseURL()
- services.PrefetchOnce(base)
- }()
+ go func(urlFromSettings string) {
+ base := strings.TrimSpace(urlFromSettings)
+ if base == "" { base = getPrefetchBaseURL() }
+ services.PrefetchOnce(strings.TrimRight(base, "/"))
+ }(s.APIBaseURL)
// If gallery_url is a Zonerama link, refresh Zonerama cache immediately
if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
@@ -3569,6 +3677,9 @@ func (bc *BaseController) GetPublicSettings(c *gin.Context) {
"map_zoom_level": s.MapZoomLevel,
"map_style": s.MapStyle,
"show_map_on_homepage": s.ShowMapOnHomepage,
+ // Deployment base URLs (hints for frontend tooling)
+ "frontend_base_url": s.FrontendBaseURL,
+ "api_base_url": s.APIBaseURL,
}
logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel)
c.JSON(http.StatusOK, resp)
@@ -4087,6 +4198,127 @@ func (bc *BaseController) DeleteSponsor(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"zprava": "Sponzor byl smazán"})
}
+// Banners (separate from sponsors)
+func (bc *BaseController) GetBanners(c *gin.Context) {
+ var items []models.Banner
+ q := bc.DB.Model(&models.Banner{})
+ activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false"
+ if activeOnly {
+ q = q.Where("is_active = ?", true)
+ }
+ if p := strings.TrimSpace(c.Query("placement")); p != "" {
+ q = q.Where("placement = ?", p)
+ }
+ if err := q.Order("display_order ASC, created_at ASC").Find(&items).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+
+func (bc *BaseController) CreateBanner(c *gin.Context) {
+ var body struct {
+ Name string `json:"name" binding:"required"`
+ ImageURL string `json:"image_url"`
+ ClickURL string `json:"click_url"`
+ Placement string `json:"placement"`
+ Width *int `json:"width"`
+ Height *int `json:"height"`
+ IsActive *bool `json:"is_active"`
+ DisplayOrder *int `json:"display_order"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
+ return
+ }
+ name := strings.TrimSpace(body.Name)
+ if name == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru je povinný"})
+ return
+ }
+ item := models.Banner{
+ Name: name,
+ ImageURL: strings.TrimSpace(body.ImageURL),
+ ClickURL: strings.TrimSpace(body.ClickURL),
+ Placement: strings.TrimSpace(body.Placement),
+ IsActive: true,
+ }
+ if body.Width != nil { item.Width = *body.Width }
+ if body.Height != nil { item.Height = *body.Height }
+ if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder }
+ if body.IsActive != nil { item.IsActive = *body.IsActive }
+ if err := bc.DB.Create(&item).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit banner"})
+ return
+ }
+ c.JSON(http.StatusCreated, item)
+}
+
+func (bc *BaseController) UpdateBanner(c *gin.Context) {
+ id := c.Param("id")
+ var item models.Banner
+ if err := bc.DB.First(&item, id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
+ return
+ }
+ var body struct {
+ Name *string `json:"name"`
+ ImageURL *string `json:"image_url"`
+ ClickURL *string `json:"click_url"`
+ Placement *string `json:"placement"`
+ Width *int `json:"width"`
+ Height *int `json:"height"`
+ IsActive *bool `json:"is_active"`
+ DisplayOrder *int `json:"display_order"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
+ return
+ }
+ if body.Name != nil {
+ v := strings.TrimSpace(*body.Name)
+ if v == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru nemůže být prázdný"})
+ return
+ }
+ item.Name = v
+ }
+ if body.ImageURL != nil { item.ImageURL = strings.TrimSpace(*body.ImageURL) }
+ if body.ClickURL != nil { item.ClickURL = strings.TrimSpace(*body.ClickURL) }
+ if body.Placement != nil { item.Placement = strings.TrimSpace(*body.Placement) }
+ if body.Width != nil { item.Width = *body.Width }
+ if body.Height != nil { item.Height = *body.Height }
+ if body.IsActive != nil { item.IsActive = *body.IsActive }
+ if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder }
+ if err := bc.DB.Save(&item).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat banner"})
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+
+func (bc *BaseController) DeleteBanner(c *gin.Context) {
+ id := c.Param("id")
+ var item models.Banner
+ if err := bc.DB.First(&item, id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
+ return
+ }
+ if err := bc.DB.Delete(&item).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat banner"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"zprava": "Banner byl smazán"})
+}
+
func (bc *BaseController) UploadImage(c *gin.Context) {
f, err := c.FormFile("file")
if err != nil {
@@ -4150,15 +4382,37 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
// Build absolute URL from request (supports proxies)
scheme := "http"
- if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
+ if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
- host = xf
+ // Take the first value if comma-separated
+ parts := strings.Split(xf, ",")
+ if len(parts) > 0 {
+ h := strings.TrimSpace(parts[0])
+ if h != "" { host = h }
+ }
+ }
+ // Append forwarded port when host has no explicit port and it's non-default
+ if !strings.Contains(host, ":") {
+ if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
+ if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
+ host = host + ":" + xfp
+ }
+ }
}
absolute := scheme + "://" + host + urlPath
- c.JSON(http.StatusOK, gin.H{"url": absolute})
+ c.JSON(http.StatusOK, gin.H{
+ // Always return a backend-relative path for storage
+ "url": urlPath,
+ // Convenience absolute URL for immediate usage in UIs
+ "absolute_url": absolute,
+ // Basic metadata (best-effort)
+ "name": outName,
+ "type": mimeType,
+ "size": f.Size,
+ })
}
// Global newsletter automation instance (set from main)
diff --git a/internal/controllers/contact_controller.go b/internal/controllers/contact_controller.go
index 90f1f58..17c45f4 100644
--- a/internal/controllers/contact_controller.go
+++ b/internal/controllers/contact_controller.go
@@ -1099,15 +1099,15 @@ func (cc *ContactController) ForwardContactMessage(c *gin.Context) {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
} else {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch message"})
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
- // Prepare email data for forwarding
+ // Prepare email data for forwarding (Czech subject)
forwardData := &email.EmailData{
- Subject: fmt.Sprintf("Fwd: Contact Form - %s", message.Subject),
- To: []string{input.ToEmail},
+ Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", message.Subject),
+ To: []string{input.ToEmail},
Template: "contact_form",
Data: struct {
Name string
@@ -1128,26 +1128,21 @@ func (cc *ContactController) ForwardContactMessage(c *gin.Context) {
},
}
- // Send email asynchronously
- go func() {
- if err := cc.emailService.SendEmail(forwardData); err != nil {
- logger.Error("Failed to forward contact message %d to %s: %v", id, input.ToEmail, err)
- } else {
- logger.Info("Contact message %d forwarded to %s", id, input.ToEmail)
- }
- }()
-
- c.JSON(http.StatusOK, gin.H{"message": "Message is being forwarded to " + input.ToEmail})
+ if err := cc.emailService.SendEmail(forwardData); err != nil {
+ logger.Error("Failed to forward contact message %d to %s: %v", message.ID, input.ToEmail, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to forward message"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "Message forwarded"})
}
-// ForwardAllContactMessages forwards all contact messages to a specified email (admin only)
// @Summary Forward all contact messages
// @Description Forwards all contact messages to a specified email address (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
-// @Param input body map[string]string true "{ to_email: string }"
+// @Param input body map[string]string true "{ to_email: string, to_emails: []string, save_default: bool }"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
@@ -1160,13 +1155,76 @@ func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
}
var input struct {
- ToEmail string `json:"to_email" binding:"required,email"`
+ ToEmail string `json:"to_email"`
+ ToEmails []string `json:"to_emails"`
+ SaveDefault bool `json:"save_default"`
}
if err := c.ShouldBindJSON(&input); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email address is required"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
+ // Build recipients list (supports comma/semicolon/space separated string or array)
+ recipients := make([]string, 0)
+ add := func(s string) {
+ v := strings.TrimSpace(s)
+ if v != "" {
+ recipients = append(recipients, v)
+ }
+ }
+ if len(input.ToEmails) > 0 {
+ for _, e := range input.ToEmails {
+ add(e)
+ }
+ }
+ if input.ToEmail != "" {
+ // split by common separators to allow multiple addresses in a single string
+ parts := strings.FieldsFunc(input.ToEmail, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
+ if len(parts) > 1 {
+ for _, p := range parts {
+ add(p)
+ }
+ } else {
+ add(input.ToEmail)
+ }
+ }
+ // Deduplicate
+ if len(recipients) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"})
+ return
+ }
+ uniq := make(map[string]struct{})
+ out := make([]string, 0, len(recipients))
+ for _, e := range recipients {
+ v := strings.TrimSpace(strings.ToLower(e))
+ if v == "" {
+ continue
+ }
+ if _, ok := uniq[v]; ok {
+ continue
+ }
+ uniq[v] = struct{}{}
+ out = append(out, e)
+ }
+ recipients = out
+
+ // Optionally save as default auto-forward list in Settings
+ if input.SaveDefault {
+ var set models.Settings
+ if err := cc.DB.First(&set).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ set = models.Settings{}
+ set.ContactForwardEnabled = true
+ set.ContactForwardList = strings.Join(recipients, ", ")
+ _ = cc.DB.Create(&set).Error
+ }
+ } else {
+ set.ContactForwardEnabled = true
+ set.ContactForwardList = strings.Join(recipients, ", ")
+ _ = cc.DB.Save(&set).Error
+ }
+ }
+
// Fetch all messages
var messages []models.ContactMessage
if err := cc.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
@@ -1180,12 +1238,12 @@ func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
}
// Forward all messages asynchronously
- go func(msgs []models.ContactMessage, toEmail string) {
+ go func(msgs []models.ContactMessage, dest []string) {
successCount := 0
for _, message := range msgs {
forwardData := &email.EmailData{
- Subject: fmt.Sprintf("Fwd: Contact Form - %s", message.Subject),
- To: []string{toEmail},
+ Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", message.Subject),
+ To: dest,
Template: "contact_form",
Data: struct {
Name string
@@ -1207,16 +1265,16 @@ func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
}
if err := cc.emailService.SendEmail(forwardData); err != nil {
- logger.Error("Failed to forward contact message %d to %s: %v", message.ID, toEmail, err)
+ logger.Error("Failed to forward contact message %d to %v: %v", message.ID, dest, err)
} else {
successCount++
}
}
- logger.Info("Forwarded %d of %d contact messages to %s", successCount, len(msgs), toEmail)
- }(messages, input.ToEmail)
+ logger.Info("Forwarded %d of %d contact messages to %v", successCount, len(msgs), dest)
+ }(messages, recipients)
c.JSON(http.StatusOK, gin.H{
- "message": fmt.Sprintf("Forwarding %d message(s) to %s", len(messages), input.ToEmail),
+ "message": fmt.Sprintf("Přeposílám %d zpráv na: %s", len(messages), strings.Join(recipients, ", ")),
"count": len(messages),
})
}
diff --git a/internal/controllers/image_processing_controller.go b/internal/controllers/image_processing_controller.go
index 7a62f12..921b306 100644
--- a/internal/controllers/image_processing_controller.go
+++ b/internal/controllers/image_processing_controller.go
@@ -159,8 +159,9 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
}
absolute := scheme + "://" + host + outputPath
c.JSON(http.StatusOK, gin.H{
- "url": absolute,
- "format": format,
+ "url": outputPath,
+ "absolute_url": absolute,
+ "format": format,
})
}
@@ -346,7 +347,8 @@ func (ctrl *ImageProcessingController) CropAndUpload(c *gin.Context) {
}
absolute := scheme + "://" + host + outputPath
c.JSON(http.StatusOK, gin.H{
- "url": absolute,
+ "url": outputPath,
+ "absolute_url": absolute,
})
}
@@ -431,7 +433,28 @@ func (ctrl *ImageProcessingController) QuickEdit(c *gin.Context) {
return
}
+ scheme := "http"
+ if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
+ scheme = "https"
+ }
+ host := c.Request.Host
+ if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
+ parts := strings.Split(xf, ",")
+ if len(parts) > 0 {
+ h := strings.TrimSpace(parts[0])
+ if h != "" { host = h }
+ }
+ }
+ if !strings.Contains(host, ":") {
+ if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
+ if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
+ host = host + ":" + xfp
+ }
+ }
+ }
+ absolute := scheme + "://" + host + outputPath
c.JSON(http.StatusOK, gin.H{
- "url": outputPath,
+ "url": outputPath,
+ "absolute_url": absolute,
})
}
diff --git a/internal/middleware/security_headers.go b/internal/middleware/security_headers.go
index e6ad40d..d1db5e7 100644
--- a/internal/middleware/security_headers.go
+++ b/internal/middleware/security_headers.go
@@ -36,7 +36,8 @@ func SecurityHeaders() gin.HandlerFunc {
c.Header("X-Permitted-Cross-Domain-Policies", "none")
c.Header("Cross-Origin-Embedder-Policy", "require-corp")
c.Header("Cross-Origin-Opener-Policy", "same-origin")
- c.Header("Cross-Origin-Resource-Policy", "same-origin")
+ // Allow assets (e.g., /uploads) to be embedded from different origin (frontend vs backend)
+ c.Header("Cross-Origin-Resource-Policy", "cross-origin")
c.Next()
}
diff --git a/internal/models/banner.go b/internal/models/banner.go
new file mode 100644
index 0000000..95f7e19
--- /dev/null
+++ b/internal/models/banner.go
@@ -0,0 +1,17 @@
+package models
+
+import "gorm.io/gorm"
+
+type Banner struct {
+ gorm.Model
+ Name string `json:"name" gorm:"not null"`
+ ImageURL string `json:"image_url"`
+ ClickURL string `json:"click_url"`
+ Placement string `json:"placement" gorm:"index"` // e.g., homepage_top, homepage_sidebar, homepage_under_table
+ Width int `json:"width"`
+ Height int `json:"height"`
+ IsActive bool `json:"is_active" gorm:"default:true;index"`
+ DisplayOrder int `json:"display_order" gorm:"default:0;index"`
+}
+
+func (Banner) TableName() string { return "banners" }
diff --git a/internal/models/models.go b/internal/models/models.go
index 3349c4c..3f36dec 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -187,6 +187,12 @@ type Settings struct {
AdditionalMeta string `gorm:"type:text" json:"additional_meta"` // raw extra meta
EnableIndexing bool `json:"enable_indexing"` // robots allow/disallow
+ // Deployment base URLs (optional runtime hints)
+ // 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"`
+
// Social profiles
FacebookURL string `json:"facebook_url"`
InstagramURL string `json:"instagram_url"`
@@ -241,6 +247,9 @@ type Settings struct {
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"`
diff --git a/internal/routes/routes.go b/internal/routes/routes.go
index 4253040..a6197e2 100644
--- a/internal/routes/routes.go
+++ b/internal/routes/routes.go
@@ -199,6 +199,15 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
sponsors.DELETE("/:id", baseController.DeleteSponsor)
}
+ // Banners (protected CRUD)
+ banners := protected.Group("/banners")
+ banners.Use(middleware.RoleAuth("admin"))
+ {
+ banners.POST("", baseController.CreateBanner)
+ banners.PUT("/:id", baseController.UpdateBanner)
+ banners.DELETE("/:id", baseController.DeleteBanner)
+ }
+
// Admin routes (single consolidated group)
admin := protected.Group("/admin")
admin.Use(middleware.RoleAuth("admin"))
@@ -488,6 +497,8 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
api.GET("/players", baseController.GetPlayers)
api.GET("/players/:id", baseController.GetPlayer)
api.GET("/sponsors", baseController.GetSponsors)
+ // Public banners
+ api.GET("/banners", baseController.GetBanners)
api.GET("/matches", baseController.GetMatches)
api.GET("/matches/history", baseController.GetMatchesHistory)
api.GET("/standings", baseController.GetStandings)
diff --git a/pkg/database/database.go b/pkg/database/database.go
index 0306c56..5120b10 100644
--- a/pkg/database/database.go
+++ b/pkg/database/database.go
@@ -123,6 +123,7 @@ func MigrateDB(db *gorm.DB) error {
&models.Team{},
&models.Player{},
&models.Sponsor{},
+ &models.Banner{},
&models.Settings{},
&models.MatchOverride{},
&models.TeamLogoOverride{},
diff --git a/pkg/email/service.go b/pkg/email/service.go
index e3e3720..4006abc 100644
--- a/pkg/email/service.go
+++ b/pkg/email/service.go
@@ -770,7 +770,11 @@ func (s *emailService) SendEmail(data *EmailData) error {
// Website and contact (best-effort)
vm["ClubURL"] = strings.TrimSpace(set.ClubURL)
vm["WebsiteURL"] = strings.TrimSpace(set.CanonicalBaseURL)
- contactEmail := strings.TrimSpace(s.config.AdminEmail)
+ // Prefer club contact email from Settings, then AdminEmail, then SMTPFrom
+ contactEmail := strings.TrimSpace(getStringField(set, "ContactEmail"))
+ if contactEmail == "" {
+ contactEmail = strings.TrimSpace(s.config.AdminEmail)
+ }
if contactEmail == "" {
contactEmail = strings.TrimSpace(s.config.SMTPFrom)
}
@@ -781,6 +785,42 @@ func (s *emailService) SendEmail(data *EmailData) error {
}
vm["ContactURL"] = contactURL
+ // Provide recipient and link fallbacks for templates
+ if _, ok := vm["RecipientEmail"]; !ok {
+ if len(data.To) > 0 {
+ vm["RecipientEmail"] = strings.TrimSpace(data.To[0])
+ }
+ }
+ if _, ok := vm["UnsubscribeURL"]; !ok {
+ if v, ok2 := vm["UnsubscribeLink"]; ok2 {
+ if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
+ vm["UnsubscribeURL"] = strings.TrimSpace(s)
+ }
+ }
+ }
+ if _, ok := vm["ManageURL"]; !ok {
+ if v, ok2 := vm["UnsubscribeURL"]; ok2 {
+ if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
+ vm["ManageURL"] = strings.TrimSpace(s)
+ }
+ } else if v2, ok4 := vm["UnsubscribeLink"]; ok4 {
+ if s, ok5 := v2.(string); ok5 && strings.TrimSpace(s) != "" {
+ vm["ManageURL"] = strings.TrimSpace(s)
+ }
+ } else if v3, ok6 := vm["SetupURL"]; ok6 {
+ if s, ok7 := v3.(string); ok7 && strings.TrimSpace(s) != "" {
+ vm["ManageURL"] = strings.TrimSpace(s)
+ }
+ }
+ }
+ if _, ok := vm["UnsubscribeURL"]; !ok {
+ if v, ok2 := vm["SetupURL"]; ok2 {
+ if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
+ vm["UnsubscribeURL"] = strings.TrimSpace(s)
+ }
+ }
+ }
+
// Parse base + template with functions
basePath := filepath.Join(s.config.EmailTemplateDir, "base.html")
templatePath := filepath.Join(s.config.EmailTemplateDir, data.Template+".html")
@@ -867,42 +907,76 @@ func (s *emailService) SendEmail(data *EmailData) error {
}
func (s *emailService) SendContactForm(data *ContactFormData) error {
- templateData := struct {
- Name string
- Email string
- Subject string
- Message string
- Time string
- IP string
- Agent string
- }{
- Name: data.Name,
- Email: data.Email,
- Subject: data.Subject,
- Message: data.Message,
- Time: time.Now().Format(time.RFC1123Z),
- IP: data.IPAddress,
- Agent: data.UserAgent,
- }
+ templateData := struct {
+ Name string
+ Email string
+ Subject string
+ Message string
+ Time string
+ IP string
+ Agent string
+ }{
+ Name: data.Name,
+ Email: data.Email,
+ Subject: data.Subject,
+ Message: data.Message,
+ Time: time.Now().Format(time.RFC1123Z),
+ IP: data.IPAddress,
+ Agent: data.UserAgent,
+ }
- emailData := &EmailData{
- Subject: "New Contact Form: " + data.Subject,
- To: []string{s.config.AdminEmail},
- Template: "contact_form",
- Data: templateData,
- From: s.config.SMTPFrom,
- FromName: s.config.SMTPFromName,
- }
+ // 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
+ 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)
+ }
+ }
+ }
+ }
+ // Deduplicate and ensure at least one recipient
+ uniq := make(map[string]struct{})
+ dedup := make([]string, 0, len(recipients))
+ for _, e := range recipients {
+ v := strings.ToLower(strings.TrimSpace(e))
+ if v == "" { continue }
+ if _, ok := uniq[v]; ok { continue }
+ uniq[v] = struct{}{}
+ dedup = append(dedup, e)
+ }
+ if len(dedup) == 0 {
+ if v := strings.TrimSpace(s.config.SMTPFrom); v != "" {
+ dedup = []string{v}
+ }
+ }
+
+ emailData := &EmailData{
+ Subject: "Nová zpráva z formuláře: " + data.Subject,
+ To: dedup,
+ Template: "contact_form",
+ Data: templateData,
+ From: s.config.SMTPFrom,
+ FromName: s.config.SMTPFromName,
+ }
// Send confirmation to user
if data.Email != "" {
- confirmationData := &EmailData{
- Subject: "We've received your message",
- To: []string{data.Email},
- Template: "contact_confirmation",
- Data: struct {
- Name string
- Message string
+ confirmationData := &EmailData{
+ Subject: "Obdrželi jsme vaši zprávu",
+ To: []string{data.Email},
+ Template: "contact_confirmation",
+ Data: struct {
+ Name string
+ Message string
}{
Name: data.Name,
Message: data.Message,
@@ -1048,6 +1122,7 @@ func (s *emailService) SendNewsletter(data *NewsletterData) error {
ClubURL string
ContactEmail string
ContactURL string
+ RecipientEmail string
}{
Subject: data.Subject,
Content: data.Content,
@@ -1073,13 +1148,14 @@ func (s *emailService) SendNewsletter(data *NewsletterData) error {
}
return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {clubURL}})
}(),
- ContactEmail: contactEmail,
+ ContactEmail: contactEmail,
ContactURL: func() string {
if contactURL == "" {
return ""
}
return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {contactURL}})
}(),
+ RecipientEmail: recipient,
}
// Wrap socials if present
diff --git a/templates/emails/base.html b/templates/emails/base.html
index b7f8818..fd57852 100644
--- a/templates/emails/base.html
+++ b/templates/emails/base.html
@@ -201,6 +201,16 @@
{{if .ContactEmail}}E-mail {{end}}
{{end}}
+ {{if .RecipientEmail}}
+ Tento e‑mail byl odeslán na adresu {{.RecipientEmail}} .
+ {{end}}
+ {{if or .UnsubscribeURL .ManageURL}}
+
+ {{if .UnsubscribeURL}}Odhlásit odběr {{end}}
+ {{if and .UnsubscribeURL .ManageURL}} | {{end}}
+ {{if .ManageURL}}Upravit předvolby {{end}}
+
+ {{end}}
{{if or .FacebookURL .InstagramURL .YouTubeURL .TwitterURL}}
{{if .FacebookURL}}
Facebook {{end}}
@@ -211,11 +221,6 @@
{{end}}
{{.ClubName}} © {{now.Format "2006"}}. Všechna práva vyhrazena.
Pokud jste tento email obdrželi omylem, prosíme o jeho smazání.
- {{if .UnsubscribeURL}}
-
- Odhlásit se z odběru
-
- {{end}}
Made by MyClub by Sportcreative
diff --git a/templates/emails/contact_confirmation.html b/templates/emails/contact_confirmation.html
new file mode 100644
index 0000000..71350c2
--- /dev/null
+++ b/templates/emails/contact_confirmation.html
@@ -0,0 +1,19 @@
+{{define "content"}}
+
Děkujeme za zprávu
+
Vaši zprávu jsme úspěšně obdrželi. Ozveme se vám co nejdříve.
+
+
+
+ Jméno:
+ {{.Name}}
+
+
+ Vaše zpráva:
+ {{.Message}}
+
+
+
+
+ Otevřít web
+
+{{end}}
diff --git a/templates/emails/contact_form.html b/templates/emails/contact_form.html
index c8930e5..401d559 100644
--- a/templates/emails/contact_form.html
+++ b/templates/emails/contact_form.html
@@ -1,39 +1,39 @@
{{define "content"}}
-
New Contact Form Submission
-
You've received a new message from the contact form on your website.
+
Nová zpráva z kontaktního formuláře
+
Obdrželi jste novou zprávu z kontaktního formuláře na webu.
- Name:
+ Jméno:
{{.Name}}
- Email:
+ E-mail:
{{.Email}}
- Subject:
+ Předmět:
{{.Subject}}
- Message:
+ Zpráva:
{{.Message}}
{{if .IPAddress}}
- IP Address:
+ IP adresa:
{{.IPAddress}}
{{end}}
{{if .UserAgent}}
- User Agent:
+ Prohlížeč:
{{.UserAgent}}
{{end}}
- Reply to {{.Name}}
+ Odpovědět {{.Name}}
{{end}}
diff --git a/templates/emails/newsletter_setup.html b/templates/emails/newsletter_setup.html
index 75fe00c..b9ac72e 100644
--- a/templates/emails/newsletter_setup.html
+++ b/templates/emails/newsletter_setup.html
@@ -1,8 +1,10 @@
-
-
-
Vítejte v newsletteru
-
Děkujeme za přihlášení k odběru. Klikněte na odkaz níže pro nastavení, jaké zprávy chcete dostávat:
-
Nastavit preference newsletteru
-
Pokud jste se k odběru nepřihlásili, můžete tento e-mail ignorovat nebo se odhlásit.
-
-
+{{define "content"}}
+
Vítejte v newsletteru
+
Děkujeme za přihlášení k odběru. Klikněte na tlačítko níže a nastavte, jaké zprávy chcete dostávat:
+ {{if .SetupURL}}
+
+ Nastavit preference
+
+ {{end}}
+
Pokud jste se k odběru nepřihlásili, můžete tento e‑mail ignorovat.
+{{end}}
diff --git a/templates/emails/newsletter_welcome.html b/templates/emails/newsletter_welcome.html
index 3b634af..4c6274a 100644
--- a/templates/emails/newsletter_welcome.html
+++ b/templates/emails/newsletter_welcome.html
@@ -1,97 +1,20 @@
{{define "content"}}
-
-
-
-
-
Vítejte v našem newsletteru!
-
-
-
-
-
- Děkujeme, že jste se přihlásili k odběru našeho newsletteru. Jsme rádi, že jste součástí naší fotbalové komunity a budeme vás pravidelně informovat o nejnovějším dění v klubu.
+
Vítejte v našem newsletteru!
+
+ Děkujeme, že jste se přihlásili k odběru našeho newsletteru. Jsme rádi, že jste součástí naší fotbalové komunity a budeme vás pravidelně informovat o nejnovějším dění v klubu.
+
+
+
Co pro vás připravujeme?
+
+ Novinky z klubu: aktuality, rozhovory a zajímavosti ze zákulisí.
+ Zápasy a výsledky: přehled nadcházejících zápasů a reporty z odehraných.
+ Speciální nabídky: výhody a slevy pro odběratele.
+ Rozhovory: s hráči, trenéry a členy klubu.
+
+
+
+
+ Aby vám naše e‑maily nechyběly, přidejte si adresu {{.FromEmail}} do kontaktů.
-
-
-
-
- Co pro vás připravujeme?
-
-
-
-
-
-
- 📰 Novinky z klubu
-
-
Aktuality, rozhovory a zajímavosti ze zákulisí našeho klubu.
-
-
-
-
- ⚽ Zápasy a výsledky
-
-
Přehled nadcházejících zápasů a reporty z těch odehraných.
-
-
-
-
- 🎟️ Speciální nabídky
-
-
Výhody a slevy výhradně pro naše odběratele.
-
-
-
-
- 👥 Rozhovory
-
-
Rozhovory s hráči, trenéry a dalšími členy klubu.
-
-
-
-
-
-
-
- ℹ️ Důležité
-
-
- Aby vám naše e-maily nechyběly, přidejte si adresu {{.FromEmail}} do vašich kontaktů.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- © {{.Year}} FK Fotbal Club. Všechna práva vyhrazena.
-
-
- Tento e-mail byl odeslán na adresu {{.Email}}
-
-
-
{{end}}
diff --git a/templates/emails/newsletter_welcome_back.html b/templates/emails/newsletter_welcome_back.html
index 48ae938..b28a8aa 100644
--- a/templates/emails/newsletter_welcome_back.html
+++ b/templates/emails/newsletter_welcome_back.html
@@ -1,90 +1,13 @@
{{define "content"}}
-
-
-
-
-
Vítejte zpět u nás!
-
-
-
-
-
- Děkujeme, že jste se znovu přihlásili k odběru našeho newsletteru. Jsme opravdu rádi, že jste se k nám vrátili a těšíme se na vaši zpětnou vazbu!
-
-
-
-
-
- Co je u nás nového?
-
-
-
-
-
-
- 👕 Nové dresy {{.Year}}
-
-
Představili jsme zbrusu nové dresy na sezónu {{.Year}}. Podívejte se na ně v našem fotoalbu .
-
-
-
-
- 📅 Aktuální rozvrh zápasů
-
-
Připravili jsme pro vás kompletní rozvrh zápasů na nadcházející měsíce. Zobrazit kalendář .
-
-
-
-
- 🏟️ Akce pro fanoušky
-
-
Připravili jsme sérii akcí pro naše věrné fanoušky. Více informací naleznete v kalendáři akcí .
-
-
-
-
-
-
-
- 📱 Sledujte nás
-
-
- Sledujte nás na sociálních sítích a buďte mezi prvními, kdo se dozví o novinkách z našeho klubu.
-
-
-
-
-
-
-
-
-
-
- © {{.Year}} FK Fotbal Club. Všechna práva vyhrazena.
-
-
- Tento e-mail byl odeslán na adresu {{.Email}}
-
-
-
-
+
Vítejte zpět u nás!
+
+ Děkujeme, že jste se znovu přihlásili k odběru našeho newsletteru. Jsme rádi, že jste zpátky a těšíme se na vaši zpětnou vazbu!
+
+
+
Co je u nás nového?
+
+ Aktuální rozvrh zápasů a výsledků.
+ Novinky z klubu a rozhovory.
+ Akce pro fanoušky a speciální nabídky.
+
{{end}}