This commit is contained in:
Tomas Dvorak
2026-03-13 19:11:12 +01:00
parent e08858ba48
commit 323cc5fca6
97 changed files with 1402 additions and 7091 deletions
+28 -4
View File
@@ -1,9 +1,33 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { HelmetProvider } from 'react-helmet-async';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
vi.mock('./hooks/useUmami', () => ({
useUmami: () => ({ isEnabled: false, isLoaded: false, trackEvent: vi.fn() }),
}));
vi.mock('./hooks/usePublicSettings', () => ({
usePublicSettings: () => ({ data: null }),
}));
vi.mock('./utils/auth', () => ({
isAuthenticated: () => false,
checkAdminExists: async () => false,
getToken: () => null,
clearToken: vi.fn(),
setToken: vi.fn(),
}));
test('renders the app shell', () => {
window.history.pushState({}, 'Test 404', '/test-not-found');
render(
<HelmetProvider>
<App />
</HelmetProvider>
);
expect(screen.getByText(/stránka nenalezena/i)).toBeInTheDocument();
});
+9
View File
@@ -13,6 +13,7 @@ interface UseFacrApiReturn {
searchResults: SearchResponse['results'] | [];
searchLoading: boolean;
searchError: Error | null;
clearSearchResults: () => void;
// Get club details by ID and type
getClub: (clubId: string, clubType?: 'football' | 'futsal') => Promise<ClubInfo>;
@@ -64,6 +65,7 @@ export const useFacrApi = (): UseFacrApiReturn => {
async (query: string): Promise<SearchResponse> => {
setSearchLoading(true);
setSearchError(null);
setSearchResults([]);
try {
const response = await handleApiCall(() => facrApi.searchClubs(query));
setSearchResults(response.results || []);
@@ -107,11 +109,18 @@ export const useFacrApi = (): UseFacrApiReturn => {
facrApi.clearCache();
}, []);
const clearSearchResults = useCallback(() => {
setSearchResults([]);
setSearchError(null);
setSearchLoading(false);
}, []);
return {
searchClubs,
searchResults,
searchLoading,
searchError,
clearSearchResults,
getClub,
getClubTable,
getClubCompetitions,
+24 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, startTransition } from 'react';
import { useEffect, useState, useMemo, useRef, 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';
@@ -62,9 +62,11 @@ const SetupPage: React.FC = () => {
const [clubUrl, setClubUrl] = useState('');
const [clubLink, setClubLink] = useState('');
const [clubQuery, setClubQuery] = useState('');
const [selectedClubSearchLabel, setSelectedClubSearchLabel] = useState('');
const { data: publicSettings } = usePublicSettings();
const isManualClubDataMode = (publicSettings?.club_data_mode || '').toLowerCase() === 'manual';
const { searchClubs, searchResults, searchLoading } = useFacrApi();
const { searchClubs, searchResults, searchLoading, clearSearchResults } = useFacrApi();
const suppressNextClubSearchRef = useRef(false);
const resolveLogoUrl = (u?: string | null) => {
if (!u) return undefined;
@@ -216,11 +218,16 @@ const SetupPage: React.FC = () => {
useEffect(() => {
const q = clubQuery.trim();
if (!q) return;
if (selectedClubSearchLabel.trim() && q === selectedClubSearchLabel.trim()) return;
if (suppressNextClubSearchRef.current) {
suppressNextClubSearchRef.current = false;
return;
}
const t = setTimeout(() => {
searchClubs(q).catch(() => {});
}, 300);
return () => clearTimeout(t);
}, [clubQuery, searchClubs]);
}, [clubQuery, searchClubs, selectedClubSearchLabel]);
useEffect(() => {
if (isDomainHost && !showAdvancedApi) {
@@ -332,12 +339,15 @@ const SetupPage: React.FC = () => {
};
const handleSelectClub = async (item: SearchResult) => {
suppressNextClubSearchRef.current = true;
setSelectedClubSearchLabel(item.name || '');
clearSearchResults();
const clubIdValue = item.club_id || '';
setClubId(clubIdValue);
setClubType(item.club_type || 'football');
setClubName(item.name || '');
setClubUrl(item.url || '');
setClubQuery(item.name || '');
setClubQuery('');
// Try to fetch both logo and club name from logoapi first
let logoUrl = '';
@@ -827,12 +837,20 @@ const SetupPage: React.FC = () => {
<FormControl>
<FormLabel>Hledat klub (FAČR)</FormLabel>
<InputGroup>
<Input value={clubQuery} onChange={(e) => setClubQuery(e.target.value)} placeholder="Hledejte podle názvu klubu" />
<Input
value={clubQuery}
onChange={(e) => {
suppressNextClubSearchRef.current = false;
setSelectedClubSearchLabel('');
setClubQuery(e.target.value);
}}
placeholder="Hledejte podle názvu klubu"
/>
<InputRightElement>
{searchLoading ? <Spinner size="sm" /> : null}
</InputRightElement>
</InputGroup>
{clubQuery && searchResults?.length > 0 && (
{clubQuery.trim() && clubQuery.trim() !== selectedClubSearchLabel && 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) => (
@@ -47,7 +47,7 @@ import {
Collapse,
Icon,
} from '@chakra-ui/react';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import AdminLayout from '../../layouts/AdminLayout';
import {
AddIcon,
+7 -14
View File
@@ -2,24 +2,17 @@ import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from
import { reportError } from './errorReporter';
import { getToken } from '../utils/auth';
import { logAction } from './actionLog';
import { resolveApiBaseUrl } from '../utils/apiBaseUrl';
function readStored(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
const storedApi = typeof window !== 'undefined' ? (readStored('fc_api_base_url') || readStored('api_base_url')) : null;
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL;
let API_URL = storedApi || envApiUrl || '/api/v1';
try {
const maybe = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
if (!/\/api\//.test(maybe.pathname)) {
maybe.pathname = maybe.pathname.replace(/\/$/, '') + '/api/v1';
API_URL = maybe.toString();
} else {
API_URL = maybe.toString();
}
} catch {}
const API_URL = resolveApiBaseUrl();
export const api: AxiosInstance = axios.create({
baseURL: API_URL,
+33
View File
@@ -0,0 +1,33 @@
function readStoredApiBase(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function normalizeApiBaseUrl(input?: string | null): string {
const fallbackOrigin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const candidate = (input || '').trim() || '/api/v1';
try {
const resolved = new URL(candidate, fallbackOrigin);
if (!/\/api\//.test(resolved.pathname)) {
resolved.pathname = resolved.pathname.replace(/\/$/, '') + '/api/v1';
}
return resolved.toString();
} catch {
const trimmed = candidate.replace(/\/$/, '');
return /\/api\//.test(trimmed) ? trimmed : `${trimmed}/api/v1`;
}
}
export function resolveApiBaseUrl(): string {
const storedApi =
typeof window !== 'undefined'
? readStoredApiBase('fc_api_base_url') || readStoredApiBase('api_base_url')
: null;
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL;
return normalizeApiBaseUrl(storedApi || envApiUrl || '/api/v1');
}
+4 -3
View File
@@ -1,5 +1,7 @@
// Use a single canonical key for the auth token across the app
// Many modules expect 'auth_token' (see usages in services and build artifacts).
import { resolveApiBaseUrl } from './apiBaseUrl';
const TOKEN_KEY = 'auth_token';
const HAS_ADMIN_KEY = 'fotbal_club_has_admin';
@@ -43,9 +45,8 @@ export const setHasAdmin = (value: boolean): void => {
export const checkAdminExists = async (): Promise<boolean> => {
try {
// Use shared API base URL which is normalized to '/api/v1'
const { API_URL } = await import('../services/api');
const response = await fetch(`${API_URL}/auth/admin/exists`, {
const apiUrl = resolveApiBaseUrl();
const response = await fetch(`${apiUrl}/auth/admin/exists`, {
headers: { 'Accept': 'application/json' },
});
if (response.ok) {
+1 -1
View File
@@ -78,7 +78,7 @@ export function parseGoogleMapsUrl(url: string): MapCoordinates | null {
}
// Try to extract from pathname (/@lat,lng,zoom format)
const pathMatch = urlObj.pathname.match(/@(-?\d+\.\d+),(-?\d+\.\d+),(\d+)z/);
const pathMatch = urlObj.pathname.match(/@(-?\d+\.\d+),(-?\d+\.\d+),(\d+)(?:[mz])/);
if (pathMatch) {
const latitude = parseFloat(pathMatch[1]);
const longitude = parseFloat(pathMatch[2]);
+68 -11
View File
@@ -253,6 +253,29 @@ const recordCircuitBreakerSuccess = (): void => {
}
};
const LOGO_API_HOSTS = new Set([
'logoapi.sportcreative.eu',
'www.logoapi.sportcreative.eu',
]);
const normalizeLogoApiUrl = (raw?: string | null): string | null => {
const value = String(raw || '').trim();
if (!value) return null;
try {
const fallbackOrigin = typeof window !== 'undefined'
? window.location.origin
: 'https://logoapi.sportcreative.eu';
const parsed = new URL(value, fallbackOrigin);
if (LOGO_API_HOSTS.has(parsed.hostname)) {
parsed.protocol = 'https:';
}
return parsed.toString();
} catch {
return value;
}
};
/**
* Fetch logo from logoapi.sportcreative.eu with optimization and circuit breaker
*/
@@ -267,8 +290,16 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
// Check cache first
const cached = await getCachedLogo(teamId);
if (cached?.url && !cached.url.startsWith('blob:')) {
const normalizedCachedUrl = normalizeLogoApiUrl(cached.url);
await updateLastUsed(teamId);
return cached.url;
if (normalizedCachedUrl && normalizedCachedUrl !== cached.url) {
await saveCachedLogo({
...cached,
url: normalizedCachedUrl,
lastUsed: Date.now(),
});
}
return normalizedCachedUrl;
}
// Fetch from logoapi with timeout
@@ -289,7 +320,7 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
}
const data = await res.json();
const url = data.logo_url_svg || data.logo_url_png || data.logo_url;
const url = normalizeLogoApiUrl(data.logo_url_svg || data.logo_url_png || data.logo_url);
if (!url) {
recordCircuitBreakerFailure();
@@ -319,19 +350,38 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
*/
export const fetchClubNameAndLogoFromAPI = async (teamId: string): Promise<{ clubName?: string; logoUrl?: string } | null> => {
try {
const cached = await getCachedLogo(teamId);
const cachedLogoUrl = cached?.url && !cached.url.startsWith('blob:')
? normalizeLogoApiUrl(cached.url)
: null;
// Fetch from logoapi
const res = await fetch(`https://logoapi.sportcreative.eu/logos/${teamId}/json`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
let res: Response;
try {
res = await fetch(`https://logoapi.sportcreative.eu/logos/${teamId}/json`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
if (!res.ok) return null;
if (!res.ok) {
recordCircuitBreakerFailure();
return cachedLogoUrl ? { logoUrl: cachedLogoUrl } : null;
}
const data = await res.json();
const logoUrl = data.logo_url_svg || data.logo_url_png || data.logo_url;
const logoUrl = normalizeLogoApiUrl(data.logo_url_svg || data.logo_url_png || data.logo_url);
const clubName = data.club_name;
if (!logoUrl && !clubName) return null;
if (!logoUrl && !clubName) {
recordCircuitBreakerFailure();
return cachedLogoUrl ? { logoUrl: cachedLogoUrl } : null;
}
// Cache the logo if available
if (logoUrl) {
@@ -343,14 +393,21 @@ export const fetchClubNameAndLogoFromAPI = async (teamId: string): Promise<{ clu
lastUsed: Date.now(),
});
}
recordCircuitBreakerSuccess();
return {
clubName: clubName || undefined,
logoUrl: logoUrl || undefined,
logoUrl: logoUrl || cachedLogoUrl || undefined,
};
} catch (e) {
recordCircuitBreakerFailure();
console.warn(`Failed to fetch club info for team ${teamId}:`, e);
return null;
const cached = await getCachedLogo(teamId).catch(() => null);
const cachedLogoUrl = cached?.url && !cached.url.startsWith('blob:')
? normalizeLogoApiUrl(cached.url)
: null;
return cachedLogoUrl ? { logoUrl: cachedLogoUrl } : null;
}
};