mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
de day #74
This commit is contained in:
@@ -178,7 +178,16 @@ export async function fetchLogoAsBlob(logoUrl: string): Promise<Blob | null> {
|
||||
fullUrl = `${apiOrigin}${logoUrl}`;
|
||||
}
|
||||
|
||||
const response = await fetch(fullUrl);
|
||||
const apiOrigin = new URL(API_URL).origin;
|
||||
let fetchUrl = fullUrl;
|
||||
try {
|
||||
const u = new URL(fullUrl);
|
||||
if (u.origin !== apiOrigin) {
|
||||
fetchUrl = `${apiOrigin}/api/v1/proxy/image?url=${encodeURIComponent(fullUrl)}`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const response = await fetch(fetchUrl);
|
||||
if (!response.ok) return null;
|
||||
|
||||
return await response.blob();
|
||||
|
||||
@@ -101,6 +101,8 @@ export async function getArticles(params: {
|
||||
featured?: boolean;
|
||||
q?: string;
|
||||
slug?: string;
|
||||
match_id?: string | number;
|
||||
month?: string; // YYYY-MM
|
||||
} = {}) {
|
||||
// Backend returns shape: { items, total, page, page_size }
|
||||
// Normalize to { data, total, page, page_size } expected by the frontend.
|
||||
|
||||
@@ -49,10 +49,14 @@ const resolveBackendUrl = (path: string) => {
|
||||
|
||||
// Lazy-load public overrides with lightweight cache
|
||||
let overridesCache: { data: any; ts: number } | null = null;
|
||||
const loadOverrides = async (): Promise<Record<string, string>> => {
|
||||
type OverridesPayload = {
|
||||
by_name?: Record<string, string>;
|
||||
by_id?: Record<string, { name?: string; logo_url?: string }>;
|
||||
};
|
||||
const loadOverrides = async (): Promise<OverridesPayload> => {
|
||||
const now = Date.now();
|
||||
if (overridesCache && now - overridesCache.ts < 60_000) {
|
||||
return (overridesCache.data?.by_name || {}) as Record<string, string>;
|
||||
return (overridesCache.data || {}) as OverridesPayload;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(resolveBackendUrl(`/api/v1/public/team-logo-overrides?t=${now}`), { cache: 'no-cache' });
|
||||
@@ -65,7 +69,7 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
|
||||
// Invalidate internal FACR GET cache so consumers refetch with new logos
|
||||
cache.clear();
|
||||
}
|
||||
return (json?.by_name || {}) as Record<string, string>;
|
||||
return (json || {}) as OverridesPayload;
|
||||
}
|
||||
} catch {}
|
||||
// Fallback to cached file if API failed
|
||||
@@ -79,11 +83,11 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
|
||||
if (prev !== next) {
|
||||
cache.clear();
|
||||
}
|
||||
return (json?.by_name || {}) as Record<string, string>;
|
||||
return (json || {}) as OverridesPayload;
|
||||
}
|
||||
} catch {}
|
||||
overridesCache = { data: { by_name: {} }, ts: now };
|
||||
return {};
|
||||
return { by_name: {} };
|
||||
};
|
||||
|
||||
// Name normalization helpers
|
||||
@@ -99,14 +103,21 @@ const stripPrefixes = (s: string) => {
|
||||
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>) => {
|
||||
const applyOverridesToClub = (club: ClubInfo, overrides: OverridesPayload) => {
|
||||
if (!club?.competitions?.length) return club;
|
||||
const byName = overrides?.by_name || {};
|
||||
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
|
||||
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) => {
|
||||
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
|
||||
if (teamId && byId[teamId]?.logo_url) {
|
||||
const v = byId[teamId]!.logo_url!;
|
||||
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
||||
return v;
|
||||
}
|
||||
if (!teamName) return original;
|
||||
const exact = (byName || {})[teamName];
|
||||
const n = norm(teamName);
|
||||
@@ -122,12 +133,18 @@ const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) =>
|
||||
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
|
||||
return chosen;
|
||||
};
|
||||
const pickName = (teamId?: string, original?: string) => {
|
||||
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
|
||||
return (v && v.trim().length > 0) ? v : original;
|
||||
};
|
||||
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),
|
||||
home: pickName(m.home_id, m.home),
|
||||
away: pickName(m.away_id, m.away),
|
||||
home_logo_url: pickLogo(m.home_id, m.home, m.home_logo_url),
|
||||
away_logo_url: pickLogo(m.away_id, m.away, m.away_logo_url),
|
||||
})),
|
||||
}));
|
||||
return club;
|
||||
@@ -232,8 +249,8 @@ export const facrApi = {
|
||||
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);
|
||||
const overrides = await loadOverrides();
|
||||
const patched = applyOverridesToClub(response.data, overrides);
|
||||
return patched;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
@@ -244,7 +261,47 @@ export const facrApi = {
|
||||
getClubTable: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
|
||||
try {
|
||||
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}/table`);
|
||||
return response.data;
|
||||
const data = response.data as any;
|
||||
const overrides = await loadOverrides();
|
||||
const byName = overrides?.by_name || {};
|
||||
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
|
||||
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 pickLogo = (teamId?: string, teamName?: string, original?: string) => {
|
||||
if (teamId && byId[teamId]?.logo_url) {
|
||||
const v = byId[teamId]!.logo_url!;
|
||||
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
||||
return v;
|
||||
}
|
||||
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;
|
||||
};
|
||||
const pickName = (teamId?: string, original?: string) => {
|
||||
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
|
||||
return (v && v.trim().length > 0) ? v : original;
|
||||
};
|
||||
if (Array.isArray(data?.competitions)) {
|
||||
data.competitions = data.competitions.map((c: any) => ({
|
||||
...c,
|
||||
table: {
|
||||
overall: (c.table?.overall || []).map((r: any) => ({
|
||||
...r,
|
||||
team: pickName(r.team_id, r.team),
|
||||
team_logo_url: pickLogo(r.team_id, r.team, r.team_logo_url),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,9 @@ export interface PredefinedElement {
|
||||
|
||||
export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
|
||||
// Layout - Rozvržení
|
||||
{ name: 'style-pack', label: 'Styl balíček', description: 'Globální vizuální balíček pro celou stránku', icon: FaCube, category: 'layout', defaultVariant: 'default' },
|
||||
{ name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' },
|
||||
{ name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'brand' },
|
||||
{ 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' },
|
||||
@@ -155,6 +157,12 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
|
||||
];
|
||||
|
||||
export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
|
||||
'style-pack': [
|
||||
{ value: 'default', label: 'Výchozí', description: 'Základní sjednocený styl' },
|
||||
{ value: 'modern', label: 'Moderní', description: 'Zaoblené rohy, lehké stíny, více prostoru' },
|
||||
{ value: 'minimal', label: 'Minimal', description: 'Čisté, bez stínů, tenké rámečky' },
|
||||
{ value: 'sparta', label: 'Sparta', description: 'Přiblížení k Sparta packu' },
|
||||
],
|
||||
header: [
|
||||
{ value: 'unified', label: 'Jednotný', description: 'Klasická hlavička s logem a navigací' },
|
||||
{ value: 'edge', label: 'Okrajový', description: 'Moderní hlavička s gradientem' },
|
||||
@@ -166,6 +174,11 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
|
||||
{ value: 'current', label: 'Současný', description: 'Stávající navigace' },
|
||||
{ value: 'fullwidth', label: 'Šířka 100%', description: 'Navigace přes celou šířku obrazovky' },
|
||||
],
|
||||
'hero-topbar': [
|
||||
{ value: 'brand', label: 'Brand', description: 'Barevná lišta s klubovými barvami a akcemi' },
|
||||
{ value: 'minimal', label: 'Minimal', description: 'Průhledná/nenápadná lišta' },
|
||||
{ value: 'badge', label: 'Badge', description: 'Pill styl s klubovou barvou' },
|
||||
],
|
||||
hero: [
|
||||
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
|
||||
{ value: 'swiper', label: 'Karusel', description: 'Posuvný karusel' },
|
||||
|
||||
@@ -170,12 +170,18 @@ export const getPolls = async (params?: {
|
||||
event_id?: number;
|
||||
video_url?: string;
|
||||
}): Promise<Poll[]> => {
|
||||
const response = await api.get('/polls', { params });
|
||||
const response = await api.get('/polls', {
|
||||
params,
|
||||
headers: { 'X-Session-Token': generateSessionToken() },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPoll = async (id: number): Promise<PollResponse> => {
|
||||
const response = await api.get(`/polls/${id}`);
|
||||
const token = generateSessionToken();
|
||||
const response = await api.get(`/polls/${id}`, {
|
||||
headers: { 'X-Session-Token': token },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -183,12 +189,20 @@ export const votePoll = async (
|
||||
id: number,
|
||||
data: PollVoteRequest
|
||||
): Promise<{ message: string; poll: Poll }> => {
|
||||
const response = await api.post(`/polls/${id}/vote`, data);
|
||||
const token = data.session_token || generateSessionToken();
|
||||
const response = await api.post(
|
||||
`/polls/${id}/vote`,
|
||||
{ ...data, session_token: token },
|
||||
{ headers: { 'X-Session-Token': token } }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPollResults = async (id: number): Promise<PollResultsResponse> => {
|
||||
const response = await api.get(`/polls/${id}/results`);
|
||||
const token = generateSessionToken();
|
||||
const response = await api.get(`/polls/${id}/results`, {
|
||||
headers: { 'X-Session-Token': token },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,27 @@ export type Player = {
|
||||
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[] };
|
||||
|
||||
function normalizePlayer(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 ?? '',
|
||||
position: p.position ?? p.Position ?? undefined,
|
||||
jersey_number: p.jersey_number ?? p.JerseyNumber ?? undefined,
|
||||
image_url: p.image_url ?? p.ImageURL ?? undefined,
|
||||
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
|
||||
nationality: p.nationality ?? p.Nationality ?? undefined,
|
||||
date_of_birth: p.date_of_birth ?? p.DateOfBirth ?? undefined,
|
||||
height: p.height ?? p.Height ?? undefined,
|
||||
weight: p.weight ?? p.Weight ?? undefined,
|
||||
email: p.email ?? p.Email ?? undefined,
|
||||
phone: p.phone ?? p.Phone ?? undefined,
|
||||
team_id: p.team_id ?? p.TeamID ?? undefined,
|
||||
} as Player;
|
||||
}
|
||||
|
||||
export async function getMatches() {
|
||||
const res = await api.get<Match[] | { data: Match[] }>('/matches');
|
||||
return Array.isArray(res.data) ? res.data : res.data.data;
|
||||
@@ -33,15 +54,16 @@ export async function getStandings() {
|
||||
}
|
||||
|
||||
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[];
|
||||
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(normalizePlayer);
|
||||
}
|
||||
|
||||
export async function getPlayer(id: number | string) {
|
||||
const res = await api.get<Player>(`/players/${id}`);
|
||||
return res.data;
|
||||
const res = await api.get<any>(`/players/${id}`);
|
||||
return normalizePlayer(res.data);
|
||||
}
|
||||
|
||||
export async function getSponsors() {
|
||||
|
||||
+146
-86
@@ -35,60 +35,50 @@ export interface SearchResults {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Enhanced scoring function for relevance with keyword support
|
||||
// Enhanced scoring function for relevance with keyword support (accent-insensitive)
|
||||
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));
|
||||
const base = (t: string, q: string): number => {
|
||||
if (!t || !q) return 0;
|
||||
if (t === q) return 100;
|
||||
if (t.startsWith(q)) return 80;
|
||||
const idx = t.indexOf(q);
|
||||
if (idx >= 0) return 60 - Math.min(idx, 30);
|
||||
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);
|
||||
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
|
||||
}
|
||||
}
|
||||
if (matchedKeywords >= keywords.length / 2) {
|
||||
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
|
||||
}
|
||||
}
|
||||
|
||||
// If at least half the keywords match, return proportional score
|
||||
if (matchedKeywords >= keywords.length / 2) {
|
||||
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
if (matched >= chars.length * 0.8) {
|
||||
return Math.min(25, Math.floor((matched / chars.length) * 25));
|
||||
}
|
||||
|
||||
return 0;
|
||||
return 0;
|
||||
};
|
||||
const strip = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
const t0 = (text || '').toLowerCase();
|
||||
const q0 = (query || '').toLowerCase();
|
||||
const t1 = strip(t0);
|
||||
const q1 = strip(q0);
|
||||
return Math.max(base(t0, q0), base(t1, q1));
|
||||
};
|
||||
|
||||
// Resolve backend URLs for assets
|
||||
@@ -105,6 +95,18 @@ const resolveBackendUrl = (path: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// Small helper to fetch JSON from backend or cache with safe failure handling
|
||||
const fetchJSON = async <T>(path: string): Promise<T | null> => {
|
||||
try {
|
||||
const url = resolveBackendUrl(path);
|
||||
const res = await fetch(url, { cache: 'no-cache' });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeName = (value: string) =>
|
||||
String(value || '')
|
||||
.normalize('NFD')
|
||||
@@ -149,49 +151,77 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
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();
|
||||
const apiUrl = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
|
||||
try {
|
||||
const r = await fetch(apiUrl, { cache: 'no-cache' });
|
||||
let arr: any = [];
|
||||
if (r.ok) arr = await r.json();
|
||||
if (!Array.isArray(arr) || arr.length === 0) {
|
||||
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
|
||||
return Array.isArray(fallback) ? fallback : [];
|
||||
}
|
||||
return arr;
|
||||
} catch {
|
||||
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
|
||||
return Array.isArray(fallback) ? fallback : [];
|
||||
}
|
||||
})(),
|
||||
|
||||
// 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();
|
||||
const apiUrl = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
|
||||
try {
|
||||
const r = await fetch(apiUrl, { cache: 'no-cache' });
|
||||
let arr: any = [];
|
||||
if (r.ok) arr = await r.json();
|
||||
if (Array.isArray(arr) && arr.length > 0) return arr;
|
||||
} catch {}
|
||||
// Build from FACR club cache as fallback
|
||||
const facr = await fetchJSON<any>(`/cache/prefetch/facr_club_info.json`);
|
||||
if (!facr || !Array.isArray(facr?.competitions)) return [];
|
||||
const now = new Date();
|
||||
const out: any[] = [];
|
||||
for (const c of facr.competitions) {
|
||||
const compName = String(c?.name || c?.code || '').trim();
|
||||
const matches = Array.isArray(c?.matches) ? c.matches : [];
|
||||
for (const m of matches) {
|
||||
const dt = String(m?.date_time || '').trim();
|
||||
if (!dt) continue;
|
||||
const [datePart, timePart = '00:00'] = dt.split(' ');
|
||||
const [day, month, year] = String(datePart || '').split('.');
|
||||
if (!day || !month || !year) continue;
|
||||
const isoDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const timeStr = String(timePart).slice(0, 5);
|
||||
const ts = new Date(`${isoDate}T${timeStr || '00:00'}:00`);
|
||||
if (!(ts instanceof Date) || isNaN(ts.getTime())) continue;
|
||||
// Past only
|
||||
if (ts.getTime() >= now.getTime()) continue;
|
||||
out.push({
|
||||
id: m?.match_id || m?.matchId,
|
||||
home: m?.home,
|
||||
away: m?.away,
|
||||
competition: compName,
|
||||
date: isoDate,
|
||||
time: timeStr || undefined,
|
||||
venue: m?.venue,
|
||||
home_logo_url: m?.home_logo_url,
|
||||
away_logo_url: m?.away_logo_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
})(),
|
||||
|
||||
// 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];
|
||||
@@ -205,8 +235,6 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
|
||||
// Gallery albums
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get('/gallery/albums');
|
||||
@@ -232,14 +260,9 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
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));
|
||||
return true;
|
||||
})
|
||||
.map((c: any) => {
|
||||
const idKey = String(c.club_id || c.id || '').toLowerCase();
|
||||
@@ -280,7 +303,26 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
// 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))
|
||||
.filter((m: any) => {
|
||||
const comp = m.competition || m.competition_name || m.league || '';
|
||||
const queryMatches = (
|
||||
scoreMatch(m.home || '', q) > 0 ||
|
||||
scoreMatch(m.away || '', q) > 0 ||
|
||||
scoreMatch(m.venue || '', q) > 0 ||
|
||||
scoreMatch(comp, q) > 0
|
||||
);
|
||||
// If related filter is active, allow either related match OR explicit query match
|
||||
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
|
||||
})
|
||||
.filter((m: any) => {
|
||||
const comp = m.competition || m.competition_name || m.league || '';
|
||||
return (
|
||||
scoreMatch(m.home || '', q) > 0 ||
|
||||
scoreMatch(m.away || '', q) > 0 ||
|
||||
scoreMatch(m.venue || '', q) > 0 ||
|
||||
scoreMatch(comp, q) > 0
|
||||
);
|
||||
})
|
||||
.map((m: any, idx: number) => ({
|
||||
type: 'match' as const,
|
||||
id: m.id || idx,
|
||||
@@ -306,7 +348,25 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
// 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))
|
||||
.filter((m: any) => {
|
||||
const comp = m.competition || m.competition_name || m.league || '';
|
||||
const queryMatches = (
|
||||
scoreMatch(m.home || '', q) > 0 ||
|
||||
scoreMatch(m.away || '', q) > 0 ||
|
||||
scoreMatch(m.venue || '', q) > 0 ||
|
||||
scoreMatch(comp, q) > 0
|
||||
);
|
||||
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
|
||||
})
|
||||
.filter((m: any) => {
|
||||
const comp = m.competition || m.competition_name || m.league || '';
|
||||
return (
|
||||
scoreMatch(m.home || '', q) > 0 ||
|
||||
scoreMatch(m.away || '', q) > 0 ||
|
||||
scoreMatch(m.venue || '', q) > 0 ||
|
||||
scoreMatch(comp, q) > 0
|
||||
);
|
||||
})
|
||||
.map((m: any, idx: number) => ({
|
||||
type: 'match_past' as const,
|
||||
id: `past-${m.id || idx}`,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import api from './api';
|
||||
|
||||
export interface CreateShortLinkPayload {
|
||||
target_url: string;
|
||||
title?: string;
|
||||
source_type?: 'article' | 'event' | 'other' | string;
|
||||
source_id?: number;
|
||||
expires_at?: string | null;
|
||||
code?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortLinkResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
short_url: string;
|
||||
link: any;
|
||||
}
|
||||
|
||||
export async function createShortLink(payload: CreateShortLinkPayload): Promise<ShortLinkResponse> {
|
||||
try {
|
||||
// Prefer editor-accessible endpoint
|
||||
const res = await api.post<ShortLinkResponse>('/shortlinks', payload);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
// Fallback to admin endpoint (for admin-only contexts)
|
||||
const res2 = await api.post<ShortLinkResponse>('/admin/shortlinks', payload);
|
||||
return res2.data;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listShortLinks(): Promise<{ items: any[] }> {
|
||||
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getShortLinkStats(id: number | string): Promise<any> {
|
||||
const res = await api.get(`/admin/shortlinks/${id}/stats`);
|
||||
return res.data;
|
||||
}
|
||||
Reference in New Issue
Block a user