This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
@@ -0,0 +1,93 @@
import { api } from '../api';
export interface ContactMessage {
id: string;
name: string;
email: string;
subject?: string;
message: string;
source?: string;
ipAddress?: string;
userAgent?: string;
isRead: boolean;
createdAt: string;
updatedAt: string;
}
export interface ContactMessagesResponse {
data: ContactMessage[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export const getContactMessages = async (params?: {
page?: number;
limit?: number;
search?: string;
isRead?: boolean;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}): Promise<ContactMessagesResponse> => {
// Backend returns { data: ContactMessage[], pagination: { total, page, limit, pages, has_more } }
const response = await api.get<{ data: ContactMessage[]; pagination: { total: number; page: number; limit: number; pages: number } }>(
'/admin/contact-messages',
{ params }
);
const r = response.data;
// Normalize possible snake_case fields coming from backend to camelCase expected by UI
const normalized = (r.data as any[]).map((m) => ({
id: String(m.id),
name: m.name,
email: m.email,
subject: m.subject,
message: m.message,
source: m.source,
ipAddress: m.ipAddress ?? m.ip_address,
userAgent: m.userAgent ?? m.user_agent,
isRead: m.isRead ?? m.is_read ?? false,
createdAt: m.createdAt ?? m.created_at,
updatedAt: m.updatedAt ?? m.updated_at,
})) as ContactMessage[];
return {
data: normalized,
total: r.pagination.total,
page: r.pagination.page,
limit: r.pagination.limit,
totalPages: r.pagination.pages,
};
};
export const getContactMessage = async (id: string) => {
// Backend returns the message object directly
const response = await api.get<ContactMessage>(`/admin/contact-messages/${id}`);
return response.data;
};
export const markAsRead = async (id: string) => {
// Backend returns only a message string; caller doesn't need data
await api.patch(
`/admin/contact-messages/${id}/read`,
{ isRead: true }
);
};
export const deleteMessage = async (id: string) => {
await api.delete(`/admin/contact-messages/${id}`);
};
export const deleteMultipleMessages = async (ids: Array<string | number>) => {
// Backend expects a raw JSON array []int in the body. Convert to numbers.
const numericIds = ids.map((v) => (typeof v === 'string' ? Number(v) : v));
await api.delete('/admin/contact-messages', { data: numericIds });
};
export const forwardMessage = async (id: string, toEmail: string) => {
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 });
return response.data;
};
+165
View File
@@ -0,0 +1,165 @@
import { api } from '../api';
export interface NewsletterSubscriber {
id: number;
email: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface NewsletterSendData {
subject: string;
content: string; // backend accepts either 'content' or 'body'
}
export const getNewsletterSubscribers = async (): Promise<NewsletterSubscriber[]> => {
const response = await api.get<NewsletterSubscriber[]>(`/admin/newsletter/subscribers`);
return response.data;
};
export const sendNewsletter = async (data: NewsletterSendData): Promise<{ message: string }> => {
// Backend will accept either 'content' or 'body'. We send 'content'.
const response = await api.post<{ message: string }>(
`/admin/newsletter/send`,
{
subject: data.subject,
content: data.content,
},
// Increase timeout to 60s to accommodate SMTP and bulk send delays
{ timeout: 60000 }
);
return response.data;
};
export const sendNewsletterTest = async (email?: string): Promise<{ message: string; recipient: string }> => {
const payload = email ? { email } : {};
// Allow more time for SMTP connection during test sends
const response = await api.post<{ message: string; recipient: string }>(
`/admin/newsletter/test`,
payload,
{ timeout: 60000 }
);
return response.data;
};
export type NewsletterTestType = 'newsletter' | 'welcome' | 'welcome_back' | 'blogs' | 'events' | 'matches' | 'scores' | 'weekly';
export interface NewsletterTestPayload {
email?: string;
emails?: string[];
type?: NewsletterTestType;
}
export type AdminSmtpTestPayload = {
host: string;
port: number;
username?: string;
password?: string;
from: string;
to: string;
subject?: string;
body?: string;
use_tls?: boolean;
};
export const adminSendSmtpTest = async (payload: AdminSmtpTestPayload): Promise<{ ok: boolean; message?: string; error?: string }> => {
const res = await api.post('/admin/newsletter/smtp-test', payload, { timeout: 60000 });
return res.data;
};
export const sendNewsletterTestAdvanced = async (payload: NewsletterTestPayload): Promise<{ message: string; recipients?: string[]; recipient?: string; type: string }> => {
// Allow more time for SMTP connection during test sends
const response = await api.post<{ message: string; recipients?: string[]; recipient?: string; type: string }>(
`/admin/newsletter/test`,
payload,
{ timeout: 60000 }
);
return response.data;
};
export interface NewsletterStatus {
total_subscribers: number;
active_subscribers: number;
sample_recipients: string[];
interval_minutes: number;
next_approximate: string;
newsletter_enabled?: boolean;
}
export const getNewsletterStatus = async (): Promise<NewsletterStatus> => {
const response = await api.get<NewsletterStatus>(`/admin/newsletter/status`);
return response.data as any;
};
export type DigestType = 'blogs' | 'events' | 'matches' | 'scores' | 'weekly';
export const sendNewsletterDigest = async (type: DigestType, competitions?: string): Promise<{ message: string; recipients: number; type: string }> => {
const response = await api.post(`/admin/newsletter/send-digest`, { type, competitions });
return response.data;
};
export const setNewsletterAutomation = async (enabled: boolean): Promise<{ newsletter_enabled: boolean }> => {
const response = await api.patch(`/admin/newsletter/enable`, { enabled });
return response.data;
};
export interface NewsletterPreviewPayload {
preferences?: Record<string, boolean> & { competitions?: string };
}
export interface NewsletterPreviewResponse {
subject: string;
html: string;
}
export const previewNewsletter = async (payload: NewsletterPreviewPayload): Promise<NewsletterPreviewResponse> => {
const response = await api.post<NewsletterPreviewResponse>(`/admin/newsletter/preview`, payload);
return response.data;
};
export interface EmailStatRow {
id: number;
created_at: string;
subject: string;
recipient: string;
type: string;
status: string;
opens: number;
clicks: number;
spam: number;
unsubs: number;
}
export const getRecentEmailStats = async (): Promise<EmailStatRow[]> => {
const response = await api.get<{ data: EmailStatRow[] }>(`/admin/newsletter/stats/recent`);
return response.data?.data || [];
};
export interface EmailEventRow {
id: number;
created_at: string;
email_log_id: number;
event_type: string;
meta?: Record<string, any>;
}
export const getEmailEventsForLog = async (id: number): Promise<EmailEventRow[]> => {
const response = await api.get<{ data: EmailEventRow[] }>(`/admin/newsletter/stats/${id}/events`);
return response.data?.data || [];
};
export const deleteSubscriber = async (id: number): Promise<void> => {
await api.delete(`/admin/newsletter/subscribers/${id}`);
};
export const toggleSubscriberStatus = async (id: number, isActive: boolean): Promise<NewsletterSubscriber> => {
const response = await api.patch<NewsletterSubscriber>(
`/admin/newsletter/subscribers/${id}/status`,
{ is_active: isActive }
);
return response.data;
};
export const updateSubscriberPreferences = async (id: number, preferences: Record<string, boolean>) => {
const response = await api.patch<NewsletterSubscriber>(`/admin/newsletter/subscribers/${id}/preferences`, preferences);
return response.data;
};
+18
View File
@@ -0,0 +1,18 @@
import api from '../../services/api';
export type PrefetchStatus = {
lastUpdated?: string;
intervalMinutes: number;
fastMode: boolean;
nextApproximate: string;
};
export async function getPrefetchStatus(): Promise<PrefetchStatus> {
const { data } = await api.get('/admin/prefetch/status');
return data as PrefetchStatus;
}
export async function triggerPrefetch(): Promise<{ message: string }>{
const { data } = await api.post('/admin/prefetch/trigger');
return data as { message: string };
}
+366
View File
@@ -0,0 +1,366 @@
import api, { API_URL } from './api';
export type AdminMatch = Record<string, any> & {
match_id?: string;
id?: string;
home?: string;
away?: string;
venue?: string;
date_time?: string;
home_logo_url?: string;
away_logo_url?: string;
};
export async function fetchAdminMatches(): Promise<AdminMatch[]> {
const res = await api.get('/admin/matches');
return Array.isArray(res.data) ? res.data : res.data?.data ?? [];
}
export type MatchOverrideInput = {
home_name_override?: string | null;
away_name_override?: string | null;
venue_override?: string | null;
date_time_override?: string | null; // ISO string
home_logo_url?: string | null;
away_logo_url?: string | null;
notes?: string | null;
};
export async function putMatchOverride(externalMatchId: string, payload: MatchOverrideInput) {
// Normalize date_time_override to RFC3339 if present
const body: any = { ...payload };
if (typeof body.date_time_override === 'string' && body.date_time_override.trim() !== '') {
const d = new Date(body.date_time_override);
if (!isNaN(d.getTime())) {
body.date_time_override = d.toISOString();
}
}
return (await api.put(`/admin/match-overrides/${encodeURIComponent(externalMatchId)}`, body)).data;
}
export async function patchMatchOverride(externalMatchId: string, payload: Partial<MatchOverrideInput>) {
const body: any = { ...payload };
if (typeof body.date_time_override === 'string' && body.date_time_override.trim() !== '') {
const d = new Date(body.date_time_override);
if (!isNaN(d.getTime())) {
body.date_time_override = d.toISOString();
}
}
return (await api.patch(`/admin/match-overrides/${encodeURIComponent(externalMatchId)}`, body)).data;
}
export async function putTeamLogoOverride(externalTeamId: string, teamName: string, logoUrl: string) {
return (await api.put(`/admin/team-logo-overrides/${encodeURIComponent(externalTeamId)}`, { team_name: teamName, logo_url: logoUrl })).data;
}
interface UploadLogoOptions {
filename?: string;
clubName?: string;
clubType?: 'football' | 'futsal';
}
/**
* Upload logo to logoapi.sportcreative.eu
* This is the primary logo storage for the application
*/
export async function uploadToLogaSportcreative(
clubId: string,
logoFile: File | Blob,
options: UploadLogoOptions = {}
): Promise<{ success: boolean; url?: string; error?: string }> {
try {
const formData = new FormData();
const filename = options.filename || (logoFile instanceof File ? logoFile.name : 'logo.png');
// Required fields
formData.append('file', logoFile, filename);
formData.append('club_name', options.clubName || 'Neznámý klub');
// Optional field - only club_type is needed
if (options.clubType) {
formData.append('club_type', options.clubType);
}
const response = await fetch(`https://logoapi.sportcreative.eu/logos/${clubId}`, {
method: 'POST',
body: formData,
});
const responseData = await response.json().catch(() => ({}));
if (!response.ok) {
return {
success: false,
error: responseData.error || `HTTP ${response.status}: ${response.statusText}`
};
}
// Construct the logo URL from the response
const logoUrl = responseData.logo_url ||
responseData.logo_url_svg ||
responseData.logo_url_png ||
`https://logoapi.sportcreative.eu/logos/${clubId}/logo.svg`;
return {
success: true,
url: logoUrl
};
} catch (error: any) {
console.error('Error uploading logo:', error);
return {
success: false,
error: error?.message || 'Nepodařilo se nahrát logo. Zkuste to prosím znovu.'
};
}
}
/**
* Upload logo to sportlogos.tdvorak.dev external API
* This runs in parallel with the local save to provide logo backup/sharing
*/
export async function uploadToSportLogosAPI(
clubId: string,
clubName: string,
logoFile: File | Blob,
options?: {
club_type?: 'football' | 'futsal';
club_website?: string;
club_city?: string;
}
): Promise<{ success: boolean; error?: string }> {
try {
const formData = new FormData();
formData.append('file', logoFile, logoFile instanceof File ? logoFile.name : 'logo.png');
formData.append('club_name', clubName);
if (options?.club_type) {
formData.append('club_type', options.club_type);
}
if (options?.club_website) {
formData.append('club_website', options.club_website);
}
if (options?.club_city) {
formData.append('club_city', options.club_city);
}
const response = await fetch(`https://sportlogos.tdvorak.dev/logos/${clubId}`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: (errorData as any)?.error || `HTTP ${response.status}: ${response.statusText}`
};
}
const result = await response.json();
return { success: true };
} catch (error: any) {
return {
success: false,
error: error?.message || 'Network error uploading to sportlogos.tdvorak.dev'
};
}
}
/**
* Helper to fetch logo file from URL (for re-uploading already uploaded logos)
*/
export async function fetchLogoAsBlob(logoUrl: string): Promise<Blob | null> {
try {
// Resolve relative URLs against the API origin
let fullUrl = logoUrl;
if (logoUrl.startsWith('/')) {
const apiOrigin = new URL(API_URL).origin;
fullUrl = `${apiOrigin}${logoUrl}`;
}
const response = await fetch(fullUrl);
if (!response.ok) return null;
return await response.blob();
} catch (error) {
console.error('Failed to fetch logo as blob:', error);
return null;
}
}
/**
* Fetch logo metadata from logoapi.sportcreative.eu
* Returns logo URL if available, null if not found
*/
export type SportLogoMetadata = {
id: string;
club_name: string;
club_type?: string;
club_website?: string;
has_svg?: boolean;
has_png?: boolean;
primary_format?: string;
logo_url?: string;
logo_url_svg?: string;
logo_url_png?: string;
file_size_svg?: number;
file_size_png?: number;
created_at?: string;
updated_at?: string;
};
export async function fetchLogoFromSportLogosAPI(clubId: string): Promise<string | null> {
try {
const response = await fetch(`https://logoapi.sportcreative.eu/logos/${clubId}/json`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
return null; // Logo not found or error
}
const data: SportLogoMetadata = await response.json();
// Prefer SVG, fallback to PNG, fallback to generic logo_url
return data.logo_url_svg || data.logo_url_png || data.logo_url || null;
} catch (error) {
console.error('Failed to fetch logo from logoapi.sportcreative.eu:', error);
return null;
}
}
/**
* Fetch logo with priority: logoapi.sportcreative.eu -> local overrides -> FACR original
*/
export async function fetchLogoWithFallback(
clubId: string | null | undefined,
teamName: string,
facrLogoUrl?: string,
localOverrides?: Record<string, string>
): Promise<string> {
const defaultLogo = '/dist/img/logo-club-empty.svg';
// Try logoapi.sportcreative.eu first if we have a club ID
if (clubId) {
const sportLogo = await fetchLogoFromSportLogosAPI(clubId);
if (sportLogo) {
return sportLogo;
}
}
// Try local overrides
if (localOverrides && teamName) {
const overrideLogo = localOverrides[teamName];
if (overrideLogo) {
return overrideLogo;
}
}
// Fallback to FACR original
if (facrLogoUrl) {
return facrLogoUrl;
}
// Final fallback to default empty logo
return defaultLogo;
}
/**
* Batch fetch logos from sportlogos.tdvorak.dev for multiple teams
* Returns a map of team_id -> logo URL
* This is more efficient than fetching one by one
*/
export async function batchFetchLogosFromSportLogosAPI(
clubIds: string[]
): Promise<Record<string, string>> {
const logoMap: Record<string, string> = {};
// Fetch in parallel with a limit to avoid overwhelming the server
const BATCH_SIZE = 10;
for (let i = 0; i < clubIds.length; i += BATCH_SIZE) {
const batch = clubIds.slice(i, i + BATCH_SIZE);
const promises = batch.map(async (clubId) => {
const logo = await fetchLogoFromSportLogosAPI(clubId);
if (logo) {
logoMap[clubId] = logo;
}
});
await Promise.all(promises);
}
return logoMap;
}
export async function uploadImage(file: File): Promise<{ url: string }> {
const fd = new FormData();
fd.append('file', file);
const res = await api.post('/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const data = res.data;
let url = data?.url || '';
try {
const parsed = new URL(url, window.location.origin);
const appOrigin = window.location.origin;
const apiOrigin = new URL(API_URL).origin;
if (parsed.origin === appOrigin || parsed.origin === apiOrigin) {
// store backend-relative path so DB records are consistent
url = parsed.pathname + parsed.search + parsed.hash;
}
} catch (e) {
// ignore and return whatever the backend provided
}
return { ...(data || {}), url };
}
export type ClubSearchResult = { id: string; name: string; logo_url?: string };
export async function searchClubs(query: string): Promise<ClubSearchResult[]> {
if (!query || query.trim().length < 2) return [];
const res = await api.get(`/facr/club/search`, { params: { q: query } });
const raw = res.data?.results ?? res.data ?? [];
// Normalize backend payload (which uses club_id) to { id, name, logo_url }
const mapped = Array.isArray(raw)
? raw.map((r: any) => ({
id: r?.id ?? r?.club_id ?? r?.ClubID ?? '',
name: r?.name ?? r?.Name ?? '',
logo_url: r?.logo_url ?? r?.LogoURL ?? undefined,
}))
: [];
return mapped.filter((x: ClubSearchResult) => x.id && x.name);
}
// Public team-logo overrides map to apply on cache-fed widgets/pages.
// Shape: { by_name: { [teamName: string]: string /* logo URL */ } }
export type TeamLogoOverrides = { by_name?: Record<string, string> };
export async function fetchTeamLogoOverrides(): Promise<TeamLogoOverrides> {
try {
// Try public endpoint first (recommended for widgets)
// Add cache-busting param to avoid 120s public cache when user just saved changes
const res = await api.get('/public/team-logo-overrides', { params: { t: Date.now() } });
return res.data ?? {};
} catch {
// Fallback: try admin (requires auth); if that fails, return empty
try {
const res2 = await api.get('/admin/team-logo-overrides', { params: { t: Date.now() } });
const data = res2.data;
// If admin returns an array of overrides, normalize to { by_name }
if (Array.isArray(data)) {
const byName: Record<string, string> = {};
for (const it of data) {
const name = it?.team_name || it?.TeamName;
const logo = it?.logo_url || it?.LogoURL;
if (name && logo) byName[String(name)] = String(logo);
}
return { by_name: byName };
}
// If already in desired shape, return as-is
if (data && typeof data === 'object' && data.by_name) return data;
return {};
} catch {
return {};
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import api from './api';
export interface AIGenerateBlogReq {
prompt: string;
audience?: string;
min_words?: number;
}
export interface AIGenerateBlogResp {
title: string;
slug: string;
html: string;
}
export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGenerateBlogResp> {
const { data } = await api.post<AIGenerateBlogResp>('/ai/blog/generate', payload);
return data;
}
export interface AIGenerateAboutReq {
prompt: string;
club_name?: string;
style?: string;
audience?: string;
}
export interface AIGenerateAboutResp {
title: string;
subtitle: string;
html: string;
seo_title: string;
seo_description: string;
}
export async function generateAboutAI(payload: AIGenerateAboutReq): Promise<AIGenerateAboutResp> {
const { data } = await api.post<AIGenerateAboutResp>('/ai/about/generate', payload);
return data;
}
+83
View File
@@ -0,0 +1,83 @@
import api from './api';
export interface AnalyticsData {
users: {
total: number;
new_this_week: number;
};
events: {
total: number;
upcoming: number;
};
articles: {
total: number;
published: number;
};
}
export interface AnalyticsOverview {
total_page_views: number;
unique_visitors: number;
total_articles: number;
published_articles: number;
page_views_today: number;
page_views_week: number;
unique_visitors_week: number;
}
export interface PageStats {
page_path: string;
page_name: string;
view_count: number;
unique_visitors: number;
}
export interface TopInteraction {
page: string;
element: string;
count: number;
}
export const getAnalytics = async (): Promise<AnalyticsData> => {
const response = await api.get('/admin/analytics');
return response.data;
};
export const getAnalyticsOverview = async (): Promise<AnalyticsOverview> => {
const response = await api.get('/admin/analytics/overview');
return response.data;
};
export const getTopPages = async (limit: number = 10): Promise<PageStats[]> => {
const response = await api.get('/admin/analytics/top-pages', {
params: { limit }
});
return response.data;
};
export const getTopArticles = async (limit: number = 10): Promise<any[]> => {
const response = await api.get('/admin/analytics/top-articles', {
params: { limit }
});
return response.data;
};
export const getTopInteractions = async (days: number = 30, limit: number = 10): Promise<{ items: TopInteraction[] }> => {
const response = await api.get('/admin/analytics/top-interactions', {
params: { days, limit }
});
return response.data;
};
// Track event - public endpoint
export const trackEvent = async (eventData: {
event_type: string;
page?: string;
page_path?: string;
page_name?: string;
element?: string;
data?: Record<string, any>;
}): Promise<{ ok: boolean }> => {
const response = await api.post('/analytics/track', eventData);
return response.data;
};
+86
View File
@@ -0,0 +1,86 @@
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { getToken } from '../utils/auth';
// Resolve API URL. Some code uses REACT_APP_API_URL (full api path including /api/v1),
// others set REACT_APP_API_BASE_URL (backend origin). Normalize so baseURL always points to API root.
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL;
let API_URL = envApiUrl || 'http://localhost:8080/api/v1';
// If the provided base looks like a backend origin (no /api/), append /api/v1
try {
const maybe = new URL(API_URL);
if (!/\/api\//.test(maybe.pathname)) {
// ensure single trailing slash then append api/v1
maybe.pathname = maybe.pathname.replace(/\/$/, '') + '/api/v1';
API_URL = maybe.toString();
}
} catch {
// If URL parsing fails, keep API_URL as-is
}
export const api: AxiosInstance = axios.create({
baseURL: API_URL,
headers: {
// If admin token provided at build time, include it only in non-production env
...((process.env.NODE_ENV !== 'production' && process.env.REACT_APP_ADMIN_TOKEN)
? { 'X-Admin-Token': process.env.REACT_APP_ADMIN_TOKEN }
: {}),
// Dev bypass header to allow protected calls in non-production (middleware DevBypass)
...((process.env.NODE_ENV !== 'production') ? { 'X-Dev-Admin': 'true' } : {}),
},
// Send cookies for same-site or allowed CORS origins
withCredentials: true,
// Prevent infinite loading spinners if backend is down or unreachable
timeout: 20000, // 20 seconds to better tolerate slower endpoints
});
// Request interceptor - attach bearer token when available
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getToken();
if (token) {
config.headers = config.headers || {};
(config.headers as any).Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor
api.interceptors.response.use(
(response: AxiosResponse) => response,
(error) => {
if (error.response?.status === 401) {
// Avoid redirect loop on the login call itself
const reqUrl: string = error.config?.url || '';
const isLoginEndpoint = reqUrl.endsWith('/auth/login') || reqUrl.includes('/auth/login');
// Do not force redirect for public endpoints like file uploads; let the caller handle it.
const isUploadEndpoint = reqUrl.endsWith('/upload') || reqUrl.includes('/upload');
if (!isLoginEndpoint) {
// Redirect to login unless already there and not an exempt endpoint
if (!isUploadEndpoint) {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
}
}
return Promise.reject(error);
}
);
// Upload image helper
export const uploadImage = async (formData: FormData): Promise<{ url: string }> => {
const res = await api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return res.data;
};
export { API_URL };
export default api;
+211
View File
@@ -0,0 +1,211 @@
import api, { API_URL } from './api';
import { getToken } from '../utils/auth';
const normalizeArticle = (raw: any): Article => {
if (!raw) return raw;
const id = raw.id ?? raw.ID ?? raw.article_id ?? raw.articleId;
const category = raw.category ?? raw.Category;
const author = raw.author ?? raw.Author;
return {
...(raw as Article),
id,
category,
author,
} as Article;
};
export interface Article {
id: number;
title: string;
content: string;
image_url?: string;
author?: { id: number; first_name?: string; last_name?: string; email: string };
category_id?: number;
published?: boolean;
featured?: boolean;
created_at?: string;
slug?: string;
seo_title?: string;
seo_description?: string;
og_image_url?: string;
published_at?: string;
updated_at?: string;
estimated_read_minutes?: number;
read_count?: number;
view_count?: number;
read_time?: number;
gallery_album_id?: string;
gallery_album_url?: string;
gallery_photo_ids?: string[];
youtube_video_id?: string;
youtube_video_title?: string;
youtube_video_url?: string;
youtube_video_thumbnail?: string;
}
// --- Article ⇄ Match link ---
export interface ArticleMatchLink {
article_id: number;
external_match_id?: string;
title?: string;
}
export async function getArticleMatchLink(articleId: number | string): Promise<ArticleMatchLink> {
const res = await api.get<ArticleMatchLink>(`/articles/${articleId}/match-link`);
return res.data;
}
export async function putArticleMatchLink(articleId: number | string, payload: { external_match_id: string; title?: string }): Promise<ArticleMatchLink> {
const res = await api.post<ArticleMatchLink>(`/articles/${articleId}/match-link`, payload);
return res.data;
}
export async function deleteArticleMatchLink(articleId: number | string): Promise<{ ok: boolean }> {
const res = await api.delete<{ ok: boolean }>(`/articles/${articleId}/match-link`);
return res.data;
}
export async function getFeaturedArticles(params: {
page?: number;
page_size?: number;
} = {}) {
const res = await api.get<any>('/articles/featured', { params });
const d = res.data || {};
const itemsRaw: any[] = Array.isArray(d.items)
? d.items
: Array.isArray(d.data)
? d.data
: [];
const items: Article[] = itemsRaw.map((item) => normalizeArticle(item));
const total = typeof d.total === 'number' ? d.total : items.length;
const page = typeof d.page === 'number' ? d.page : (params as any).page || 1;
const page_size = typeof d.page_size === 'number' ? d.page_size : (params as any).page_size || items.length;
return { data: items, total, page, page_size } as Paginated<Article>;
}
export interface Paginated<T> {
data: T[];
page: number;
page_size: number;
total: number;
}
export async function getArticles(params: {
page?: number;
page_size?: number;
category_id?: number;
published?: boolean;
featured?: boolean;
q?: string;
slug?: string;
} = {}) {
// Backend returns shape: { items, total, page, page_size }
// Normalize to { data, total, page, page_size } expected by the frontend.
const res = await api.get<any>('/articles', { params });
const d = res.data || {};
const itemsRaw: any[] = Array.isArray(d.items)
? d.items
: Array.isArray(d.data)
? d.data
: [];
const items: Article[] = itemsRaw.map((item) => normalizeArticle(item));
const total = typeof d.total === 'number' ? d.total : (typeof d.count === 'number' ? d.count : items.length);
const page = typeof d.page === 'number' ? d.page : (params as any).page || 1;
const page_size = typeof d.page_size === 'number' ? d.page_size : (params as any).page_size || items.length;
return { data: items, total, page, page_size } as Paginated<Article>;
}
export async function getArticle(id: number | string) {
const res = await api.get<Article>(`/articles/${id}`);
return normalizeArticle(res.data);
}
export interface CreateArticlePayload {
title: string;
content: string;
category_id?: number;
category_name?: string; // optional: backend will resolve/create category by name
published?: boolean;
image_url?: string;
slug?: string;
seo_title?: string;
seo_description?: string;
og_image_url?: string;
featured?: boolean;
gallery_album_id?: string;
gallery_album_url?: string;
gallery_photo_ids?: string[];
youtube_video_id?: string;
youtube_video_title?: string;
youtube_video_url?: string;
youtube_video_thumbnail?: string;
}
export async function createArticle(payload: CreateArticlePayload) {
const res = await api.post<Article>('/articles', payload);
return normalizeArticle(res.data);
}
export type UpdateArticlePayload = Partial<CreateArticlePayload>;
export async function updateArticle(id: number | string, payload: UpdateArticlePayload) {
const res = await api.put<Article>(`/articles/${id}`, payload);
return normalizeArticle(res.data);
}
export async function deleteArticle(id: number | string) {
const res = await api.delete<{ success: boolean }>(`/articles/${id}`);
return res.data;
}
// Fetch by slug if the backend exposes /articles/slug/:slug.
// Falls back to querying the list endpoint with ?slug= if needed.
export async function getArticleBySlug(slug: string) {
try {
const res = await api.get<Article>(`/articles/slug/${encodeURIComponent(slug)}`);
return res.data;
} catch (e) {
// Fallback: attempt list query through normalized helper and return first match
const list = await getArticles({ slug });
return list.data?.[0];
}
}
export async function uploadFile(file: File) {
const form = new FormData();
form.append('file', file);
// Important: Do NOT set Content-Type manually so Axios can attach the proper multipart boundary.
// The api instance will automatically add Authorization via the interceptor when a token exists.
const res = await api.post<{ url: string; name: string; type: string; size: number }>(
'/upload',
form
);
const data = res.data;
let url = data.url || '';
try {
// Normalize URLs returned by backend: if the returned URL points to the frontend origin (dev server)
// or to the API origin, convert it to a backend-relative path like '/uploads/2025/...'
const parsed = new URL(url, window.location.origin);
const appOrigin = window.location.origin;
const apiOrigin = new URL(API_URL).origin;
if (parsed.origin === appOrigin) {
// Rewrite frontend-origin URLs to API origin, then strip origin to keep a relative path
url = parsed.pathname + parsed.search + parsed.hash;
} else if (parsed.origin === apiOrigin) {
// Keep only the path so stored values are consistent (backend relative path)
url = parsed.pathname + parsed.search + parsed.hash;
}
} catch (e) {
// ignore parsing errors, keep original
}
return { ...data, url };
}
export async function trackArticleView(id: number | string) {
try {
await api.post(`/articles/${id}/track-view`);
} catch (e) {
console.debug('Failed to track article view:', e);
}
}
+32
View File
@@ -0,0 +1,32 @@
import api from './api';
export interface CategoryItem {
id: number;
name: string;
description?: string;
}
export async function getCategories(): Promise<CategoryItem[]> {
const res = await api.get<CategoryItem[] | { items?: CategoryItem[]; data?: CategoryItem[] }>(
'/categories'
);
const d: any = res.data;
if (Array.isArray(d)) return d as CategoryItem[];
if (d && Array.isArray(d.items)) return d.items as CategoryItem[];
if (d && Array.isArray(d.data)) return d.data as CategoryItem[];
return [];
}
export async function createCategory(payload: { name: string; description?: string }): Promise<CategoryItem> {
const res = await api.post<CategoryItem>('/admin/categories', payload);
return res.data;
}
export async function updateCategory(id: number | string, payload: { name?: string; description?: string }): Promise<CategoryItem> {
const res = await api.put<CategoryItem>(`/admin/categories/${id}`, payload);
return res.data;
}
export async function deleteCategory(id: number | string): Promise<void> {
await api.delete(`/admin/categories/${id}`);
}
+65
View File
@@ -0,0 +1,65 @@
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
export interface ClothingItem {
id: number;
title: string;
description?: string;
price?: number;
currency?: string;
image_url: string;
url?: string;
is_active?: boolean;
display_order?: number;
created_at?: string;
updated_at?: string;
}
export interface ClothingResponse {
data: ClothingItem[];
}
// Public endpoint - get all active clothing items
export const getClothing = async (): Promise<ClothingItem[]> => {
const response = await axios.get<ClothingResponse>(`${API_BASE_URL}/clothing`);
return response.data.data;
};
// Admin endpoint - get all clothing items
export const getClothingAdmin = async (): Promise<ClothingItem[]> => {
const response = await axios.get<ClothingResponse>(`${API_BASE_URL}/admin/clothing`, {
withCredentials: true,
});
return response.data.data;
};
// Admin endpoint - create clothing item
export const createClothing = async (data: Partial<ClothingItem>): Promise<ClothingItem> => {
const response = await axios.post<ClothingItem>(`${API_BASE_URL}/admin/clothing`, data, {
withCredentials: true,
});
return response.data;
};
// Admin endpoint - update clothing item
export const updateClothing = async (id: number, data: Partial<ClothingItem>): Promise<ClothingItem> => {
const response = await axios.put<ClothingItem>(`${API_BASE_URL}/admin/clothing/${id}`, data, {
withCredentials: true,
});
return response.data;
};
// Admin endpoint - delete clothing item
export const deleteClothing = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/clothing/${id}`, {
withCredentials: true,
});
};
// Admin endpoint - update display order
export const updateClothingOrder = async (items: Array<{ id: number; display_order: number }>): Promise<void> => {
await axios.post(`${API_BASE_URL}/admin/clothing/reorder`, items, {
withCredentials: true,
});
};
@@ -0,0 +1,50 @@
import api from './api';
export type CompetitionAlias = {
code: string;
alias: string;
original_name?: string;
display_order?: number;
};
// Public: fetch all aliases
export async function getCompetitionAliasesPublic(): Promise<CompetitionAlias[]> {
const res = await api.get<CompetitionAlias[] | { data: CompetitionAlias[] }>(
'/competition-aliases'
);
return Array.isArray(res.data) ? res.data : (res.data as any).data;
}
// Admin: list all aliases
export async function getCompetitionAliasesAdmin(): Promise<CompetitionAlias[]> {
const res = await api.get<CompetitionAlias[] | { data: CompetitionAlias[] }>(
'/admin/competition-aliases'
);
return Array.isArray(res.data) ? res.data : (res.data as any).data;
}
// Admin: upsert alias by code
export async function upsertCompetitionAlias(code: string, payload: { alias: string; original_name?: string; display_order?: number }): Promise<CompetitionAlias> {
const res = await api.put<CompetitionAlias | { data: CompetitionAlias }>(
`/admin/competition-aliases/${encodeURIComponent(code)}`,
payload
);
return (res.data as any).code ? (res.data as any) : (res.data as any).data;
}
// Admin: delete alias by code
export async function deleteCompetitionAlias(code: string): Promise<{ ok: boolean } | void> {
const res = await api.delete<{ ok: boolean } | any>(
`/admin/competition-aliases/${encodeURIComponent(code)}`
);
return (res.data && typeof res.data.ok !== 'undefined') ? res.data : undefined;
}
// Admin: reorder competition aliases
export async function reorderCompetitionAliases(items: Array<{ code: string; display_order: number }>): Promise<{ ok: boolean; updated: number }> {
const res = await api.post<{ ok: boolean; updated: number }>(
'/admin/competition-aliases/reorder',
{ items }
);
return res.data;
}
+24
View File
@@ -0,0 +1,24 @@
import axios from 'axios';
import { API_BASE_URL } from '../config';
export interface ContactFormData {
name: string;
email: string;
subject: string;
message: string;
}
export const submitContactForm = async (data: ContactFormData) => {
const response = await axios.post(`${API_BASE_URL}/api/v1/contact`, data);
return response.data;
};
export const subscribeToNewsletter = async (email: string) => {
const response = await axios.post(`${API_BASE_URL}/api/v1/newsletter/subscribe`, { email });
return response.data;
};
export const unsubscribeFromNewsletter = async (email: string) => {
const response = await axios.post(`${API_BASE_URL}/api/v1/newsletter/unsubscribe/${encodeURIComponent(email)}`);
return response.data;
};
+84
View File
@@ -0,0 +1,84 @@
import api from './api';
// Types
export interface ContactCategory {
id: number;
name: string;
description: string;
display_order: number;
is_active: boolean;
created_at?: string;
updated_at?: string;
}
export interface Contact {
id: number;
category_id?: number;
category?: ContactCategory;
name: string;
position: string;
email: string;
phone: string;
image_url?: string;
description?: string;
display_order: number;
is_active: boolean;
created_at?: string;
updated_at?: string;
}
export interface GroupedContacts {
categories: Record<string, Contact[]>;
uncategorized: Contact[];
}
// Public API
export const getPublicContacts = async (): Promise<GroupedContacts> => {
const response = await api.get('/contacts');
return response.data;
};
export const getPublicContactCategories = async (): Promise<ContactCategory[]> => {
const response = await api.get('/contacts/categories');
return response.data;
};
// Admin API - Contact Categories
export const getContactCategories = async (): Promise<ContactCategory[]> => {
const response = await api.get('/admin/contacts/categories');
return response.data;
};
export const createContactCategory = async (data: Partial<ContactCategory>): Promise<ContactCategory> => {
const response = await api.post('/admin/contacts/categories', data);
return response.data;
};
export const updateContactCategory = async (id: number, data: Partial<ContactCategory>): Promise<ContactCategory> => {
const response = await api.put(`/admin/contacts/categories/${id}`, data);
return response.data;
};
export const deleteContactCategory = async (id: number): Promise<void> => {
await api.delete(`/admin/contacts/categories/${id}`);
};
// Admin API - Contacts
export const getContacts = async (): Promise<Contact[]> => {
const response = await api.get('/admin/contacts');
return response.data;
};
export const createContact = async (data: Partial<Contact>): Promise<Contact> => {
const response = await api.post('/admin/contacts', data);
return response.data;
};
export const updateContact = async (id: number, data: Partial<Contact>): Promise<Contact> => {
const response = await api.put(`/admin/contacts/${id}`, data);
return response.data;
};
export const deleteContact = async (id: number): Promise<void> => {
await api.delete(`/admin/contacts/${id}`);
};
+45
View File
@@ -0,0 +1,45 @@
import { api, API_URL as SHARED_API_URL } from './api';
import { Event } from '../types/event';
import { getToken } from '../utils/auth';
// Use shared API URL, which is already resolved to the full API path (e.g. http://host:port/api/v1)
const API_URL = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || SHARED_API_URL || 'http://localhost:8080/api/v1';
export const getUpcomingEvents = async (): Promise<Event[]> => {
const response = await api.get<Event[]>('/events/upcoming');
return response.data as any;
};
export const createEvent = async (eventData: Partial<Event>): Promise<Event> => {
const token = getToken();
const response = await api.post<Event>('/events', eventData, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
return response.data as any;
};
export const getEvents = async (): Promise<Event[]> => {
const response = await api.get<Event[]>('/events');
return response.data as any;
};
export const updateEvent = async (id: number | string, eventData: Partial<Event>): Promise<Event> => {
const token = getToken();
const response = await api.put<Event>(`/events/${id}`, eventData, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
return response.data as any;
};
export const deleteEvent = async (id: number | string): Promise<{ ok: boolean } | void> => {
const token = getToken();
const response = await api.delete<{ ok: boolean }>(`/events/${id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
return response.data;
};
export const getEvent = async (id: number | string): Promise<Event> => {
const response = await api.get<Event>(`/events/${id}`);
return response.data as any;
};
+28
View File
@@ -0,0 +1,28 @@
import { API_URL } from '../api';
// Resolve a backend-served static path (e.g. /cache/prefetch/*.json) against the API origin
function resolveBackend(path: string): string {
try {
if (/^https?:\/\//i.test(path)) return path;
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const u = new URL(base);
u.pathname = path.startsWith('/') ? path : `/${path}`;
return u.toString();
} catch {
return path;
}
}
export async function getFacrClubInfoCache(): Promise<any | null> {
const url = resolveBackend('/cache/prefetch/facr_club_info.json');
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return null;
return res.json();
}
export async function getFacrTablesCache(): Promise<any | null> {
const url = resolveBackend('/cache/prefetch/facr_tables.json');
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return null;
return res.json();
}
+277
View File
@@ -0,0 +1,277 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import {
SearchResponse,
ClubInfo,
Competition,
SearchResult,
} from './types';
// Cache interface
interface CacheItem<T> {
data: T;
timestamp: number;
}
// Cache store
const cache = new Map<string, CacheItem<any>>();
// Create axios instance with base URL from environment variables
const apiClient: AxiosInstance = axios.create({
baseURL: process.env.REACT_APP_FACR_API_BASE_URL || 'http://localhost:8080/api/v1/facr',
timeout: parseInt(process.env.REACT_APP_FACR_API_TIMEOUT || '20000', 10),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
// Helper: resolve backend absolute URL for /api and /cache paths
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const baseOrigin = new URL(base).origin;
// Use URL constructor so query strings in `path` (e.g. /api/v1/x?t=123) are handled correctly
return new URL(path, baseOrigin).toString();
}
return path;
} catch {
return path;
}
};
// Lazy-load public overrides with lightweight cache
let overridesCache: { data: any; ts: number } | null = null;
const loadOverrides = async (): Promise<Record<string, string>> => {
const now = Date.now();
if (overridesCache && now - overridesCache.ts < 60_000) {
return (overridesCache.data?.by_name || {}) as Record<string, string>;
}
try {
const res = await fetch(resolveBackendUrl(`/api/v1/public/team-logo-overrides?t=${now}`), { cache: 'no-cache' });
if (res.ok) {
const json = await res.json();
const prev = JSON.stringify(overridesCache?.data || {});
const next = JSON.stringify(json || {});
overridesCache = { data: json, ts: now };
if (prev !== next) {
// Invalidate internal FACR GET cache so consumers refetch with new logos
cache.clear();
}
return (json?.by_name || {}) as Record<string, string>;
}
} catch {}
// Fallback to cached file if API failed
try {
const res2 = await fetch(resolveBackendUrl('/cache/prefetch/team_logo_overrides.json'), { cache: 'no-cache' });
if (res2.ok) {
const json = await res2.json();
const prev = JSON.stringify(overridesCache?.data || {});
const next = JSON.stringify(json || {});
overridesCache = { data: json, ts: now };
if (prev !== next) {
cache.clear();
}
return (json?.by_name || {}) as Record<string, string>;
}
} catch {}
overridesCache = { data: { by_name: {} }, ts: now };
return {};
};
// Name normalization helpers
const norm = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = norm(s);
// Remove common Czech tokens/prefixes
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) => {
if (!club?.competitions?.length) return club;
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => {
acc[norm(k)] = byName[k];
return acc;
}, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pick = (teamName?: string, original?: string) => {
if (!teamName) return original;
const exact = (byName || {})[teamName];
const n = norm(teamName);
let candidate = exact || byNameNorm[n];
if (!candidate) {
const s = stripPrefixes(teamName);
for (const { key, url } of strippedPairs) {
if (!key) continue;
if (s.endsWith(key) || key.endsWith(s)) { candidate = url; break; }
}
}
const chosen = candidate || original;
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
return chosen;
};
club.competitions = (club.competitions || []).map((c) => ({
...c,
matches: (c.matches || []).map((m: any) => ({
...m,
home_logo_url: pick(m.home, m.home_logo_url),
away_logo_url: pick(m.away, m.away_logo_url),
})),
}));
return club;
};
// Request interceptor for caching
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Skip cache for non-GET requests
if (config.method?.toLowerCase() !== 'get') {
return config;
}
// Generate a cache key based on the request
const cacheKey = `${config.method}:${config.url}:${JSON.stringify(config.params)}`;
// Check if we have a cached response
const cached = cache.get(cacheKey);
const now = Date.now();
const cacheTtl = parseInt(process.env.REACT_APP_FACR_CACHE_TTL || '3600000', 10);
if (cached && now - cached.timestamp < cacheTtl) {
// Return cached response
return {
...config,
adapter: () => Promise.resolve({
data: cached.data,
status: 200,
statusText: 'OK',
headers: {},
config,
}),
};
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for caching successful responses
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
const method = response.config.method?.toLowerCase();
const url = response.config.url;
const params = response.config.params;
// Only cache GET requests
if (method === 'get') {
const cacheKey = `${method}:${url}:${JSON.stringify(params)}`;
cache.set(cacheKey, {
data: response.data,
timestamp: Date.now(),
});
}
return response;
},
(error) => {
console.error('API Error:', error);
return Promise.reject(error);
}
);
// Helper function to handle API errors
const handleApiError = (error: any) => {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
const errorData = error.response.data || {};
const errorMessage = errorData.message || 'API request failed';
console.error('API Error Response:', errorData);
throw new Error(errorMessage);
} else if (error.request) {
// The request was made but no response was received
console.error('API Error: No response received', error.request);
throw new Error('No response from server. Please check if the FACR scraper API is running.');
} else {
// Something happened in setting up the request that triggered an Error
console.error('API Error:', error.message);
throw new Error(`API request failed: ${error.message}`);
}
};
// FACR API client
export const facrApi = {
// Search for clubs
searchClubs: async (query: string): Promise<SearchResponse> => {
try {
const response = await apiClient.get<SearchResponse>('/club/search', {
params: { q: query },
});
return response.data;
} catch (error) {
return handleApiError(error);
}
},
// Get club details and matches
getClub: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
try {
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}`);
// Load overrides and apply before returning/caching consumers
const byName = await loadOverrides();
const patched = applyOverridesToClub(response.data, byName);
return patched;
} catch (error) {
return handleApiError(error);
}
},
// Get club table (standings)
getClubTable: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
try {
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}/table`);
return response.data;
} catch (error) {
return handleApiError(error);
}
},
// Clear cache
clearCache: (): void => {
cache.clear();
},
// Get all competitions for a club
getClubCompetitions: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<Competition[]> => {
try {
const clubInfo = await facrApi.getClub(clubId, clubType);
return clubInfo.competitions || [];
} catch (error) {
return handleApiError(error);
}
},
// Get matches for a specific competition
getCompetitionMatches: async (competitionId: string): Promise<any[]> => {
try {
// Note: This assumes the competition ID is in the format 'type/id'
const [type, id] = competitionId.split('/');
const response = await apiClient.get<ClubInfo>(`/club/${type}/${id}`);
// Find the specific competition and return its matches
const competition = response.data.competitions?.find(c => c.id === competitionId);
return competition?.matches || [];
} catch (error) {
return handleApiError(error);
}
},
};
export default facrApi;
+81
View File
@@ -0,0 +1,81 @@
// Search result types
export interface SearchResult {
name: string;
club_id: string;
club_type: 'football' | 'futsal';
url: string;
logo_url: string;
category?: string;
address?: string;
}
export interface SearchResponse {
query: string;
count: number;
results: SearchResult[];
}
// Match types
export interface Match {
date_time: string;
home: string;
home_id: string;
home_logo_url: string;
away: string;
away_id: string;
away_logo_url: string;
score: string;
venue: string;
match_id: string;
report_url: string;
}
// Table row types
export interface TableRow {
rank: string;
team: string;
team_id: string;
team_logo_url: string;
played: string;
wins: string;
draws: string;
losses: string;
score: string;
points: string;
}
// Competition types
export interface Competition {
id: string;
code: string;
name: string;
team_count: string;
matches_link: string;
matches?: Match[];
table?: {
overall: TableRow[];
};
}
// Club info response
export interface ClubInfo {
name: string;
club_id: string;
club_type: 'football' | 'futsal';
club_internal_id: string;
url: string;
logo_url: string;
address: string;
category: string;
competitions: Competition[];
}
// Alias for backward compatibility
export type ClubSearchResult = SearchResult;
export type ClubSearchResponse = SearchResponse;
export type ClubMatch = Match;
export type ClubCompetition = Competition;
export type ClubResponse = ClubInfo;
export type TeamStanding = TableRow;
export type CompetitionTable = { overall: TableRow[] };
export type ClubTableCompetition = Competition & { table: CompetitionTable };
+119
View File
@@ -0,0 +1,119 @@
import axios from 'axios';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
export interface FileInfo {
id: number;
filename: string;
file_path: string;
file_url: string;
url: string;
file_size: number;
size: number;
mime_type: string;
uploaded_by?: {
id: number;
email: string;
first_name: string;
last_name: string;
};
created_at: string;
usages?: FileUsage[];
usage_count: number;
md5_hash?: string;
}
export interface FileUsage {
id: number;
file_id: number;
entity_type: string;
entity_id: number;
field_name: string;
entity_info?: {
type: string;
id: number;
title?: string;
name?: string;
slug?: string;
url?: string;
position?: string;
};
}
export interface DuplicateFiles {
[hash: string]: FileInfo[];
}
export const getAllFiles = async (params?: {
search?: string;
mime_type?: string;
sort_by?: string;
sort_order?: string;
}): Promise<FileInfo[]> => {
const response = await axios.get(`${API_URL}/admin/files`, {
params,
withCredentials: true,
});
return response.data;
};
export const getUnusedFiles = async (): Promise<FileInfo[]> => {
const response = await axios.get(`${API_URL}/admin/files/unused`, {
withCredentials: true,
});
return response.data;
};
export const getDuplicateFiles = async (): Promise<DuplicateFiles> => {
const response = await axios.get(`${API_URL}/admin/files/duplicates`, {
withCredentials: true,
});
return response.data;
};
export const getFileUsages = async (fileId: number): Promise<any[]> => {
const response = await axios.get(`${API_URL}/admin/files/${fileId}/usages`, {
withCredentials: true,
});
return response.data;
};
export const deleteFile = async (fileId: number, force: boolean = false): Promise<void> => {
await axios.delete(`${API_URL}/admin/files/${fileId}`, {
params: { force },
withCredentials: true,
});
};
export const scanAndSyncFiles = async (): Promise<{
message: string;
found_files: number;
new_files: number;
orphaned_files: number;
skipped_files?: number;
new_files_list?: string[];
orphaned_list?: string[];
}> => {
const response = await axios.post(`${API_URL}/admin/files/scan`, {}, {
withCredentials: true,
});
return response.data;
};
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
import { IconType } from 'react-icons';
import { FiImage, FiFileText, FiVideo, FiFile } from 'react-icons/fi';
export const getFileIcon = (mimeType: string): IconType => {
if (mimeType.startsWith('image/')) return FiImage;
if (mimeType === 'application/pdf') return FiFileText;
if (mimeType.startsWith('video/')) return FiVideo;
return FiFile;
};
+116
View File
@@ -0,0 +1,116 @@
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
export interface NavigationItem {
id?: number;
label: string;
url?: string;
icon?: string;
type: 'internal' | 'external' | 'dropdown' | 'page';
page_type?: string;
page_id?: number;
visible: boolean;
display_order: number;
parent_id?: number;
children?: NavigationItem[];
target?: '_self' | '_blank';
css_class?: string;
requires_auth?: boolean;
requires_admin?: boolean;
}
export interface SocialLink {
id?: number;
platform: string;
url: string;
display_order: number;
visible: boolean;
icon?: string;
}
// Public endpoints
export const getNavigationItems = async (): Promise<NavigationItem[]> => {
const response = await axios.get(`${API_BASE_URL}/navigation`);
return response.data;
};
export const getSocialLinks = async (): Promise<SocialLink[]> => {
const response = await axios.get(`${API_BASE_URL}/social-links`);
return response.data;
};
// Admin endpoints
export const getAllNavigationItems = async (): Promise<NavigationItem[]> => {
const response = await axios.get(`${API_BASE_URL}/admin/navigation`, {
withCredentials: true,
});
return response.data;
};
export const createNavigationItem = async (item: Partial<NavigationItem>): Promise<NavigationItem> => {
const response = await axios.post(`${API_BASE_URL}/admin/navigation`, item, {
withCredentials: true,
});
return response.data;
};
export const updateNavigationItem = async (id: number, item: Partial<NavigationItem>): Promise<NavigationItem> => {
const response = await axios.put(`${API_BASE_URL}/admin/navigation/${id}`, item, {
withCredentials: true,
});
return response.data;
};
export const deleteNavigationItem = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/navigation/${id}`, {
withCredentials: true,
});
};
export const reorderNavigationItems = async (orders: { id: number; display_order: number }[]): Promise<void> => {
await axios.post(`${API_BASE_URL}/admin/navigation/reorder`, orders, {
withCredentials: true,
});
};
// Social links admin endpoints
export const getAllSocialLinks = async (): Promise<SocialLink[]> => {
const response = await axios.get(`${API_BASE_URL}/admin/social-links`, {
withCredentials: true,
});
return response.data;
};
export const createSocialLink = async (link: Partial<SocialLink>): Promise<SocialLink> => {
const response = await axios.post(`${API_BASE_URL}/admin/social-links`, link, {
withCredentials: true,
});
return response.data;
};
export const updateSocialLink = async (id: number, link: Partial<SocialLink>): Promise<SocialLink> => {
const response = await axios.put(`${API_BASE_URL}/admin/social-links/${id}`, link, {
withCredentials: true,
});
return response.data;
};
export const deleteSocialLink = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/social-links/${id}`, {
withCredentials: true,
});
};
export const reorderSocialLinks = async (orders: { id: number; display_order: number }[]): Promise<void> => {
await axios.post(`${API_BASE_URL}/admin/social-links/reorder`, orders, {
withCredentials: true,
});
};
export const seedDefaultNavigation = async (): Promise<{ message: string; count: number; seeded: boolean }> => {
const response = await axios.post(`${API_BASE_URL}/admin/navigation/seed`, {}, {
withCredentials: true,
});
return response.data;
};
+369
View File
@@ -0,0 +1,369 @@
import axios from 'axios';
import { IconType } from 'react-icons';
import {
FaRegClipboard,
FaBullseye,
FaMapSigns,
FaColumns,
FaFlag,
FaNewspaper,
FaFutbol,
FaUsers,
FaTable,
FaChartLine,
FaCalendarAlt,
FaHandshake,
FaTshirt,
FaCommentDots,
FaTrophy,
FaBookOpen,
FaImages,
FaVideo,
FaBroadcastTower,
FaPodcast,
FaHashtag,
FaEnvelopeOpenText,
FaPhoneAlt,
FaHourglassHalf,
FaPoll,
FaQuestionCircle,
FaSearch,
FaMapMarkedAlt,
FaCalendarCheck,
FaCloudSun,
FaTicketAlt,
FaCube
} from 'react-icons/fa';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
export interface PageElementConfig {
id?: number;
page_type: string;
element_name: string;
variant: string;
visible?: boolean;
display_order?: number;
settings?: Record<string, any>;
created_at?: string;
updated_at?: string;
}
// Public endpoints
export const getPageElementConfigs = async (pageType: string): Promise<PageElementConfig[]> => {
const response = await axios.get(`${API_BASE_URL}/page-elements`, {
params: { page_type: pageType }
});
return response.data || [];
};
// Admin endpoints
export const getAllPageElementConfigs = async (): Promise<PageElementConfig[]> => {
const response = await axios.get(`${API_BASE_URL}/admin/page-elements`, {
withCredentials: true,
});
return response.data || [];
};
export const createOrUpdatePageElementConfig = async (config: Partial<PageElementConfig>): Promise<PageElementConfig> => {
const response = await axios.post(`${API_BASE_URL}/admin/page-elements`, config, {
withCredentials: true,
});
return response.data;
};
export const updatePageElementConfig = async (id: number, config: Partial<PageElementConfig>): Promise<PageElementConfig> => {
const response = await axios.put(`${API_BASE_URL}/admin/page-elements/${id}`, config, {
withCredentials: true,
});
return response.data;
};
export const deletePageElementConfig = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/page-elements/${id}`, {
withCredentials: true,
});
};
export const batchUpdatePageElementConfigs = async (configs: PageElementConfig[]): Promise<{ message: string; updated: number; created: number }> => {
const response = await axios.post(`${API_BASE_URL}/admin/page-elements/batch`, configs, {
withCredentials: true,
});
return response.data;
};
// Element variant definitions
export interface ElementVariant {
value: string;
label: string;
description: string;
preview?: string;
}
// Predefined element types that can be added to pages
export interface PredefinedElement {
name: string;
label: string;
description: string;
icon: IconType;
category: 'content' | 'media' | 'interactive' | 'layout';
defaultVariant: string;
component?: string;
}
export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Layout - Rozvržení
{ name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' },
{ 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: 'matches', label: 'Zápasy', description: 'Nadcházející a poslední zápasy', icon: FaFutbol, category: 'content', defaultVariant: 'compact' },
{ name: 'team', label: 'Tým', description: 'Hráči a realizační tým', icon: FaUsers, category: 'content', defaultVariant: 'grid' },
{ name: 'table', label: 'Tabulka', description: 'Ligová tabulka', icon: FaTable, category: 'content', defaultVariant: 'split_news' },
{ name: 'stats', label: 'Statistiky', description: 'Týmové a hráčské statistiky', icon: FaChartLine, category: 'content', defaultVariant: 'cards' },
{ name: 'activities', label: 'Akce', description: 'Nadcházející události a aktivity', icon: FaCalendarAlt, category: 'content', defaultVariant: 'list' },
{ name: 'sponsors', label: 'Partneři', description: 'Loga a odkazy partnerů', icon: FaHandshake, category: 'content', defaultVariant: 'grid' },
{ name: 'merch', label: 'Fanshop', description: 'Prodej klubového zboží', icon: FaTshirt, category: 'content', defaultVariant: 'grid' },
{ name: 'testimonials', label: 'Reference', description: 'Hodnocení a ohlasy fanoušků', icon: FaCommentDots, category: 'content', defaultVariant: 'carousel' },
{ name: 'achievements', label: 'Úspěchy', description: 'Trofeje a ocenění klubu', icon: FaTrophy, category: 'content', defaultVariant: 'timeline' },
{ name: 'history', label: 'Historie', description: 'Historie a milníky klubu', icon: FaBookOpen, category: 'content', defaultVariant: 'timeline' },
// Media - Média
{ name: 'gallery', label: 'Galerie', description: 'Fotogalerie', icon: FaImages, category: 'media', defaultVariant: 'grid' },
{ name: 'videos', label: 'Videa', description: 'YouTube videa a sestřihy', icon: FaVideo, category: 'media', defaultVariant: 'grid' },
{ name: 'live', label: 'Live Stream', description: 'Živé přenosy zápasů', icon: FaBroadcastTower, category: 'media', defaultVariant: 'featured' },
{ name: 'podcast', label: 'Podcast', description: 'Zvukové podcasty a komentáře', icon: FaPodcast, category: 'media', defaultVariant: 'list' },
{ name: 'social', label: 'Sociální Sítě', description: 'Příspěvky ze sociálních sítí', icon: FaHashtag, category: 'media', defaultVariant: 'grid' },
// Interactive - Interaktivní
{ name: 'newsletter', label: 'Newsletter', description: 'Formulář pro odběr novinek', icon: FaEnvelopeOpenText, category: 'interactive', defaultVariant: 'default' },
{ name: 'contact', label: 'Kontakt', description: 'Kontaktní formulář', icon: FaPhoneAlt, category: 'interactive', defaultVariant: 'form' },
{ name: 'countdown', label: 'Odpočet', description: 'Odpočet do příštího zápasu', icon: FaHourglassHalf, category: 'interactive', defaultVariant: 'default' },
{ name: 'poll', label: 'Anketa', description: 'Hlasování a ankety pro fanoušky', icon: FaPoll, category: 'interactive', defaultVariant: 'vertical' },
{ name: 'quiz', label: 'Kvíz', description: 'Interaktivní kvízy', icon: FaQuestionCircle, category: 'interactive', defaultVariant: 'card' },
{ name: 'search', label: 'Vyhledávání', description: 'Vyhledávací pole', icon: FaSearch, category: 'interactive', defaultVariant: 'header' },
{ name: 'map', label: 'Mapa', description: 'Mapa stadionu a areálu', icon: FaMapMarkedAlt, category: 'interactive', defaultVariant: 'default' },
{ name: 'calendar', label: 'Kalendář', description: 'Kalendář zápasů a akcí', icon: FaCalendarCheck, category: 'interactive', defaultVariant: 'month' },
{ name: 'weather', label: 'Počasí', description: 'Informace o počasí na stadionu', icon: FaCloudSun, category: 'interactive', defaultVariant: 'widget' },
{ name: 'ticketing', label: 'Vstupenky', description: 'Prodej a rezervace vstupenek', icon: FaTicketAlt, category: 'interactive', defaultVariant: 'widget' },
];
export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
header: [
{ value: 'unified', label: 'Jednotný', description: 'Klasická hlavička s logem a navigací' },
{ value: 'edge', label: 'Okrajový', description: 'Moderní hlavička s gradientem' },
{ value: 'minimal', label: 'Minimální', description: 'Čistý minimalistický design' },
{ value: 'modern', label: 'Moderní', description: 'Odvážný moderní styl s akcenty' },
{ value: 'sticky', label: 'Přilepený', description: 'Pevně přilepená hlavička při scrollování' },
{ value: 'transparent', label: 'Průhledný', description: 'Průhledná hlavička s efektem' },
{ value: 'sparta_navbar', label: 'Sparta Navbar', description: 'AC Sparta Praha styl - burger menu, logo, navigace, vyhledávání' },
],
hero: [
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
{ value: 'swiper', label: 'Karusel', description: 'Posuvný karusel' },
{ value: 'swiper_full', label: 'Celý Karusel', description: 'Celoobrazovkový karusel' },
{ value: 'edge', label: 'Okrajový', description: 'Moderní okrajový styl' },
{ value: 'video', label: 'Video', description: 'Hero s pozadím videa' },
{ value: 'split', label: 'Rozdělený', description: 'Rozdělené rozložení text/obraz' },
{ value: 'featured_sidebar', label: 'Zvýrazněný + Sidebar', description: 'Velký článek vlevo + 4 články vpravo. Kategorie: BLOG/TÝM. Tlačítko: PŘEHRÁT. Sidebar link: VÍCE NOVINEK. Překrývá se s nadcházejícím zápasem' },
{ 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', 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' },
{ value: 'featured_sidebar', label: 'Zvýrazněný + Sidebar', description: 'Velký článek vlevo + 4 články vpravo. Kategorie: BLOG/NOVINKY. Tlačítko: PŘEHRÁT/ČÍST VÍCE. Link: VÍCE NOVINEK' },
{ value: 'list', label: 'Seznam', description: 'Vertikální seznam' },
{ value: 'magazine', label: 'Magazín', description: 'Stylizace jako časopis' },
{ value: 'masonry', label: 'Zdivo', description: 'Pinterest styl zdiva' },
{ value: 'timeline', label: 'Časová Osa', description: 'Chronologická časová osa' },
],
matches: [
{ value: 'compact', label: 'Kompaktní', description: 'Kompaktní karty zápasů - jeden sloupec (slider + taby dole)' },
{ value: 'compact_split', label: 'Kompaktní Rozdělený', description: 'Kompaktní karty zápasů - dva sloupce (slider vlevo + taby vpravo)' },
{ value: 'expanded', label: 'Rozšířený', description: 'Detailní informace o zápasech' },
{ value: 'timeline', label: 'Časová Osa', description: 'Zobrazení časové osy' },
{ value: 'calendar', label: 'Kalendář', description: 'Kalendářní zobrazení' },
{ value: 'live', label: 'Živě', description: 'Živé výsledky' },
{ value: 'scoreboard', label: 'Tabule', description: 'TV broadcast style - velká tabule skóre s live aktualizacemi' },
{ value: 'ticker', label: 'Ticker', description: 'Scrollující ticker s výsledky a nadcházejícími zápasy' },
],
sponsors: [
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' },
{ value: 'slider', label: 'Posuvník', description: 'Animovaný posuvník' },
{ value: 'scroller', label: 'Horizontální', description: 'Horizontální posuvník' },
{ value: 'pyramid', label: 'Pyramida', description: 'Pyramidové rozložení' },
{ value: 'wall', label: 'Zeď', description: 'Zeď partnerů' },
{ value: 'tiered', label: 'Vrstvený', description: 'Úrovně partnerů - Hlavní / Zlatí / Stříbrní / Bronzoví' },
{ value: 'spotlight', label: 'Spotlight', description: 'Velký hlavní partner + menší partneři okolo' },
{ value: 'sparta_partners_pyramid', label: 'Sparta Partners Pyramid', description: 'Tří-úrovňová pyramida partnerů (1 hlavní / 4 střední / více malých)' },
],
gallery: [
{ value: 'grid', label: 'Mřížka', description: 'Fotomřížka' },
{ value: 'masonry', label: 'Zdivo', description: 'Pinterest styl' },
{ value: 'slider', label: 'Posuvník', description: 'Slideshow prezentace' },
{ value: 'lightbox', label: 'Lightbox', description: 'Otevření v lightboxu' },
{ value: 'collage', label: 'Koláž', description: 'Kreativní koláž' },
{ value: 'featured_grid', label: 'Zvýrazněná + Mřížka', description: 'Velká hlavní fotka vlevo + malé fotky vpravo v mřížce' },
{ value: 'stories', label: 'Stories', description: 'Instagram-style stories kruhové thumbnaily s full-screen view' },
],
videos: [
{ value: 'grid', label: 'Mřížka', description: 'Video mřížka' },
{ value: 'featured', label: 'Zvýrazněné', description: 'Hlavní video + seznam' },
{ value: 'carousel', label: 'Karusel', description: 'Video karusel' },
{ value: 'playlist', label: 'Playlist', description: 'Playlist styl' },
{ value: 'highlight_reel', label: 'Sestřih', description: 'TV broadcast style - hlavní sestřih + kategorie (Góly, Zákroky, Highlights)' },
{ value: 'channel', label: 'Kanál', description: 'YouTube channel style s playlists a navigací' },
{ value: 'sparta_horizontal_slider', label: 'Sparta Horizontal Slider', description: 'Horizontální posuvník s kartami, UNLIMITED odznaky, prev/next tlačítka, drag support' },
],
team: [
{ value: 'grid', label: 'Mřížka', description: 'Karty hráčů v mřížce' },
{ value: 'list', label: 'Seznam', description: 'Seznam hráčů' },
{ value: 'carousel', label: 'Karusel', description: 'Posuvný karusel' },
{ value: 'auto_scroller', label: 'Auto Scroller', description: 'Horizontální auto-scroll. Jméno hráče + číslo dresu + odkaz. Taby: MUŽI / ŽENY. Fotky hráčů na pozadí' },
{ value: 'table', label: 'Tabulka', description: 'Tabulkové zobrazení' },
{ value: 'hierarchy', label: 'Hierarchie', description: 'Organizační hierarchie' },
{ value: 'formation', label: 'Rozestavení', description: 'Fotbalové rozestavení na hřišti (4-4-2, 4-3-3, atd.)' },
{ value: 'depth_chart', label: 'Hloubka Kádru', description: 'Pozice s hlavními hráči a náhradníky' },
{ value: 'sparta_tabs_stats', label: 'Sparta Tabs & Stats', description: 'Tabovaný výběr týmu s fotkou, statistikami a CTA tlačítky (Koupit dres, Detail týmu)' },
],
activities: [
{ value: 'list', label: 'Seznam', description: 'Seznam událostí' },
{ value: 'calendar', label: 'Kalendář', description: 'Kalendářní zobrazení' },
{ value: 'timeline', label: 'Časová Osa', description: 'Zobrazení časové osy' },
{ value: 'cards', label: 'Karty', description: 'Kartové rozložení' },
{ value: 'featured_event', label: 'Zvýrazněná Akce', description: 'Velká hlavní akce + menší nadcházející akce' },
{ value: 'countdown_grid', label: 'Odpočet + Mřížka', description: 'Odpočet k nejbližší akci + mřížka dalších akcí' },
],
newsletter: [
{ value: 'default', label: 'Výchozí', description: 'Standardní formulář' },
{ value: 'popup', label: 'Vyskakovací', description: 'Vyskakovací okno' },
{ value: 'inline', label: 'Vložený', description: 'Vložený minimální' },
{ value: 'sidebar', label: 'Boční', description: 'Boční panel' },
{ value: 'banner', label: 'Banner', description: 'Sticky top banner s minimalistickým formulářem' },
{ value: 'hero_cta', label: 'Hero CTA', description: 'Velký call-to-action s benefity (Získejte exkluzivní obsah, slevy...)' },
],
social: [
{ value: 'grid', label: 'Mřížka', description: 'Mřížka příspěvků' },
{ value: 'sidebar', label: 'Boční Panel', description: 'Boční widgety' },
{ value: 'floating', label: 'Plovoucí', description: 'Plovoucí ikony' },
{ value: 'feed', label: 'Tok', description: 'Tok příspěvků' },
{ value: 'wall', label: 'Sociální Zeď', description: 'Twitter/Instagram zeď s živými příspěvky z všech platforem' },
{ value: 'highlights', label: 'Highlights', description: 'Nejlepší příspěvky týdne s most liked/shared' },
],
stats: [
{ value: 'cards', label: 'Karty', description: 'Karty statistik' },
{ value: 'table', label: 'Tabulka', description: 'Datová tabulka' },
{ value: 'charts', label: 'Grafy', description: 'Vizuální grafy' },
{ value: 'dashboard', label: 'Dashboard', description: 'Přehledová deska' },
{ value: 'leaderboard', label: 'Žebříček', description: 'Top hráči - nejlepší střelci, asistence, čisté konto, atd.' },
{ value: 'comparison', label: 'Porovnání', description: 'Side-by-side porovnání hráčů nebo týmů' },
],
countdown: [
{ value: 'default', label: 'Výchozí', description: 'Standardní odpočet' },
{ value: 'minimal', label: 'Minimální', description: 'Minimální časovač' },
{ value: 'large', label: 'Velký', description: 'Velké zobrazení' },
{ value: 'circular', label: 'Kruhový', description: 'Kruhový odpočet' },
],
map: [
{ value: 'default', label: 'Výchozí', description: 'Standardní mapa' },
{ value: 'satellite', label: 'Satelit', description: 'Satelitní zobrazení' },
{ value: 'minimal', label: 'Minimální', description: 'Minimální design' },
{ value: 'interactive', label: 'Interaktivní', description: 'Plně interaktivní mapa' },
],
merch: [
{ value: 'grid', label: 'Mřížka', description: 'Produktová mřížka' },
{ value: 'carousel', label: 'Karusel', description: 'Produktový karusel' },
{ value: 'featured', label: 'Zvýrazněné', description: 'Zvýrazněné produkty' },
{ value: 'list', label: 'Seznam', description: 'Seznam produktů' },
{ value: 'sparta_product_slider', label: 'Sparta Product Slider', description: 'Produktový karusel s fotkami, cenami a tlačítky Koupit' },
],
footer: [
{ value: 'standard', label: 'Standardní', description: 'Klasická patička' },
{ value: 'minimal', label: 'Minimální', description: 'Minimalistická patička' },
{ value: 'extended', label: 'Rozšířená', description: 'Rozšířená s více sloupci' },
{ value: 'centered', label: 'Centrovaná', description: 'Centrované rozložení' },
{ value: 'sparta_extended', label: 'Sparta Extended', description: 'Rozšířená patička s partnery, navigací, newsletterem a sociálními sítěmi' },
],
sidebar: [
{ value: 'right', label: 'Pravý', description: 'Pravý boční panel' },
{ value: 'left', label: 'Levý', description: 'Levý boční panel' },
{ value: 'sticky', label: 'Přilepený', description: 'Přilepený při scrollování' },
],
banner: [
{ value: 'top', label: 'Nahoře', description: 'Banner v horní části' },
{ value: 'bottom', label: 'Dole', description: 'Banner ve spodní části' },
{ value: 'sidebar', label: 'Boční', description: 'Boční banner' },
{ value: 'overlay', label: 'Překryvný', description: 'Překryvný banner' },
],
table: [
{ value: 'split_news', label: 'Rozdělený s Aktualitami', description: 'Dva sloupce - Aktuality vlevo + Tabulka vpravo (výchozí)' },
{ value: 'standard', label: 'Standardní', description: 'Klasická tabulka - jeden sloupec' },
{ value: 'compact', label: 'Kompaktní', description: 'Kompaktní zobrazení' },
{ value: 'detailed', label: 'Detailní', description: 'Detailní informace' },
],
testimonials: [
{ value: 'carousel', label: 'Karusel', description: 'Posuvný karusel' },
{ value: 'grid', label: 'Mřížka', description: 'Mřížka referencí' },
{ value: 'wall', label: 'Zeď', description: 'Zeď ohlasů' },
],
achievements: [
{ value: 'timeline', label: 'Časová Osa', description: 'Chronologická osa' },
{ value: 'grid', label: 'Mřížka', description: 'Mřížka úspěchů' },
{ value: 'showcase', label: 'Výloha', description: 'Výloha trofejí' },
],
history: [
{ value: 'timeline', label: 'Časová Osa', description: 'Historická časová osa' },
{ value: 'story', label: 'Příběh', description: 'Vyprávěcí formát' },
{ value: 'milestones', label: 'Milníky', description: 'Klíčové milníky' },
],
live: [
{ value: 'featured', label: 'Zvýrazněný', description: 'Hlavní live stream' },
{ value: 'embedded', label: 'Vložený', description: 'Vložený přehrávač' },
{ value: 'multi', label: 'Více Kamer', description: 'Více kamer najednou' },
],
podcast: [
{ value: 'list', label: 'Seznam', description: 'Seznam epizod' },
{ value: 'player', label: 'Přehrávač', description: 'Integrovaný přehrávač' },
{ value: 'featured', label: 'Zvýrazněný', description: 'Zvýrazněné epizody' },
],
contact: [
{ value: 'form', label: 'Formulář', description: 'Kontaktní formulář' },
{ value: 'info', label: 'Informace', description: 'Kontaktní údaje' },
{ value: 'combined', label: 'Kombinovaný', description: 'Formulář + údaje' },
],
poll: [
{ value: 'vertical', label: 'Vertikální', description: 'Vertikální rozložení' },
{ value: 'horizontal', label: 'Horizontální', description: 'Horizontální rozložení' },
{ value: 'cards', label: 'Karty', description: 'Kartové rozložení' },
],
quiz: [
{ value: 'card', label: 'Karta', description: 'Karta po kartě' },
{ value: 'form', label: 'Formulář', description: 'Formulářový styl' },
{ value: 'interactive', label: 'Interaktivní', description: 'Plně interaktivní' },
],
search: [
{ value: 'header', label: 'Hlavička', description: 'V hlavičce' },
{ value: 'overlay', label: 'Překryvný', description: 'Překryvné vyhledávání' },
{ value: 'inline', label: 'Vložený', description: 'Vložený do stránky' },
],
calendar: [
{ value: 'month', label: 'Měsíc', description: 'Měsíční zobrazení' },
{ value: 'week', label: 'Týden', description: 'Týdenní zobrazení' },
{ value: 'list', label: 'Seznam', description: 'Seznam událostí' },
],
weather: [
{ value: 'widget', label: 'Widget', description: 'Widget počasí' },
{ value: 'detailed', label: 'Detailní', description: 'Detailní předpověď' },
{ value: 'minimal', label: 'Minimální', description: 'Základní info' },
],
ticketing: [
{ value: 'widget', label: 'Widget', description: 'Widget vstupenek' },
{ value: 'full', label: 'Plný', description: 'Plný systém' },
{ value: 'link', label: 'Odkaz', description: 'Pouze odkaz' },
],
};
+67
View File
@@ -0,0 +1,67 @@
import api from './api';
export interface Player {
id: number;
first_name: string;
last_name: string;
date_of_birth?: string; // ISO date string
position?: string;
jersey_number?: number;
team_id?: number;
nationality?: string;
height?: number;
weight?: number;
image_url?: string;
is_active: boolean;
created_at?: string;
email?: string;
phone?: string;
}
// Normalize backend payloads where gorm.Model serializes as `ID` rather than `id`.
// Also keep only the fields we use.
function normalize(p: any): Player {
if (!p) return p as any;
const id = p.id ?? p.ID;
return {
id: typeof id === 'string' ? Number(id) : id,
first_name: p.first_name ?? p.FirstName ?? '',
last_name: p.last_name ?? p.LastName ?? '',
date_of_birth: p.date_of_birth ?? p.DateOfBirth ?? undefined,
position: p.position ?? p.Position ?? undefined,
jersey_number: p.jersey_number ?? p.JerseyNumber ?? undefined,
team_id: p.team_id ?? p.TeamID ?? undefined,
nationality: p.nationality ?? p.Nationality ?? undefined,
height: p.height ?? p.Height ?? undefined,
weight: p.weight ?? p.Weight ?? undefined,
image_url: p.image_url ?? p.ImageURL ?? undefined,
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
created_at: p.created_at ?? p.CreatedAt ?? undefined,
email: p.email ?? p.Email ?? undefined,
phone: p.phone ?? p.Phone ?? undefined,
} as Player;
}
export async function getPlayers(): Promise<Player[]> {
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
const raw = Array.isArray(res.data)
? res.data
: ((res.data as any).data || (res.data as any).items);
return (raw || []).map(normalize);
}
export async function createPlayer(payload: Partial<Player>) {
// Admin endpoint requires auth token via api interceptor
const res = await api.post<any>('/players', payload);
return normalize(res.data);
}
export async function updatePlayer(id: number | string, payload: Partial<Player>) {
const res = await api.put<any>(`/players/${id}`, payload);
return normalize(res.data);
}
export async function deletePlayer(id: number | string) {
const res = await api.delete<{ zprava: string }>(`/players/${id}`);
return res.data;
}
+234
View File
@@ -0,0 +1,234 @@
import api from './api';
export interface Poll {
id: number;
title: string;
description: string;
type: 'single' | 'multiple' | 'rating';
status: 'draft' | 'active' | 'closed' | 'archived';
start_date?: string;
end_date?: string;
allow_multiple: boolean;
max_choices: number;
show_results: 'always' | 'after_vote' | 'after_end' | 'never';
require_auth: boolean;
allow_guest_vote: boolean;
featured: boolean;
category_id?: number;
category?: {
id: number;
name: string;
slug: string;
};
related_match_id?: number;
related_article_id?: number;
related_article?: {
id: number;
title: string;
slug: string;
};
related_event_id?: number;
related_event?: {
id: number;
title: string;
start_time: string;
};
related_video_url?: string;
image_url?: string;
total_votes: number;
options: PollOption[];
created_by: number;
creator?: {
id: number;
email: string;
username?: string;
};
created_at: string;
updated_at: string;
}
export interface PollOption {
id: number;
poll_id: number;
text: string;
description?: string;
image_url?: string;
display_order: number;
vote_count: number;
player_id?: number;
player?: {
id: number;
first_name: string;
last_name: string;
jersey_number?: number;
image_url?: string;
};
created_at: string;
updated_at: string;
}
export interface PollVoteRequest {
option_ids: number[];
session_token?: string;
}
export interface PollResult {
option_id: number;
text: string;
vote_count: number;
percentage: number;
image_url?: string;
player_id?: number;
}
export interface PollResultsResponse {
poll_id: number;
title: string;
total_votes: number;
results: PollResult[];
}
export interface PollResponse {
poll: Poll;
has_voted: boolean;
is_active: boolean;
can_show_results: boolean;
}
export interface CreatePollRequest {
title: string;
description?: string;
type?: 'single' | 'multiple' | 'rating';
status?: 'draft' | 'active' | 'closed' | 'archived';
start_date?: string;
end_date?: string;
allow_multiple?: boolean;
max_choices?: number;
show_results?: 'always' | 'after_vote' | 'after_end' | 'never';
require_auth?: boolean;
allow_guest_vote?: boolean;
featured?: boolean;
category_id?: number;
related_match_id?: number;
related_article_id?: number;
related_event_id?: number;
related_video_url?: string;
image_url?: string;
options: {
text: string;
description?: string;
image_url?: string;
display_order?: number;
player_id?: number;
}[];
}
export interface UpdatePollRequest {
title?: string;
description?: string;
type?: 'single' | 'multiple' | 'rating';
status?: 'draft' | 'active' | 'closed' | 'archived';
start_date?: string;
end_date?: string;
allow_multiple?: boolean;
max_choices?: number;
show_results?: 'always' | 'after_vote' | 'after_end' | 'never';
require_auth?: boolean;
allow_guest_vote?: boolean;
featured?: boolean;
category_id?: number;
related_match_id?: number;
related_article_id?: number;
related_event_id?: number;
related_video_url?: string;
image_url?: string;
}
export interface PollStats {
poll: Poll;
votes_by_day: {
date: string;
count: number;
}[];
authenticated_votes: number;
guest_votes: number;
}
// Public API
export const getPolls = async (params?: {
featured?: boolean;
status?: string;
article_id?: number;
event_id?: number;
video_url?: string;
}): Promise<Poll[]> => {
const response = await api.get('/polls', { params });
return response.data;
};
export const getPoll = async (id: number): Promise<PollResponse> => {
const response = await api.get(`/polls/${id}`);
return response.data;
};
export const votePoll = async (
id: number,
data: PollVoteRequest
): Promise<{ message: string; poll: Poll }> => {
const response = await api.post(`/polls/${id}/vote`, data);
return response.data;
};
export const getPollResults = async (id: number): Promise<PollResultsResponse> => {
const response = await api.get(`/polls/${id}/results`);
return response.data;
};
// Admin API
export const getAdminPolls = async (params?: {
status?: string;
}): Promise<Poll[]> => {
const response = await api.get('/admin/polls', { params });
return response.data;
};
export const getAdminPoll = async (id: number): Promise<PollResponse> => {
const response = await api.get(`/admin/polls/${id}`);
return response.data;
};
export const createPoll = async (data: CreatePollRequest): Promise<Poll> => {
const response = await api.post('/admin/polls', data);
return response.data;
};
export const updatePoll = async (
id: number,
data: UpdatePollRequest
): Promise<Poll> => {
const response = await api.put(`/admin/polls/${id}`, data);
return response.data;
};
export const deletePoll = async (id: number): Promise<void> => {
await api.delete(`/admin/polls/${id}`);
};
export const getPollStats = async (id: number): Promise<PollStats> => {
const response = await api.get(`/admin/polls/${id}/stats`);
return response.data;
};
// Helper to generate a session token for guest voting
export const generateSessionToken = (): string => {
const stored = localStorage.getItem('poll_session_token');
if (stored) {
return stored;
}
const token = `guest_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
localStorage.setItem('poll_session_token', token);
return token;
};
+72
View File
@@ -0,0 +1,72 @@
import api from './api';
export type Match = { id: number; home?: string; away?: string; date?: string; score?: string };
export type Standing = { team: string; played: number; won: number; draw: number; lost: number; points: number };
export type Player = {
id: number;
first_name: string;
last_name: string;
position?: string;
jersey_number?: number;
image_url?: string;
is_active?: boolean;
// Extended detail fields (optional on public endpoint)
nationality?: string;
date_of_birth?: string;
height?: number;
weight?: number;
email?: string;
phone?: string;
team_id?: number;
};
export type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string; tier?: string; display_order?: number };
export type Category = { id?: number; name: string; slug?: string; url?: string; children?: Category[] };
export async function getMatches() {
const res = await api.get<Match[] | { data: Match[] }>('/matches');
return Array.isArray(res.data) ? res.data : res.data.data;
}
export async function getStandings() {
const res = await api.get<Standing[] | { data: Standing[] }>('/standings');
return Array.isArray(res.data) ? res.data : res.data.data;
}
export async function getPlayers() {
const res = await api.get<Player[] | { data?: Player[]; items?: Player[] }>('/players');
if (Array.isArray(res.data)) return res.data as Player[];
const d = res.data as any;
return (d?.data || d?.items || []) as Player[];
}
export async function getPlayer(id: number | string) {
const res = await api.get<Player>(`/players/${id}`);
return res.data;
}
export async function getSponsors() {
const res = await api.get<Sponsor[] | { data: Sponsor[] }>('/sponsors');
return Array.isArray(res.data) ? res.data : res.data.data;
}
export async function sendContact(payload: { name: string; email: string; subject: string; message: string; source?: string }) {
const res = await api.post('/contact', payload);
return res.data;
}
export async function subscribeToNewsletter(email: string, preferences?: Record<string, boolean> ) {
const payload: any = { email };
try {
// Provide backend with the correct site origin for preference/unsubscribe links
const origin = window?.location?.origin;
if (origin) (payload as any).site_base_url = origin;
} catch {}
if (preferences) payload.preferences = preferences;
const res = await api.post('/newsletter/subscribe', payload);
return res.data;
}
export async function getCategories() {
const res = await api.get<Category[] | { data: Category[] }>('/categories');
return Array.isArray(res.data) ? res.data : res.data.data;
}
@@ -0,0 +1,98 @@
import { api } from '../../services/api';
export type SubscriberPreferences = {
blogs?: boolean;
matches?: boolean;
events?: boolean;
scores?: boolean;
competitions?: string; // comma-separated list for now
categories?: string; // legacy key used by backend filtering
// allow future keys without strict typing issues
[key: string]: boolean | string | number | null | string[] | undefined;
};
type NormalizedPreferences = Record<string, boolean | string | number | null>;
const normalizePreferences = (preferences: SubscriberPreferences = {}): NormalizedPreferences => {
const normalized: NormalizedPreferences = {};
Object.entries(preferences).forEach(([key, raw]) => {
if (raw === undefined) {
return;
}
if (typeof raw === 'boolean' || typeof raw === 'number') {
normalized[key] = raw;
return;
}
if (typeof raw === 'string') {
normalized[key] = raw.trim();
return;
}
if (Array.isArray(raw)) {
const joined = (raw as Array<unknown>)
.map((item: unknown) => {
if (typeof item === 'string') {
return item.trim();
}
if (item === null || item === undefined) {
return '';
}
return String(item);
})
.filter((item: string) => item !== '');
normalized[key] = joined.join(', ');
return;
}
if (raw === null) {
normalized[key] = null;
return;
}
try {
normalized[key] = JSON.stringify(raw);
} catch {
normalized[key] = String(raw);
}
});
const competitions = normalized['competitions'];
if (typeof competitions === 'string' && !normalized['categories']) {
normalized['categories'] = competitions;
}
return normalized;
};
export type PreferencesResponse = {
email: string;
is_active: boolean;
preferences: SubscriberPreferences;
};
export async function getPreferences(token: string): Promise<PreferencesResponse> {
const { data } = await api.get<PreferencesResponse>(`/newsletter/preferences`, {
params: { token },
});
return data;
}
export async function savePreferences(token: string, preferences: SubscriberPreferences): Promise<{ message: string }> {
const normalized = normalizePreferences(preferences);
if (typeof normalized.competitions === 'string' && !normalized.categories) {
normalized.categories = normalized.competitions;
}
const { data } = await api.post<{ message: string }>(`/newsletter/preferences`, {
token,
preferences: normalized,
});
return data;
}
export async function unsubscribeToken(token: string): Promise<{ message: string }> {
const { data } = await api.post<{ message: string }>(`/newsletter/unsubscribe-token`, { token });
return data;
}
+134
View File
@@ -0,0 +1,134 @@
import { assetUrl } from '../utils/url';
export interface RelatedClub {
id: string;
name: string;
logo_url?: string;
competition?: string;
last_played_iso?: string;
matches_played?: number;
}
const normalize = (value: string) =>
String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const resolveBackendUrl = (path: string): string => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(base).origin;
return new URL(path, origin).toString();
}
return path;
} catch {
return path;
}
};
const fetchJSON = async <T>(path: string): Promise<T | null> => {
try {
const res = await fetch(resolveBackendUrl(path), { cache: 'no-cache' });
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
};
const parseDateTime = (dt?: string): Date | null => {
if (!dt) return null;
const [datePart, timePart = '00:00'] = String(dt).split(' ');
const [day, month, year] = (datePart || '').split('.');
if (!day || !month || !year) return null;
const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${timePart.slice(0, 5)}:00`;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
};
let cachePromise: Promise<RelatedClub[]> | null = null;
const buildRelatedClubs = (clubInfo: any): RelatedClub[] => {
if (!clubInfo) return [];
const ourNameNorm = normalize(clubInfo.name || '');
const competitions = Array.isArray(clubInfo.competitions) ? clubInfo.competitions : [];
const map = new Map<string, RelatedClub & { lastPlayedDate?: Date }>();
const considerTeam = (team: {
name?: string;
id?: string;
logo?: string;
competition?: string;
occurredAt?: Date | null;
}) => {
const teamName = team.name?.trim();
if (!teamName) return;
const normName = normalize(teamName);
if (!normName || normName === ourNameNorm) return;
const existing = map.get(normName);
const matchesPlayed = (existing?.matches_played ?? 0) + 1;
const lastPlayedDate = team.occurredAt && (!existing?.lastPlayedDate || team.occurredAt > existing.lastPlayedDate)
? team.occurredAt
: existing?.lastPlayedDate;
const logoUrl = team.logo || existing?.logo_url;
const competition = team.competition || existing?.competition;
map.set(normName, {
id: team.id || existing?.id || normName,
name: teamName,
logo_url: logoUrl ? assetUrl(logoUrl) || logoUrl : existing?.logo_url,
competition,
matches_played: matchesPlayed,
last_played_iso: lastPlayedDate ? lastPlayedDate.toISOString() : existing?.last_played_iso,
lastPlayedDate,
});
};
competitions.forEach((comp: any) => {
const matches = Array.isArray(comp?.matches) ? comp.matches : [];
matches.forEach((match: any) => {
const occurredAt = parseDateTime(match?.date_time);
considerTeam({
name: match?.home,
id: match?.home_id || match?.homeId,
logo: match?.home_logo_url,
competition: comp?.name,
occurredAt,
});
considerTeam({
name: match?.away,
id: match?.away_id || match?.awayId,
logo: match?.away_logo_url,
competition: comp?.name,
occurredAt,
});
});
});
return Array.from(map.values())
.map(({ lastPlayedDate, ...rest }) => rest)
.sort((a, b) => {
const ad = a.last_played_iso ? new Date(a.last_played_iso).getTime() : 0;
const bd = b.last_played_iso ? new Date(b.last_played_iso).getTime() : 0;
return bd - ad;
});
};
const fetchRelatedClubs = async (): Promise<RelatedClub[]> => {
const clubInfo = await fetchJSON<any>('/cache/prefetch/facr_club_info.json');
if (!clubInfo) return [];
return buildRelatedClubs(clubInfo);
};
export const getRelatedClubs = async (): Promise<RelatedClub[]> => {
if (!cachePromise) {
cachePromise = fetchRelatedClubs().catch(() => []);
}
return cachePromise;
};
+208
View File
@@ -0,0 +1,208 @@
import { extractPalette } from '@/utils/colors';
import api, { API_URL } from '@/services/api';
export type ScoreboardTheme = 'classic' | 'pill' | 'var1' | 'var2' | 'var3' | 'var4';
export type ScoreboardState = {
homeName: string;
awayName: string;
homeLogo?: string;
awayLogo?: string;
homeShort?: string;
awayShort?: string;
primaryColor?: string; // home color
secondaryColor?: string; // away color
homeScore: number;
awayScore: number;
halfLength: number; // minutes
theme: ScoreboardTheme;
externalMatchId?: string;
active?: boolean;
timer?: string; // MM:SS
running?: boolean;
};
const DEFAULT_STATE: ScoreboardState = {
homeName: 'DOMÁCÍ',
awayName: 'HOSTÉ',
homeLogo: '',
awayLogo: '',
homeShort: 'DOM',
awayShort: 'HOS',
primaryColor: '#1e3a8a',
secondaryColor: '#2563eb',
homeScore: 0,
awayScore: 0,
halfLength: 45,
theme: 'pill',
externalMatchId: '',
active: false,
timer: '00:00',
running: false,
};
const STORAGE_KEY = 'scoreboard_state_v1';
export async function getScoreboardState(): Promise<ScoreboardState> {
// Try backend admin singleton first (if authenticated), then fallback to public, then localStorage
try {
const res = await api.get('/admin/scoreboard');
const fromApi = normalizeFromApi(res.data);
// mirror to localStorage for fast next-load
localStorage.setItem(STORAGE_KEY, JSON.stringify(fromApi));
return { ...DEFAULT_STATE, ...fromApi } as ScoreboardState;
} catch {
// Not admin or not logged in; try public
try {
const base = (API_URL || '').replace(/\/$/, '');
const res = await fetch(`${base}/scoreboard`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
const fromApi = normalizeFromApi(data);
localStorage.setItem(STORAGE_KEY, JSON.stringify(fromApi));
return { ...DEFAULT_STATE, ...fromApi } as ScoreboardState;
}
} catch {}
// Fallback to localStorage
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_STATE;
const parsed = JSON.parse(raw);
return { ...DEFAULT_STATE, ...parsed } as ScoreboardState;
} catch {
return DEFAULT_STATE;
}
}
}
export async function saveScoreboardState(state: Partial<ScoreboardState>): Promise<ScoreboardState> {
const current = await getScoreboardState();
const next = { ...current, ...state } as ScoreboardState;
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
// Attempt to persist to backend if admin
try {
await api.put('/admin/scoreboard', toApiPayload(state));
} catch {
// ignore if not authorized
}
return next;
}
export async function getAdminScoreboard(): Promise<ScoreboardState> {
const res = await api.get('/admin/scoreboard');
const fromApi = normalizeFromApi(res.data);
localStorage.setItem(STORAGE_KEY, JSON.stringify(fromApi));
return { ...DEFAULT_STATE, ...fromApi } as ScoreboardState;
}
export async function updateAdminScoreboard(patch: Partial<ScoreboardState>): Promise<ScoreboardState> {
const res = await api.put('/admin/scoreboard', toApiPayload(patch));
const fromApi = normalizeFromApi(res.data);
localStorage.setItem(STORAGE_KEY, JSON.stringify(fromApi));
return { ...DEFAULT_STATE, ...fromApi } as ScoreboardState;
}
export async function getPublicScoreboard(): Promise<ScoreboardState> {
const base = (API_URL || '').replace(/\/$/, '');
const res = await fetch(`${base}/scoreboard`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load public scoreboard');
const data = await res.json();
const fromApi = normalizeFromApi(data);
return { ...DEFAULT_STATE, ...fromApi } as ScoreboardState;
}
// Timer controls (admin)
export async function startTimer(): Promise<void> {
await api.post('/admin/scoreboard/timer/start');
}
export async function pauseTimer(): Promise<void> {
await api.post('/admin/scoreboard/timer/pause');
}
export async function resetTimer(): Promise<void> {
await api.post('/admin/scoreboard/timer/reset');
}
// Utilities
export function deriveShort(name?: string): string {
if (!name) return '---';
const s = String(name).trim().toUpperCase();
if (!s) return '---';
const map: Record<string, string> = {
'Á':'A','Ä':'A','Å':'A','Â':'A','À':'A',
'Č':'C','Ć':'C','Ç':'C',
'Ď':'D',
'É':'E','Ě':'E','È':'E','Ë':'E','Ê':'E',
'Í':'I','Ì':'I','Ï':'I','Î':'I',
'Ň':'N','Ń':'N',
'Ó':'O','Ö':'O','Ô':'O','Ò':'O',
'Ř':'R',
'Š':'S','Ś':'S',
'Ť':'T',
'Ú':'U','Ů':'U','Ù':'U','Ü':'U','Û':'U',
'Ý':'Y',
'Ž':'Z',
};
let out = '';
for (const ch of s) {
let c = map[ch] || ch;
if (c >= 'A' && c <= 'Z') {
out += c;
if (out.length === 3) break;
}
}
while (out.length < 3) out += '-';
return out;
}
export async function derivePrimaryFromLogo(logoUrl?: string): Promise<string | null> {
if (!logoUrl) return null;
try {
const colors = await extractPalette(logoUrl, 5);
return colors?.[0] || null;
} catch {
return null;
}
}
// Helpers to map API payloads
function normalizeFromApi(d: any): Partial<ScoreboardState> {
if (!d) return {};
return {
homeName: d.homeName || d.home_name || d.HomeName || '',
awayName: d.awayName || d.away_name || d.AwayName || '',
homeLogo: d.homeLogo || d.home_logo || d.home_logo_url || d.HomeLogoURL || '',
awayLogo: d.awayLogo || d.away_logo || d.away_logo_url || d.AwayLogoURL || '',
homeShort: d.homeShort || d.home_short || d.HomeShort || '',
awayShort: d.awayShort || d.away_short || d.AwayShort || '',
primaryColor: d.primaryColor || d.primary_color || d.PrimaryColor || undefined,
secondaryColor: d.secondaryColor || d.secondary_color || d.SecondaryColor || undefined,
homeScore: typeof d.homeScore === 'number' ? d.homeScore : (typeof d.home_score === 'number' ? d.home_score : 0),
awayScore: typeof d.awayScore === 'number' ? d.awayScore : (typeof d.away_score === 'number' ? d.away_score : 0),
halfLength: typeof d.halfLength === 'number' ? d.halfLength : (typeof d.half_length === 'number' ? d.half_length : 45),
theme: (d.theme || 'pill') as any,
externalMatchId: d.externalMatchId || d.external_match_id || d.ExternalMatchID || '',
active: typeof d.active === 'boolean' ? d.active : undefined,
timer: d.timer || d.Timer || '00:00',
running: typeof d.running === 'boolean' ? d.running : undefined,
};
}
function toApiPayload(p: Partial<ScoreboardState>) {
const out: any = {};
if (p.homeName !== undefined) out.homeName = p.homeName;
if (p.awayName !== undefined) out.awayName = p.awayName;
if (p.homeLogo !== undefined) out.homeLogo = p.homeLogo;
if (p.awayLogo !== undefined) out.awayLogo = p.awayLogo;
if (p.homeShort !== undefined) out.homeShort = p.homeShort;
if (p.awayShort !== undefined) out.awayShort = p.awayShort;
if (p.primaryColor !== undefined) out.primaryColor = p.primaryColor;
if (p.secondaryColor !== undefined) out.secondaryColor = p.secondaryColor;
if (p.homeScore !== undefined) out.homeScore = p.homeScore;
if (p.awayScore !== undefined) out.awayScore = p.awayScore;
if (p.halfLength !== undefined) out.halfLength = p.halfLength;
if (p.theme !== undefined) out.theme = p.theme;
if (p.externalMatchId !== undefined) out.externalMatchId = p.externalMatchId;
if (p.active !== undefined) out.active = p.active;
if (p.timer !== undefined) out.timer = p.timer;
return out;
}
+536
View File
@@ -0,0 +1,536 @@
import api from './api';
import { getArticles } from './articles';
import { getPlayers } from './public';
import { getUpcomingEvents } from './eventService';
import { getSponsors } from './sponsors';
import facrApi from './facr/facrApi';
import { getRelatedClubs, RelatedClub } from './relatedClubs';
export interface SearchResult {
type: 'club' | 'match' | 'match_past' | 'article' | 'player' | 'event' | 'sponsor' | 'team' | 'contact' | 'gallery';
id: string | number;
title: string;
subtitle?: string;
description?: string;
image_url?: string;
logo_url?: string;
url?: string;
date?: string;
time?: string;
score?: number;
metadata?: Record<string, any>;
}
export interface SearchResults {
clubs: SearchResult[];
matches: SearchResult[];
matchesPast: SearchResult[];
articles: SearchResult[];
players: SearchResult[];
events: SearchResult[];
sponsors: SearchResult[];
teams: SearchResult[];
contacts: SearchResult[];
gallery: SearchResult[];
total: number;
}
// Enhanced scoring function for relevance with keyword support
const scoreMatch = (text: string, query: string): number => {
const t = (text || '').toLowerCase();
const q = (query || '').toLowerCase();
if (!t || !q) return 0;
// Exact match - highest score
if (t === q) return 100;
// Starts with query - very high score
if (t.startsWith(q)) return 80;
// Contains query as whole substring
const idx = t.indexOf(q);
if (idx >= 0) return 60 - Math.min(idx, 30);
// Keyword matching - split query into words and check each
const keywords = q.split(/\s+/).filter(k => k.length > 1);
if (keywords.length > 1) {
let matchedKeywords = 0;
let totalScore = 0;
for (const keyword of keywords) {
if (t.includes(keyword)) {
matchedKeywords++;
const keywordIdx = t.indexOf(keyword);
// Score based on position and keyword match
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
}
}
// If at least half the keywords match, return proportional score
if (matchedKeywords >= keywords.length / 2) {
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
}
}
// Partial character matching for typos/fuzzy search
const chars = q.split('');
let lastIdx = -1;
let matched = 0;
for (const char of chars) {
const charIdx = t.indexOf(char, lastIdx + 1);
if (charIdx > lastIdx) {
matched++;
lastIdx = charIdx;
}
}
if (matched >= chars.length * 0.8) {
return Math.min(25, Math.floor((matched / chars.length) * 25));
}
return 0;
};
// Resolve backend URLs for assets
const resolveBackendUrl = (path: string): string => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(base).origin;
return new URL(path, origin).toString();
}
return path;
} catch {
return path;
}
};
const normalizeName = (value: string) =>
String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
export async function searchAll(query: string): Promise<SearchResults> {
if (!query || query.trim().length === 0) {
return {
clubs: [],
matches: [],
matchesPast: [],
articles: [],
players: [],
events: [],
sponsors: [],
teams: [],
contacts: [],
gallery: [],
total: 0,
};
}
const q = query.trim().toLowerCase();
try {
const relatedClubsPromise = getRelatedClubs();
// Fetch all data in parallel
const [
relatedClubsRes,
clubsRes,
matchesRes,
matchesPastRes,
articlesRes,
playersRes,
eventsRes,
sponsorsRes,
teamsRes,
contactsRes,
galleryRes,
] = await Promise.allSettled([
relatedClubsPromise,
// Clubs from FACR
facrApi.searchClubs(query).catch(() => ({ results: [] })),
// Matches (upcoming)
(async () => {
const url = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
return await res.json();
})(),
// Matches (past)
(async () => {
const url = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
return await res.json();
})(),
// Articles
getArticles({ q: query, published: true, page: 1, page_size: 50 }),
// Players
getPlayers(),
// Events
getUpcomingEvents(),
// Sponsors
getSponsors(),
// Teams
(async () => {
const res = await api.get('/teams');
return Array.isArray(res.data) ? res.data : res.data?.data || [];
})(),
// Contacts
(async () => {
try {
const res = await api.get('/contacts');
// Backend returns { categories: {...}, uncategorized: [...] }
// Flatten into a single array
const grouped = res.data?.categories || {};
const uncategorized = res.data?.uncategorized || [];
const allContacts = [...uncategorized];
Object.values(grouped).forEach((contacts: any) => {
if (Array.isArray(contacts)) {
allContacts.push(...contacts);
}
});
return allContacts;
} catch {
return [];
}
})(),
// Gallery albums
(async () => {
try {
const res = await api.get('/gallery/albums');
return Array.isArray(res.data) ? res.data : res.data?.data || res.data?.albums || [];
} catch {
return [];
}
})(),
]);
const relatedClubs: RelatedClub[] = relatedClubsRes.status === 'fulfilled' ? relatedClubsRes.value : [];
const relatedById = new Map<string, RelatedClub>();
const relatedByName = new Map<string, RelatedClub>();
relatedClubs.forEach((club) => {
const idKey = String(club.id || '').toLowerCase();
if (idKey) relatedById.set(idKey, club);
const nameKey = normalizeName(club.name);
if (nameKey) relatedByName.set(nameKey, club);
});
const hasRelatedFilter = relatedById.size > 0 || relatedByName.size > 0;
// Process clubs
const clubsData = clubsRes.status === 'fulfilled' ? (clubsRes.value as any)?.results || [] : [];
const clubs: SearchResult[] = clubsData
.filter((c: any) => {
// Filter out clubs with no name or empty name
const name = String(c.name || '').trim();
if (!name) return false;
if (!hasRelatedFilter) return true;
const idKey = String(c.club_id || c.id || '').toLowerCase();
const nameKey = normalizeName(c.name);
return (idKey && relatedById.has(idKey)) || (nameKey && relatedByName.has(nameKey));
})
.map((c: any) => {
const idKey = String(c.club_id || c.id || '').toLowerCase();
const nameKey = normalizeName(c.name);
const related = (idKey && relatedById.get(idKey)) || (nameKey && relatedByName.get(nameKey)) || null;
const score = Math.max(scoreMatch(c.name, q), scoreMatch(c.category || '', q));
const logo = related?.logo_url || c.logo_url;
const subtitle = related?.competition || c.category || c.club_type;
return {
type: 'club' as const,
id: c.club_id || c.id,
title: c.name,
subtitle,
logo_url: logo ? resolveBackendUrl(logo) : undefined,
metadata: {
club_type: c.club_type,
last_played_iso: related?.last_played_iso,
matches_played: related?.matches_played,
},
score,
} as SearchResult;
})
.sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
const matchPassesFilter = (home?: string, away?: string, homeId?: string, awayId?: string) => {
if (!hasRelatedFilter) return true;
const homeName = normalizeName(home || '');
const awayName = normalizeName(away || '');
if (homeName && relatedByName.has(homeName)) return true;
if (awayName && relatedByName.has(awayName)) return true;
const homeKey = String(homeId || '').toLowerCase();
const awayKey = String(awayId || '').toLowerCase();
if (homeKey && relatedById.has(homeKey)) return true;
if (awayKey && relatedById.has(awayKey)) return true;
return false;
};
// Process matches (upcoming)
const matchesData = matchesRes.status === 'fulfilled' ? matchesRes.value : [];
const matches: SearchResult[] = (Array.isArray(matchesData) ? matchesData : [])
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
.map((m: any, idx: number) => ({
type: 'match' as const,
id: m.id || idx,
title: `${m.home || 'TBD'} vs ${m.away || 'TBD'}`,
subtitle: m.competition || m.competition_name || m.league,
date: m.date,
time: m.time,
metadata: {
home: m.home,
away: m.away,
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
venue: m.venue,
},
score: Math.max(
scoreMatch(m.home || '', q),
scoreMatch(m.away || '', q),
scoreMatch(m.venue || '', q),
scoreMatch(m.competition || m.competition_name || m.league || '', q)
),
})).sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
// Process matches (past)
const matchesPastData = matchesPastRes.status === 'fulfilled' ? matchesPastRes.value : [];
const matchesPast: SearchResult[] = (Array.isArray(matchesPastData) ? matchesPastData : [])
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
.map((m: any, idx: number) => ({
type: 'match_past' as const,
id: `past-${m.id || idx}`,
title: `${m.home || 'TBD'} vs ${m.away || 'TBD'}`,
subtitle: m.competition || m.competition_name || m.league,
date: m.date,
time: m.time,
metadata: {
home: m.home,
away: m.away,
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
venue: m.venue,
},
score: Math.max(
scoreMatch(m.home || '', q),
scoreMatch(m.away || '', q),
scoreMatch(m.venue || '', q),
scoreMatch(m.competition || m.competition_name || m.league || '', q)
),
})).sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
// Process articles
const articlesData = articlesRes.status === 'fulfilled' ? articlesRes.value?.data || [] : [];
const articles: SearchResult[] = articlesData
.filter((a: any) => {
const titleMatch = scoreMatch(a.title || '', q);
const excerptMatch = scoreMatch(a.excerpt || '', q);
const contentMatch = scoreMatch(a.content || '', q);
return titleMatch > 0 || excerptMatch > 0 || contentMatch > 0;
})
.map((a: any) => ({
type: 'article' as const,
id: a.id,
title: a.title,
description: a.excerpt,
image_url: a.image_url,
url: `/blog/${a.slug || a.id}`,
date: a.published_at || a.created_at,
score: Math.max(
scoreMatch(a.title || '', q),
scoreMatch(a.excerpt || '', q) * 0.7,
scoreMatch(a.content || '', q) * 0.3
),
}))
.sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
// Process players
const playersData = playersRes.status === 'fulfilled' ? playersRes.value : [];
const players: SearchResult[] = (Array.isArray(playersData) ? playersData : [])
.filter((p: any) => {
const fullName = `${p.first_name || ''} ${p.last_name || ''}`;
const nameMatch = scoreMatch(fullName, q);
const positionMatch = scoreMatch(p.position || '', q);
return nameMatch > 0 || positionMatch > 0;
})
.map((p: any) => ({
type: 'player' as const,
id: p.id,
title: `${p.first_name} ${p.last_name}`,
subtitle: p.position,
image_url: p.image_url,
url: `/hraci/${p.id}`,
metadata: { jersey_number: p.jersey_number },
score: Math.max(
scoreMatch(`${p.first_name} ${p.last_name}`, q),
scoreMatch(p.position || '', q)
),
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
// Process events
const eventsData = eventsRes.status === 'fulfilled' ? eventsRes.value : [];
const events: SearchResult[] = (Array.isArray(eventsData) ? eventsData : [])
.filter((e: any) => {
const titleMatch = scoreMatch(e.title || '', q);
const descMatch = scoreMatch(e.description || '', q);
const locationMatch = scoreMatch(e.location || '', q);
return titleMatch > 0 || descMatch > 0 || locationMatch > 0;
})
.map((e: any) => ({
type: 'event' as const,
id: e.id,
title: e.title,
description: e.description,
date: e.event_date,
time: e.event_time,
metadata: { location: e.location },
score: Math.max(
scoreMatch(e.title || '', q),
scoreMatch(e.description || '', q) * 0.7,
scoreMatch(e.location || '', q) * 0.5
),
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
// Process sponsors
const sponsorsData = sponsorsRes.status === 'fulfilled' ? sponsorsRes.value : [];
const sponsors: SearchResult[] = (Array.isArray(sponsorsData) ? sponsorsData : [])
.filter((s: any) => {
const nameMatch = scoreMatch(s.name || '', q);
return nameMatch > 0;
})
.map((s: any) => ({
type: 'sponsor' as const,
id: s.id,
title: s.name,
logo_url: s.logo_url,
url: s.website_url,
score: scoreMatch(s.name || '', q),
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
// Process teams
const teamsData = teamsRes.status === 'fulfilled' ? teamsRes.value : [];
const teams: SearchResult[] = (Array.isArray(teamsData) ? teamsData : [])
.filter((t: any) => {
// Filter out teams with no name or empty name
const name = String(t.name || '').trim();
if (!name) return false;
const nameMatch = scoreMatch(t.name || '', q);
const shortMatch = scoreMatch(t.short_name || '', q);
return nameMatch > 0 || shortMatch > 0;
})
.map((t: any) => ({
type: 'team' as const,
id: t.id,
title: t.name,
subtitle: t.short_name,
logo_url: t.logo_url,
description: t.description,
score: Math.max(scoreMatch(t.name || '', q), scoreMatch(t.short_name || '', q)),
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
// Process contacts
const contactsData = contactsRes.status === 'fulfilled' ? contactsRes.value : [];
const contacts: SearchResult[] = (Array.isArray(contactsData) ? contactsData : [])
.filter((c: any) => {
const nameMatch = scoreMatch(c.name || '', q);
const positionMatch = scoreMatch(c.position || '', q);
const emailMatch = scoreMatch(c.email || '', q);
return nameMatch > 0 || positionMatch > 0 || emailMatch > 0;
})
.map((c: any) => ({
type: 'contact' as const,
id: c.id,
title: c.name,
subtitle: c.position,
description: c.description,
image_url: c.image_url,
metadata: { email: c.email, phone: c.phone },
score: Math.max(
scoreMatch(c.name || '', q),
scoreMatch(c.position || '', q),
scoreMatch(c.email || '', q) * 0.5
),
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
// Process gallery albums
const galleryData = galleryRes.status === 'fulfilled' ? galleryRes.value : [];
const gallery: SearchResult[] = (Array.isArray(galleryData) ? galleryData : [])
.filter((g: any) => {
const titleMatch = scoreMatch(g.title || g.name || '', q);
const descMatch = scoreMatch(g.description || '', q);
return titleMatch > 0 || descMatch > 0;
})
.map((g: any) => ({
type: 'gallery' as const,
id: g.id,
title: g.title || g.name,
description: g.description,
image_url: g.cover_url || g.thumbnail_url,
url: `/galerie/${g.id}`,
metadata: { photo_count: g.photo_count },
score: Math.max(
scoreMatch(g.title || g.name || '', q),
scoreMatch(g.description || '', q) * 0.7
),
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
const total =
clubs.length +
matches.length +
matchesPast.length +
articles.length +
players.length +
events.length +
sponsors.length +
teams.length +
contacts.length +
gallery.length;
return {
clubs,
matches,
matchesPast,
articles,
players,
events,
sponsors,
teams,
contacts,
gallery,
total,
};
} catch (error) {
console.error('Search error:', error);
return {
clubs: [],
matches: [],
matchesPast: [],
articles: [],
players: [],
events: [],
sponsors: [],
teams: [],
contacts: [],
gallery: [],
total: 0,
};
}
}
+22
View File
@@ -0,0 +1,22 @@
import api from './api';
export interface SeoSettings {
site_title?: string;
site_description?: string;
meta_keywords?: string;
default_og_image_url?: string;
twitter_handle?: string;
canonical_base_url?: string;
additional_meta?: string;
enable_indexing?: boolean;
}
export async function getSeoSettings(): Promise<SeoSettings> {
const res = await api.get<SeoSettings>('/admin/seo');
return res.data || {};
}
export async function updateSeoSettings(payload: Partial<SeoSettings>): Promise<{ ok: boolean }> {
const res = await api.put<{ ok: boolean }>('/admin/seo', payload);
return res.data;
}
+136
View File
@@ -0,0 +1,136 @@
import api from './api';
export type Category = {
name: string;
slug?: string;
url?: string; // optional explicit URL override
children?: { name: string; slug?: string; url?: string }[];
};
export type CustomNavLink = {
label: string;
url: string;
external?: boolean;
};
export type PublicSettings = {
frontpage_layout?: string;
frontpage_style?: string;
hero_style?: 'grid' | 'scroller' | 'swiper' | 'swiper_full';
club_id?: string;
club_type?: 'football' | 'futsal';
club_name?: string; // preferred display name
club_logo_url?: string; // high-quality PNG/SVG override
primary_color?: string;
secondary_color?: string;
accent_color?: string;
background_color?: string;
text_color?: string;
shop_url?: string; // external e-shop URL
sponsors_layout?: 'grid' | 'slider' | 'scroller' | 'pyramid';
sponsors_theme?: 'dark' | 'light';
// Simple array of video URLs (legacy)
videos?: string[];
// Rich videos (preferred)
videos_items?: Array<{
url: string;
title?: string;
length?: string; // e.g. 3:45
uploaded_at?: string; // ISO date
thumbnail_url?: string;
}>;
facebook_url?: string;
instagram_url?: string;
youtube_url?: string;
zonerama_url?: string;
// Videos module
videos_module_enabled?: boolean;
videos_style?: 'slider' | 'grid3' | 'grid';
videos_source?: 'auto' | 'manual';
videos_limit?: number;
// Merch module
merch_module_enabled?: boolean;
merch_style?: 'grid' | 'slider';
merch_source?: 'manual' | 'auto';
merch_limit?: number;
merch_items?: Array<{
title?: string;
image_url: string;
url?: string;
}>;
// generic gallery support (preferred). If provided, Navbar uses this instead of zonerama_url
gallery_url?: string;
gallery_label?: string; // default: "Fotogalerie"
font_heading?: string;
font_body?: string;
custom_css?: string;
custom_js?: string;
custom_html_home?: string;
custom_html_blog_list?: string;
custom_html_blog_post?: string;
// About/Club page
about_html?: string;
show_about_in_nav?: boolean;
// Custom navigation links managed via admin settings
custom_nav?: CustomNavLink[];
// categories for top navigation (optional)
categories?: Category[];
// Map settings
map_style?: 'default' | 'positron' | 'positron-no-labels' | 'dark' | 'dark-no-labels' | 'toner' | 'toner-lite' | 'voyager' | 'terrain' | 'watercolor' | 'satellite';
};
export type AdminSettings = PublicSettings & {
id?: number;
created_at?: string;
updated_at?: string;
// SMTP configuration (admin-only)
smtp_host?: string;
smtp_port?: number;
smtp_user?: string;
smtp_password?: string;
smtp_from?: string;
smtp_from_name?: string;
smtp_encryption?: 'tls' | 'ssl' | 'none';
smtp_auth?: boolean;
smtp_skip_verify?: boolean;
// Newsletter defaults
default_digest_type?: 'blogs' | 'events' | 'matches' | 'scores' | 'weekly';
default_digest_competitions?: string;
// Newsletter scheduling
enable_weekly?: boolean;
enable_match_reminders?: boolean;
enable_results?: boolean;
newsletter_weekly_day?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';
newsletter_weekly_hour?: number; // 0-23
newsletter_reminder_lead_hours?: number; // default 48
newsletter_quiet_start?: number; // 0-23
newsletter_quiet_end?: number; // 0-23
// Contact & Map settings
contact_address?: string;
contact_city?: string;
contact_zip?: string;
contact_country?: string;
contact_phone?: string;
contact_email?: string;
location_latitude?: number;
location_longitude?: number;
map_zoom_level?: number;
show_map_on_homepage?: boolean;
// Homepage matches display configuration
finished_match_display_days?: number; // Number of days to show finished matches with scores on homepage
};
export const getPublicSettings = async (): Promise<PublicSettings> => {
const res = await api.get('/settings');
return res.data;
};
export const getAdminSettings = async (): Promise<AdminSettings> => {
const res = await api.get('/admin/settings');
return res.data;
};
export const updateAdminSettings = async (payload: Partial<AdminSettings>): Promise<AdminSettings> => {
const res = await api.put('/admin/settings', payload);
return res.data;
};
+75
View File
@@ -0,0 +1,75 @@
import api from './api';
export type SetupStatus = {
requires_setup: boolean;
};
export type SetupInitializePayload = {
admin_email: string;
admin_password: string;
first_name?: string;
last_name?: string;
jwt_secret?: string;
club_id?: string;
club_type?: string;
club_name?: string;
club_logo_url?: string;
club_url?: string;
frontpage_style?: 'unified' | 'magazine' | 'pro' | 'edge';
primary_color?: string;
secondary_color?: string;
accent_color?: string;
background_color?: string;
text_color?: string;
font_heading?: string;
font_body?: string;
// social & gallery (optional)
facebook_url?: string;
instagram_url?: string;
youtube_url?: string;
gallery_url?: string;
gallery_label?: string;
// location/contact info (optional)
contact_address?: string;
contact_city?: string;
contact_zip?: string;
contact_country?: string;
contact_phone?: string;
contact_email?: string;
location_latitude?: number;
location_longitude?: number;
map_style?: string;
map_zoom_level?: number;
smtp?: {
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
use_tls?: boolean;
} | null;
};
export const getSetupStatus = async (): Promise<SetupStatus> => {
const res = await api.get('/setup/status');
return res.data;
};
export const initializeSetup = async (payload: SetupInitializePayload): Promise<{ message: string }> => {
const res = await api.post('/setup/initialize', payload, { timeout: 30000 });
return res.data;
};
export type SMTPValidationPayload = {
host: string;
port: number;
username?: string;
password?: string;
from?: string;
use_tls?: boolean;
};
export const validateSMTP = async (payload: SMTPValidationPayload): Promise<{ ok: boolean; message?: string; error?: string }> => {
const res = await api.post('/setup/validate-smtp', payload, { timeout: 30000 });
return res.data;
};
+41
View File
@@ -0,0 +1,41 @@
import api from './api';
export interface Sponsor {
id: number;
name: string;
logo_url?: string;
website_url?: string;
is_active: boolean;
created_at?: string;
tier?: string; // 'general' for main partners, 'standard' for regular sponsors
display_order?: number; // For custom ordering
// Optional banner-specific metadata
placement?: string; // e.g., homepage_top, homepage_sidebar
width?: number;
height?: number;
}
export async function getSponsors(): Promise<Sponsor[]> {
const res = await api.get<any>('/sponsors');
const body = res.data;
const list = Array.isArray(body) ? body : (Array.isArray(body?.data) ? body.data : []);
return (list || []).map((s: any) => ({
...s,
id: s.id ?? s.ID ?? s.Id ?? s.iD,
}));
}
export async function createSponsor(payload: { name: string; logo_url?: string; website_url?: string; is_active?: boolean; tier?: string; display_order?: number; placement?: string; width?: number; height?: number }) {
const res = await api.post<Sponsor>('/sponsors', payload);
return res.data;
}
export async function updateSponsor(id: number | string, payload: Partial<{ name: string; logo_url?: string; website_url?: string; is_active?: boolean; tier?: string; display_order?: number; placement?: string; width?: number; height?: number }>) {
const res = await api.put<Sponsor>(`/sponsors/${id}`, payload);
return res.data;
}
export async function deleteSponsor(id: number | string) {
const res = await api.delete<{ zprava: string }>(`/sponsors/${id}`);
return res.data;
}
+65
View File
@@ -0,0 +1,65 @@
import api from './api';
export type YouTubeVideo = {
video_id: string;
title: string;
thumbnail_url: string;
views_text?: string;
views?: number;
published_text?: string;
published_date?: string; // YYYY-MM-DD
};
export type YouTubeChannelPayload = {
channel: string;
channel_url: string;
subscribers_text?: string;
subscribers?: number;
videos: YouTubeVideo[];
};
export const getCachedYouTube = async (): Promise<YouTubeChannelPayload | null> => {
try {
const res = await api.get('/youtube/videos');
const primary = res?.data as YouTubeChannelPayload | undefined;
const hasVideos = Array.isArray(primary?.videos) && (primary!.videos.length > 0);
// If backend responded with 204 or empty payload, fall back to the static cached JSON
if (res.status === 204 || !primary || !hasVideos) {
const fallback = await fetchStaticYouTubeCache();
return sortByPublishedDate(fallback);
}
return sortByPublishedDate(primary) as YouTubeChannelPayload;
} catch {
// Fallback: fetch static cached JSON directly from backend /cache path to avoid stressing external API
return await fetchStaticYouTubeCache();
}
};
// Helper: fetch static cached JSON from /cache/prefetch
const fetchStaticYouTubeCache = async (): Promise<YouTubeChannelPayload | null> => {
try {
// Determine backend origin from env config similar to HomePage resolve logic
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
const u = new URL(base);
// Force path to backend-exposed cache file
u.pathname = '/cache/prefetch/youtube_channel.json';
const resp = await fetch(u.toString(), { cache: 'no-cache' });
if (!resp.ok) return null;
const data = (await resp.json()) as YouTubeChannelPayload;
return sortByPublishedDate(data);
} catch {
return null;
}
};
// Helper: ensure videos are sorted by most recent published_date
const sortByPublishedDate = (payload: YouTubeChannelPayload | null | undefined): YouTubeChannelPayload | null => {
if (!payload || !Array.isArray(payload.videos)) return payload ?? null;
const copy: YouTubeChannelPayload = { ...payload, videos: [...payload.videos] };
copy.videos.sort((a, b) => {
const ta = Date.parse(a.published_date || '') || 0;
const tb = Date.parse(b.published_date || '') || 0;
return tb - ta; // newest first
});
return copy;
};
+122
View File
@@ -0,0 +1,122 @@
import api from './api';
export interface ZoneramaPhoto {
id: string;
page_url: string;
image_1500?: string;
title?: string;
}
export interface ZoneramaAlbumResp {
album: { id?: string; title?: string; url?: string };
photos: ZoneramaPhoto[];
}
export interface ZoneramaPickPayload {
id: string;
album_id?: string;
album_url: string;
page_url?: string;
image_url: string;
title?: string;
}
export async function getZoneramaAlbum(link: string, opts: { photo_limit?: number; rendered?: boolean } = {}): Promise<ZoneramaAlbumResp> {
const params: any = { link };
if (typeof opts.photo_limit === 'number') params.photo_limit = String(opts.photo_limit);
if (typeof opts.rendered === 'boolean') params.rendered = String(opts.rendered);
const res = await api.get<ZoneramaAlbumResp>('/zonerama/album', { params });
return res.data as any;
}
export async function getZoneramaPicks(): Promise<ZoneramaPickPayload[]> {
const res = await api.get<ZoneramaPickPayload[]>('/zonerama/picks');
return Array.isArray(res.data) ? res.data : [] as any;
}
export async function putZoneramaPick(payload: ZoneramaPickPayload): Promise<{ ok: boolean; count: number }> {
const res = await api.post<{ ok: boolean; count: number }>('/admin/zonerama/pick', payload);
return res.data;
}
export interface ZoneramaAlbumData {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: ZoneramaPhoto[];
fetched_at?: string;
}
export async function saveAlbumToCache(albumLink: string, photoLimit: number = 50): Promise<ZoneramaAlbumData> {
const res = await api.post<ZoneramaAlbumData>('/admin/zonerama/save-album', {
link: albumLink,
photo_limit: photoLimit
});
return res.data;
}
// Helper to read the flat manifest produced by the prefetcher for fast grid rendering
export async function getZoneramaManifest(): Promise<Array<{ id: string; album_id: string; src: string; local: string; page_url: string }>> {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
// New unified path for prefetched Zonerama items
const url = `${origin}/cache/prefetch/zonerama_flat.json`;
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
try {
const json = await res.json();
if (Array.isArray(json)) return json as any;
return [];
} catch {
return [];
}
}
// More robust loader that tries multiple sources for prefetched items
export async function getZoneramaManifestWithFallbacks(): Promise<Array<{ id: string; album_id: string; src: string; local: string; page_url: string }>> {
// 1) Try the main manifest
const primary = await getZoneramaManifest();
if (primary && primary.length > 0) return primary;
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
// 1b) Backward-compat removed - old path no longer used to avoid 404 errors
// 2) Try unified controller JSON (one JSON to control all prefetched albums)
try {
const ctrlUrl = `${origin}/cache/prefetch/zonerama_albums.json`;
const resCtrl = await fetch(ctrlUrl, { cache: 'no-cache' });
if (resCtrl.ok) {
const json = await resCtrl.json();
// Expect shape: { albums: [{ id, page_url, cover_local, cover_src, items: [...] }], items?: [...] }
const items = Array.isArray(json?.items) ? json.items : [];
const flatFromCtrl = items.map((it: any) => ({
id: String(it.id || it.photo_id || ''),
album_id: String(it.album_id || ''),
src: String(it.src || it.image_url || ''),
local: String(it.local || it.local_url || it.image_local || ''),
page_url: String(it.page_url || it.url || ''),
}));
if (flatFromCtrl.length > 0) return flatFromCtrl;
}
} catch { /* ignore */ }
// 3) Fall back to picks endpoint if available (admin saves picks)
try {
const picks = await getZoneramaPicks();
const mapped = picks.map((p) => ({
id: String(p.id),
album_id: String(p.album_id || ''),
src: String(p.image_url),
local: String(p.image_url),
page_url: String(p.page_url || p.album_url || ''),
}));
return mapped;
} catch { /* ignore */ }
return [];
}