mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
enhance
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user