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