Files
MyClub/frontend/src/pages/SetupPage.tsx
T
Tomas Dvorak e9a63073e5 dev day #63
2025-10-17 17:39:11 +02:00

946 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useMemo, startTransition } from 'react';
import { Box, Button, FormControl, FormLabel, Input, VStack, Heading, useToast, SimpleGrid, Divider, Text, useColorModeValue, InputGroup, InputRightElement, List, ListItem, Spinner, HStack, Image, Checkbox, Tooltip, Alert, AlertIcon, Select, FormHelperText, Badge, Link } from '@chakra-ui/react';
import { InfoOutlineIcon } from '@chakra-ui/icons';
import './styles/MagazineHome.css';
import './styles/ProHome.css';
import { useNavigate } from 'react-router-dom';
import { getSetupStatus, initializeSetup, SetupInitializePayload, validateSMTP } from '../services/setup';
import { updateSeoSettings } from '../services/seo';
import { API_URL } from '../services/api';
import { assetUrl } from '../utils/url';
import { useFacrApi } from '../hooks/useFacrApi';
import { SearchResult } from '../services/facr/types';
import { extractPalette, pickTextColor, generateJwtSecret, contrastRatio, isContrastAccessible, generateThemeCandidates, ThemeCandidate, adjustForContrast } from '../utils/colors';
import { clearToken, setHasAdmin } from '../utils/auth';
import ContactMap from '../components/home/ContactMap';
import { FONT_PAIRINGS, applyFontPairing, getFontStyleColor } from '../config/fonts';
import MapLinkImporter from '../components/admin/MapLinkImporter';
import MapStyleSelector from '../components/admin/MapStyleSelector';
import { MapCoordinates } from '../utils/mapUrlParser';
import { fetchLogoFromLogoAPI } from '../utils/sportLogosAPI';
const SetupPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [requiresSetup, setRequiresSetup] = useState(false);
// form state
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [showAdminPassword, setShowAdminPassword] = useState(false);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [jwtSecret, setJwtSecret] = useState('');
const [clubId, setClubId] = useState('');
const [clubType, setClubType] = useState('football');
const [clubName, setClubName] = useState('');
const [clubLogoUrl, setClubLogoUrl] = useState('');
const [uploadingLogo, setUploadingLogo] = useState(false);
const [clubUrl, setClubUrl] = useState('');
const [clubQuery, setClubQuery] = useState('');
const { searchClubs, searchResults, searchLoading } = useFacrApi();
const resolveLogoUrl = (u?: string | null) => {
if (!u) return undefined;
// If it's a logoapi URL, use it directly (no proxy needed)
if (u.includes('logoapi.sportcreative.eu')) return u;
// If it's a backend-relative path or dist asset, use assetUrl helper
if (u.startsWith('/uploads') || u.startsWith('/dist') || u.startsWith('/api/')) return assetUrl(u);
// If it's an absolute remote URL, route through backend proxy to avoid CORS/hotlinking issues
if (/^https?:\/\//i.test(u)) {
const base = (API_URL || '').replace(/\/$/, '');
return `${base}/proxy/image?url=${encodeURIComponent(u)}`;
}
return u;
};
// Theme colors
// Default theme colors before any logo/preset is selected
const [primaryColor, setPrimaryColor] = useState('#2d74da');
const [secondaryColor, setSecondaryColor] = useState('#f6f8fb');
const [accentColor, setAccentColor] = useState('#ffb703');
const [backgroundColor, setBackgroundColor] = useState('#ffffff');
const [textColor, setTextColor] = useState('#111827');
// Site style
const [frontpageStyle, setFrontpageStyle] = useState<'unified' | 'magazine'>('unified');
// Theme presets generated from logo
const [themePresets, setThemePresets] = useState<ThemeCandidate[]>([]);
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
// Typography/Font
const [selectedFont, setSelectedFont] = useState<string>('inter-inter');
// GPS Location
const [mapUrl, setMapUrl] = useState('');
const [gpsLat, setGpsLat] = useState<number | ''>('');
const [gpsLng, setGpsLng] = useState<number | ''>('');
const [mapStyle, setMapStyle] = useState<string>('positron');
// Contact Details
const [contactStreet, setContactStreet] = useState('');
const [contactCity, setContactCity] = useState('');
const [contactPostalCode, setContactPostalCode] = useState('');
const [contactCountry, setContactCountry] = useState('Česká republika');
const [contactPhone, setContactPhone] = useState('');
const [contactEmail, setContactEmail] = useState('');
// SMTP
const [smtpHost, setSmtpHost] = useState('');
const [smtpPort, setSmtpPort] = useState<number | ''>('');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [showSmtpPass, setShowSmtpPass] = useState(false);
// Sender display name only; actual email is derived from smtpUser
const [smtpFromName, setSmtpFromName] = useState('');
const [smtpTLS, setSmtpTLS] = useState(true);
const [testingSMTP, setTestingSMTP] = useState(false);
// Social & gallery
const [facebookUrl, setFacebookUrl] = useState('');
const [instagramUrl, setInstagramUrl] = useState('');
const [youtubeUrl, setYoutubeUrl] = useState('');
const [galleryUrl, setGalleryUrl] = useState('');
const [galleryLabel, setGalleryLabel] = useState('Fotogalerie');
const toast = useToast();
const navigate = useNavigate();
const bg = useColorModeValue('white', 'gray.800');
const borderCol = useColorModeValue('gray.200', 'gray.600');
// Password helpers
const sanitizePassword = (val: string) => {
// remove all whitespace characters (spaces, tabs, newlines, non-breaking spaces)
return (val || '').replace(/\s+/g, '');
};
const isPasswordValid = (val: string) => {
// 8-128 chars, no whitespace
return /^\S{8,128}$/.test(val);
};
const generateStrongPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*()-_=+[]{};:,.?';
let out = '';
const len = 16;
for (let i = 0; i < len; i++) {
out += chars.charAt(Math.floor(Math.random() * chars.length));
}
return out;
};
useEffect(() => {
let mounted = true;
// Ensure no stale auth/admin flags can interfere with setup
try {
clearToken();
setHasAdmin(false);
} catch {}
(async () => {
try {
const s = await getSetupStatus();
if (!mounted) return;
setRequiresSetup(!!s.requires_setup);
} catch (e) {
setRequiresSetup(false);
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Auto-generate JWT secret when setup is required
useEffect(() => {
if (requiresSetup && !jwtSecret) {
setJwtSecret(generateJwtSecret());
}
}, [requiresSetup, jwtSecret]);
// Debounced search for clubs
useEffect(() => {
const q = clubQuery.trim();
if (!q) return;
const t = setTimeout(() => {
searchClubs(q).catch(() => {});
}, 300);
return () => clearTimeout(t);
}, [clubQuery, searchClubs]);
// Load and apply selected font for preview
useEffect(() => {
const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
if (pairing) {
applyFontPairing(pairing);
}
}, [selectedFont]);
// Auto-fill SMTP username from contact email
useEffect(() => {
if (contactEmail && !smtpUser) {
setSmtpUser(contactEmail);
}
}, [contactEmail, smtpUser]);
const handleSelectClub = async (item: SearchResult) => {
const clubIdValue = item.club_id || '';
setClubId(clubIdValue);
setClubType(item.club_type || 'football');
setClubName(item.name || '');
setClubUrl(item.url || '');
setClubQuery(item.name || '');
// Try to fetch logo from logoapi first, fallback to FACR logo
let logoUrl = '';
if (clubIdValue) {
const logoApiUrl = await fetchLogoFromLogoAPI(clubIdValue, item.name);
if (logoApiUrl) {
logoUrl = logoApiUrl;
}
}
// Fallback to FACR logo if logoapi doesn't have it
if (!logoUrl && item.logo_url) {
logoUrl = item.logo_url;
}
setClubLogoUrl(logoUrl);
// Auto-fill sender display name from club name if empty
if (!smtpFromName && item.name) {
setSmtpFromName(item.name);
}
// Update the document title immediately for instant feedback
try {
if (typeof document !== 'undefined' && item.name) {
document.title = item.name;
}
} catch {}
// Try to extract colors
if (logoUrl) {
extractPalette(logoUrl, 5)
.then((colors) => {
if (!colors || colors.length === 0) return;
const presets = generateThemeCandidates(colors);
setThemePresets(presets);
// Apply first preset by default
const p0 = presets[0];
if (p0) {
setPrimaryColor(p0.primary);
setSecondaryColor(p0.secondary);
setAccentColor(p0.accent);
setBackgroundColor(p0.background);
setTextColor(p0.text);
setSelectedPreset(0);
}
})
.catch(() => {});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
// Validate and sanitize password
const cleanPassword = sanitizePassword(adminPassword);
if (!isPasswordValid(cleanPassword)) {
toast({ title: 'Neplatné heslo', description: 'Heslo musí mít 8128 znaků a nesmí obsahovat mezery.', status: 'error' });
setSubmitting(false);
return;
}
// Compose SMTP From as "Name <username>" if both provided
const composedFrom = smtpFromName && smtpUser
? `${smtpFromName} <${smtpUser}>`
: (smtpUser || undefined);
const payload: SetupInitializePayload = {
admin_email: adminEmail,
admin_password: cleanPassword,
first_name: firstName || undefined,
last_name: lastName || undefined,
jwt_secret: jwtSecret || undefined,
club_id: clubId || undefined,
club_type: clubType || undefined,
club_name: clubName || undefined,
club_logo_url: clubLogoUrl || undefined,
club_url: clubUrl || undefined,
frontpage_style: frontpageStyle || undefined,
primary_color: primaryColor || undefined,
secondary_color: secondaryColor || undefined,
accent_color: accentColor || undefined,
background_color: backgroundColor || undefined,
text_color: textColor || undefined,
// typography (optional)
font_heading: FONT_PAIRINGS.find(f => f.id === selectedFont)?.heading || undefined,
font_body: FONT_PAIRINGS.find(f => f.id === selectedFont)?.body || undefined,
// social & gallery (optional)
facebook_url: facebookUrl || undefined,
instagram_url: instagramUrl || undefined,
youtube_url: youtubeUrl || undefined,
gallery_url: galleryUrl || undefined,
gallery_label: galleryLabel || undefined,
// GPS location (optional)
location_latitude: typeof gpsLat === 'number' ? gpsLat : undefined,
location_longitude: typeof gpsLng === 'number' ? gpsLng : undefined,
map_style: mapStyle || undefined,
// Contact details (optional)
contact_address: contactStreet || undefined,
contact_city: contactCity || undefined,
contact_zip: contactPostalCode || undefined,
contact_country: contactCountry || undefined,
contact_phone: contactPhone || undefined,
contact_email: contactEmail || undefined,
smtp: (smtpHost || smtpPort || smtpUser || smtpPass || smtpFromName) ? {
host: smtpHost || undefined,
port: typeof smtpPort === 'number' ? smtpPort : undefined,
username: smtpUser || undefined,
password: smtpPass || undefined,
from: composedFrom,
use_tls: smtpTLS,
} : null,
};
await initializeSetup(payload);
// Set sensible default SEO based on setup data
try {
const origin = (typeof window !== 'undefined' ? window.location.origin : '').replace(/\/$/, '');
await updateSeoSettings({
site_title: clubName || 'Fotbal Club',
site_description: clubName ? `${clubName} oficiální klubový web: aktuality, zápasy, tabulky, hráči.` : 'Oficiální klubový web: aktuality, zápasy, tabulky, hráči.',
default_og_image_url: clubLogoUrl || undefined,
canonical_base_url: origin || undefined,
enable_indexing: true,
});
} catch {}
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true });
navigate('/login', { replace: true });
// Force full reload to ensure app picks up fresh server state and env
setTimeout(() => {
try { window.location.reload(); } catch {}
}, 800);
} catch (error: any) {
toast({ title: 'Nastavení selhalo', description: error?.response?.data?.error || 'Zkontrolujte prosím své údaje', status: 'error' });
} finally {
setSubmitting(false);
}
};
const onLogoFile = async (f?: File | null) => {
if (!f) return;
setUploadingLogo(true);
try {
// Use a dedicated form so we can pass preserve_quality to backend
const fd = new FormData();
fd.append('file', f);
fd.append('preserve_quality', 'true');
// Upload should go to the API root (usually /api/v1/upload). Use configured API_URL
const uploadUrl = `${(API_URL || '').replace(/\/$/, '')}/upload`;
const res = await fetch(uploadUrl, { method: 'POST', body: fd });
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
let url = data?.url || '';
try {
const parsed = new URL(url, window.location.origin);
url = parsed.pathname + parsed.search + parsed.hash;
} catch {}
setClubLogoUrl(url);
// Also upload to logoapi if we have a club ID
if (clubId) {
try {
const logoFd = new FormData();
logoFd.append('logo', f);
const logoApiRes = await fetch(`https://logoapi.sportcreative.eu/logos/${clubId}`, {
method: 'POST',
body: logoFd,
});
if (logoApiRes.ok) {
toast({ title: 'Logo nahráno', description: 'Logo bylo nahráno na logoapi i lokálně', status: 'success', duration: 3000 });
}
} catch (logoApiErr) {
console.warn('Failed to upload to logoapi:', logoApiErr);
// Don't fail the whole upload if logoapi fails
}
}
// Try to extract colors from uploaded logo
try { const colors = await extractPalette(url, 5); const presets = generateThemeCandidates(colors); setThemePresets(presets); if (presets[0]) { setPrimaryColor(presets[0].primary); setSecondaryColor(presets[0].secondary); setAccentColor(presets[0].accent); setBackgroundColor(presets[0].background); setTextColor(presets[0].text); setSelectedPreset(0); } } catch {}
} catch (e) {
toast({ title: 'Nahrání loga selhalo', status: 'error' });
} finally {
setUploadingLogo(false);
}
};
// Derived: contrast checks (memoized for perf)
const bgTextContrast = useMemo(() => {
if (!backgroundColor || !textColor) return null;
return contrastRatio(backgroundColor, textColor);
}, [backgroundColor, textColor]);
const primaryTextContrast = useMemo(() => {
if (!primaryColor || !textColor) return null;
return contrastRatio(primaryColor, textColor);
}, [primaryColor, textColor]);
const isBgTextAccessible = useMemo(() => {
if (bgTextContrast == null) return true;
return isContrastAccessible(backgroundColor, textColor, 'AA');
}, [bgTextContrast, backgroundColor, textColor]);
const autofixTextForBg = () => {
if (!backgroundColor) return;
const fixed = pickTextColor(backgroundColor);
setTextColor(fixed);
};
// Re-generate presets manually when user clicks button (from current logo URL)
const regenerateFromLogo = async () => {
const url = clubLogoUrl?.trim();
if (!url) return;
try {
const colors = await extractPalette(url, 5);
const presets = generateThemeCandidates(colors);
setThemePresets(presets);
if (presets[0]) {
setPrimaryColor(presets[0].primary);
setSecondaryColor(presets[0].secondary);
setAccentColor(presets[0].accent);
setBackgroundColor(presets[0].background);
setTextColor(presets[0].text);
setSelectedPreset(0);
}
} catch {}
};
const applyPreset = (idx: number) => {
const p = themePresets[idx];
if (!p) return;
setPrimaryColor(p.primary);
setSecondaryColor(p.secondary);
setAccentColor(p.accent);
setBackgroundColor(p.background);
setTextColor(p.text);
setSelectedPreset(idx);
};
// Redirect if setup not required
useEffect(() => {
if (!loading && !requiresSetup) {
navigate('/login', { replace: true });
}
}, [loading, requiresSetup, navigate]);
if (loading) return <Box p={8}>Načítání</Box>;
if (!requiresSetup) {
return null;
}
// Get selected font pairing for live preview
const selectedFontPairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
const fontHeading = selectedFontPairing?.cssHeading || 'inherit';
const fontBody = selectedFontPairing?.cssBody || 'inherit';
return (
<Box minH="100vh" bg="gray.50" display="flex" alignItems="center" justifyContent="center" px={8} py={8} fontFamily={fontBody}>
<Box as="form" onSubmit={handleSubmit} w="100%" maxW="3xl" p={8} bg={bg} borderRadius="xl" boxShadow="lg" borderWidth="1px" borderColor={borderCol} fontFamily={fontBody}>
<VStack spacing={3} mb={6} align="stretch">
<Heading size="xl" fontFamily={fontHeading}>🚀 Vítejte v nastavení vašeho webu!</Heading>
<Text fontSize="md" color="gray.600">
Nastavte základní informace o vašem klubu. Můžete vše vyplnit nyní, nebo některé údaje doplnit později v administraci.
</Text>
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box>
<Text fontSize="sm" fontWeight="medium">💡 Tip: Vyhledejte váš klub v databázi FAČR</Text>
<Text fontSize="sm">Logo, barvy a základní údaje se doplní automaticky.</Text>
</Box>
</Alert>
</VStack>
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
<Box>
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}>🔐 Administrátorský účet</Heading>
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>Email administrátora</FormLabel>
<Input type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} placeholder="admin@example.com" />
</FormControl>
<FormControl isRequired>
<FormLabel>Heslo administrátora</FormLabel>
<InputGroup>
<Input
type={showAdminPassword ? 'text' : 'password'}
value={adminPassword}
onChange={(e) => setAdminPassword(sanitizePassword(e.target.value))}
placeholder="Minimálně 8 znaků, bez mezer"
minLength={8}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => setShowAdminPassword((s) => !s)}>{showAdminPassword ? 'Skrýt' : 'Zobrazit'}</Button>
</InputRightElement>
</InputGroup>
<FormHelperText>Bez mezer. 8128 znaků. Použijte písmena, číslice a speciální znaky.</FormHelperText>
<HStack mt={2} spacing={2}>
<Button size="sm" variant="outline" onClick={() => setAdminPassword(generateStrongPassword())}>Vygenerovat silné heslo</Button>
<Button size="sm" variant="ghost" onClick={() => setAdminPassword('')}>Vymazat</Button>
</HStack>
</FormControl>
<FormControl>
<FormLabel>Jméno</FormLabel>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Příjmení</FormLabel>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</FormControl>
</VStack>
</Box>
<Box>
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}> Informace o klubu</Heading>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Hledat klub (FAČR)</FormLabel>
<InputGroup>
<Input value={clubQuery} onChange={(e) => setClubQuery(e.target.value)} placeholder="Hledejte podle názvu klubu" />
<InputRightElement>
{searchLoading ? <Spinner size="sm" /> : null}
</InputRightElement>
</InputGroup>
{clubQuery && searchResults?.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" maxH="240px" overflowY="auto">
<List spacing={0}>
{searchResults.filter((r) => r.name && r.name.trim() !== '').slice(0, 8).map((r) => (
<ListItem
key={`${r.club_type}-${r.club_id}`}
px={3} py={2} _hover={{ bg: 'gray.50', cursor: 'pointer' }}
onClick={() => handleSelectClub(r)}
>
<HStack spacing={3}>
{r.logo_url ? <Image src={resolveLogoUrl(r.logo_url)} alt={r.name} boxSize="24px" objectFit="contain" /> : null}
<Box>
<Text fontWeight="medium">{r.name}</Text>
<Text fontSize="sm" color="gray.500">{r.club_type}</Text>
</Box>
</HStack>
</ListItem>
))}
</List>
</Box>
)}
</FormControl>
<FormControl>
<FormLabel>Typ klubu</FormLabel>
<Input value={clubType} onChange={(e) => setClubType(e.target.value)} placeholder="football nebo futsal" />
</FormControl>
<FormControl>
<FormLabel>ID klubu</FormLabel>
<Input value={clubId} onChange={(e) => setClubId(e.target.value)} placeholder="FAČR ID klubu" />
</FormControl>
<FormControl>
<FormLabel>Název klubu</FormLabel>
<Input value={clubName} onChange={(e) => setClubName(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>URL loga klubu</FormLabel>
<Input value={clubLogoUrl} onChange={(e) => setClubLogoUrl(e.target.value)} />
<HStack mt={2} spacing={3}>
<Button as="label" size="sm" leftIcon={<InfoOutlineIcon />}>
Nahrát logo
<Input
type="file"
display="none"
accept="image/*,image/svg+xml,application/pdf"
onChange={(e) => {
const f = e.target.files?.[0] || null;
// Process selected file
onLogoFile(f);
// Important: reset input value so selecting the same file again fires onChange
// This fixes the bug where uploading the same logo twice did nothing
try { (e.target as HTMLInputElement).value = ''; } catch {}
}}
/>
</Button>
{uploadingLogo ? <Text fontSize="sm">Nahrávám</Text> : null}
</HStack>
</FormControl>
<FormControl>
<FormLabel>URL klubu</FormLabel>
<Input value={clubUrl} onChange={(e) => setClubUrl(e.target.value)} />
</FormControl>
<Box>
<Text mb={2}>Náhled loga</Text>
<Image src={resolveLogoUrl(clubLogoUrl) || (assetUrl('/dist/img/logo-club-empty.svg') as string)} alt="Logo preview" maxH="80px" objectFit="contain" />
</Box>
</VStack>
</Box>
</SimpleGrid>
{/* removed overall style preview per request */}
<Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>🎨 Barvy a vzhled webu</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Automaticky z loga (lze upravit). Vyberte jednu z předloh nebo barvy ručně dolaďte.</Text>
{/* Preset selector */}
{themePresets.length > 0 && (
<Box mb={4}>
<Text fontWeight="semibold" mb={2}>Předlohy z loga</Text>
<SimpleGrid columns={{ base: 1, md: 5 }} spacing={3}>
{themePresets.map((p, i) => (
<Box key={p.name + i} borderWidth={selectedPreset === i ? '2px' : '1px'} borderColor={selectedPreset === i ? 'blue.400' : 'gray.200'} borderRadius="md" p={3} cursor="pointer" onClick={() => applyPreset(i)}>
<Box mb={2} display="flex" gap="6px" alignItems="center">
<Box flex="1" h="48px" borderRadius="6px" bg={p.primary} display="flex" alignItems="center" justifyContent="center" color={p.text}>{/* primary */}</Box>
<Box w="48px" h="48px" borderRadius="6px" bg={p.secondary} />
<Box w="48px" h="48px" borderRadius="6px" bg={p.accent} />
</Box>
<Text fontSize="sm" fontWeight="semibold">{p.name}</Text>
<Text fontSize="xs" color="gray.500">Primární / Sekundární / Akcent</Text>
</Box>
))}
</SimpleGrid>
<Button mt={3} variant="ghost" onClick={regenerateFromLogo}>Znovu z loga</Button>
</Box>
)}
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
<FormControl>
<FormLabel>Primární
<Tooltip label="Hlavní barva značky (tlačítka, odkazy, zvýraznění)." hasArrow><InfoOutlineIcon ml={2} /></Tooltip>
</FormLabel>
<Input type="color" value={primaryColor} onChange={(e) => startTransition(() => setPrimaryColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Používá se na hlavních prvcích.</Text>
</FormControl>
<FormControl>
<FormLabel>Sekundární
<Tooltip label="Doplňková barva pro méně důležité prvky a plochy." hasArrow><InfoOutlineIcon ml={2} /></Tooltip>
</FormLabel>
<Input type="color" value={secondaryColor} onChange={(e) => startTransition(() => setSecondaryColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Podpůrné zvýraznění.</Text>
</FormControl>
<FormControl>
<FormLabel>Akcent
<Tooltip label="Kontrastní barva pro odznaky, štítky a malé akce." hasArrow><InfoOutlineIcon ml={2} /></Tooltip>
</FormLabel>
<Input type="color" value={accentColor} onChange={(e) => startTransition(() => setAccentColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Menší prvky a upozornění.</Text>
</FormControl>
<FormControl>
<FormLabel>Pozadí</FormLabel>
<Input type="color" value={backgroundColor} onChange={(e) => startTransition(() => setBackgroundColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Barva ploch stránky.</Text>
</FormControl>
<FormControl>
<FormLabel>Text</FormLabel>
<Input type="color" value={textColor} onChange={(e) => startTransition(() => setTextColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Základní barva textu.</Text>
</FormControl>
</SimpleGrid>
{/* Contrast warnings */}
{!isBgTextAccessible && (
<Alert status="warning" mt={4} borderRadius="md">
<AlertIcon />
Slabý kontrast textu vůči pozadí (poměr {bgTextContrast?.toFixed(2)}). Pro čitelnost upravte barvy nebo
<Button variant="link" colorScheme="blue" ml={2} onClick={autofixTextForBg}>automaticky opravit barvu textu</Button>.
</Alert>
)}
{primaryTextContrast !== null && primaryTextContrast < 4.5 && (
<Alert status="warning" mt={3} borderRadius="md">
<AlertIcon />
Text na primární barvě nízký kontrast (poměr {primaryTextContrast.toFixed(2)}). Zvažte jinou barvu textu nebo primární barvy.
</Alert>
)}
<Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📱 Sociální sítě a fotogalerie</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Zadejte odkazy na profily klubu a volitelně na fotogalerii. Lze později upravit v administraci.</Text>
<SimpleGrid columns={[1, 1, 2]} spacing={6} mb={2}>
<FormControl>
<FormLabel>Facebook URL</FormLabel>
<Input placeholder="https://www.facebook.com/vas.klub" value={facebookUrl} onChange={(e) => setFacebookUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Instagram URL</FormLabel>
<Input placeholder="https://www.instagram.com/vas.klub" value={instagramUrl} onChange={(e) => setInstagramUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>YouTube URL</FormLabel>
<Input placeholder="https://www.youtube.com/@vas_klub" value={youtubeUrl} onChange={(e) => setYoutubeUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>URL fotogalerie</FormLabel>
<Input placeholder="https://photos.example.com/club" value={galleryUrl} onChange={(e) => setGalleryUrl(e.target.value)} />
<FormHelperText>Můžete použít libovolný web (SmugMug, Flickr, Google Photos, Zonerama...).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Popisek odkazu fotogalerie</FormLabel>
<Input placeholder="Fotogalerie" value={galleryLabel} onChange={(e) => setGalleryLabel(e.target.value)} />
</FormControl>
</SimpleGrid>
<Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}> Písmo a typografie</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Vyberte vzhled písma pro váš web. Náhled se aplikuje okamžitě na celou stránku.</Text>
<Box mb={4}>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
{FONT_PAIRINGS.map((font) => (
<Box
key={font.id}
borderWidth={selectedFont === font.id ? '2px' : '1px'}
borderColor={selectedFont === font.id ? 'blue.400' : 'gray.200'}
borderRadius="md"
p={3}
cursor="pointer"
onClick={() => setSelectedFont(font.id)}
>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text fontSize="sm" fontWeight="medium">{font.name}</Text>
<Badge colorScheme={getFontStyleColor(font.style)}>{font.style}</Badge>
</HStack>
<Text fontFamily={font.cssHeading} fontSize="lg" fontWeight="bold">Nadpis</Text>
<Text fontFamily={font.cssBody} fontSize="sm">Text běžného odstavce</Text>
</VStack>
</Box>
))}
</SimpleGrid>
<Text fontSize="xs" color="gray.500" mt={2}>Zobrazeno {FONT_PAIRINGS.length} dostupných stylů písma.</Text>
</Box>
<Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📍 GPS poloha a mapa</Heading>
<Text fontSize="sm" mb={4} color="gray.600">Nastavte polohu vašeho stadionu. Můžete vložit odkaz z mapy, nebo zadat souřadnice ručně. Vyberte také styl mapy.</Text>
<Box mb={4}>
<MapLinkImporter
currentLatitude={typeof gpsLat === 'number' ? gpsLat : undefined}
currentLongitude={typeof gpsLng === 'number' ? gpsLng : undefined}
currentZoom={15}
mapStyle={mapStyle}
onMapStyleChange={setMapStyle}
clubPrimaryColor={primaryColor}
clubSecondaryColor={accentColor}
clubName={clubName || 'Váš klub'}
onImport={(coords: MapCoordinates) => {
setGpsLat(coords.latitude);
setGpsLng(coords.longitude);
// Auto-fill address fields if available from geocoding
if (coords.street) setContactStreet(coords.street);
if (coords.city) setContactCity(coords.city);
if (coords.zip) setContactPostalCode(coords.zip);
if (coords.country) setContactCountry(coords.country);
toast({
title: 'Poloha importována',
description: coords.city ? `GPS souřadnice a adresa (${coords.city}) načteny` : 'GPS souřadnice načteny',
status: 'success',
duration: 3000,
});
}}
/>
</Box>
<Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📧 Kontaktní údaje</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Tyto údaje se automaticky vyplní při importu z mapy. Můžete je upravit nebo doplnit ručně.</Text>
<SimpleGrid columns={[1, 1, 2]} spacing={4} mb={4}>
<FormControl>
<FormLabel>Adresa (ulice a číslo)</FormLabel>
<Input placeholder="Hlavní 123" value={contactStreet} onChange={(e) => setContactStreet(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Město</FormLabel>
<Input placeholder="Krnov" value={contactCity} onChange={(e) => setContactCity(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>PSČ</FormLabel>
<Input placeholder="794 01" value={contactPostalCode} onChange={(e) => setContactPostalCode(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Země</FormLabel>
<Input placeholder="Česká republika" value={contactCountry} onChange={(e) => setContactCountry(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Telefon</FormLabel>
<Input type="tel" placeholder="+420 123 456 789" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
<FormHelperText>Hlavní kontaktní telefon klubu</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>E-mail</FormLabel>
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
</FormControl>
</SimpleGrid>
<Divider my={6} />
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}>🔒 Zabezpečení a SMTP</Heading>
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
<FormControl>
<FormLabel>JWT tajemství</FormLabel>
<Input value={jwtSecret} onChange={(e) => setJwtSecret(e.target.value)} placeholder="Ponechte prázdné pro stávající hodnotu" />
<Button mt={2} size="sm" onClick={() => setJwtSecret(generateJwtSecret())}>Vygenerovat bezpečné tajemství</Button>
</FormControl>
<Box>
<FormControl mb={3}>
<FormLabel>SMTP hostitel</FormLabel>
<Input value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} placeholder="smtp.example.com" />
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP port</FormLabel>
<Input type="number" value={smtpPort} onChange={(e) => setSmtpPort(e.target.value ? Number(e.target.value) : '')} placeholder="587" />
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP uživatelské jméno</FormLabel>
<Input value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} />
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP heslo</FormLabel>
<InputGroup>
<Input type={showSmtpPass ? 'text' : 'password'} value={smtpPass} onChange={(e) => setSmtpPass(e.target.value)} />
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => setShowSmtpPass((s) => !s)}>{showSmtpPass ? 'Skrýt' : 'Zobrazit'}</Button>
</InputRightElement>
</InputGroup>
</FormControl>
<FormControl mb={3}>
<FormLabel>Jméno odesílatele</FormLabel>
<Input value={smtpFromName} onChange={(e) => setSmtpFromName(e.target.value)} placeholder="Název klubu (např. Fotbal Club)" />
<FormHelperText>Jako adresu použijeme váš SMTP username (email). Zde vyplňte pouze zobrazované jméno.</FormHelperText>
</FormControl>
<Checkbox isChecked={smtpTLS} onChange={(e) => setSmtpTLS(e.target.checked)}>Použít TLS</Checkbox>
<HStack mt={3} spacing={3}>
<Button
size="sm"
onClick={async () => {
const host = smtpHost.trim();
const port = typeof smtpPort === 'number' ? smtpPort : 0;
if (!host || !port) {
toast({ title: 'Zadejte SMTP host a port', status: 'warning' });
return;
}
setTestingSMTP(true);
try {
// Compose From as "Name <username>" for validation, if possible
const composedFrom = smtpFromName && smtpUser ? `${smtpFromName} <${smtpUser}>` : (smtpFromName || undefined);
const res = await validateSMTP({
host,
port,
username: smtpUser || undefined,
password: smtpPass || undefined,
from: composedFrom,
use_tls: smtpTLS,
});
if (res.ok) {
toast({ title: 'SMTP ověřeno', description: 'Připojení a případná autentizace proběhly v pořádku.', status: 'success' });
} else {
toast({ title: 'SMTP selhalo', description: res.error || 'Zkontrolujte prosím údaje (host, port, šifrování, jméno/heslo).', status: 'error', duration: 7000 });
}
} catch (e: any) {
const msg = e?.response?.data?.error || e?.message || 'Neznámá chyba';
toast({ title: 'SMTP selhalo', description: msg, status: 'error', duration: 7000 });
} finally {
setTestingSMTP(false);
}
}}
isLoading={testingSMTP}
>Otestovat SMTP</Button>
<Tooltip label="Pro port 465 použijte implicitní SSL (ponechte zaškrtnuté). Pro 587 obvykle STARTTLS (také zaškrtnout).">
<InfoOutlineIcon />
</Tooltip>
</HStack>
<Alert status="info" mt={4} borderRadius="md">
<AlertIcon />
<Box>
<Text fontWeight="medium">Před dokončením nastavení otestujte SMTP připojení.</Text>
<Text fontSize="sm" mt={1}>Nejčastější chyba: nesprávné heslo. Ujistěte se, že heslo je zkopírováno přesně bez mezer.</Text>
</Box>
</Alert>
</Box>
</SimpleGrid>
<Button type="submit" colorScheme="blue" mt={8} isLoading={submitting} loadingText="Ukládám…">Dokončit nastavení</Button>
</Box>
</Box>
);
};
// Lightweight preview of overall site style during setup
const SetupStylePreview: React.FC<{ styleKey: 'unified' | 'magazine' | 'pro' | 'edge' }> = ({ styleKey }) => {
// Try to render a screenshot if present in /dist/img
const [imgError, setImgError] = useState(false);
const fileMap: Record<string, string> = {
unified: 'style-unified.png',
magazine: 'style-magazine.png',
pro: 'style-pro.png',
edge: 'style-edge.png',
};
const imgSrc = assetUrl(`/dist/img/${fileMap[styleKey] || fileMap.unified}`);
if (!imgError && imgSrc) {
return (
<Box borderWidth="1px" borderRadius="md" overflow="hidden">
<Image src={imgSrc} alt={`Náhled stylu: ${styleKey}`} onError={() => setImgError(true)} width="100%" objectFit="cover" />
</Box>
);
}
if (styleKey === 'magazine') {
return (
<Box className="magazine" borderRadius="md" overflow="hidden" borderWidth="1px" borderColor="gray.200">
<Box className="mag-bars" />
<Box p={3}>
<HStack justify="space-between">
<Text fontWeight="bold">Magazínová hlavička</Text>
<Text fontSize="sm" color="gray.500">2 barvy klubu</Text>
</HStack>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} mt={3}>
<Box h="100px" bg="gray.100" borderRadius="md" />
<Box h="48px" bg="gray.100" borderRadius="md" />
<Box h="48px" bg="gray.100" borderRadius="md" />
</SimpleGrid>
</Box>
</Box>
);
}
if (styleKey === 'pro') {
return (
<Box className="pro" borderRadius="md" overflow="hidden" borderWidth="1px" borderColor="gray.200">
<Box className="pro-hero" height="120px" position="relative">
<Box position="absolute" inset={0} bg="gray.300" />
<Box position="absolute" inset={0} display="flex" alignItems="center" justifyContent="center">
<Text fontWeight="bold" color="white">Fullscreen Hero (náhled)</Text>
</Box>
</Box>
<Box p={3}>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
<Box borderWidth="1px" borderRadius="md" p={3}>Zápasy</Box>
<Box borderWidth="1px" borderRadius="md" p={3}>Aktuality</Box>
<Box borderWidth="1px" borderRadius="md" p={3}>Vstupenka</Box>
</SimpleGrid>
</Box>
</Box>
);
}
if (styleKey === 'edge') {
return (
<Box borderRadius="md" overflow="hidden" borderWidth="1px" borderColor="gray.200" p={3}>
<Text fontWeight="bold" mb={2}>Edge: Fullwidth minimal</Text>
<Box h="80px" bg="gray.200" borderRadius="md" mb={2} />
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
<Box h="48px" bg="gray.100" borderRadius="md" />
<Box h="48px" bg="gray.100" borderRadius="md" />
</SimpleGrid>
</Box>
);
}
return (
<Box borderWidth="1px" borderRadius="md" p={3}>
<Text fontSize="sm" color="gray.600">Stávající styl (Unified): Grid hero, sekce Nejbližší zápas, tři sloupce a sponzoři.</Text>
</Box>
);
};
export default SetupPage;