This commit is contained in:
Tomas Dvorak
2025-10-31 18:22:04 +01:00
parent 16e4533202
commit ac886502e0
65 changed files with 3211 additions and 553 deletions
+8 -9
View File
@@ -1,24 +1,23 @@
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 || '/api/v1';
function readStored(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
}
const storedApi = typeof window !== 'undefined' ? (readStored('fc_api_base_url') || readStored('api_base_url')) : null;
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL;
let API_URL = storedApi || envApiUrl || '/api/v1';
// If the provided base looks like a backend origin (no /api/), append /api/v1
try {
const maybe = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
if (!/\/api\//.test(maybe.pathname)) {
// ensure single trailing slash then append api/v1
maybe.pathname = maybe.pathname.replace(/\/$/, '') + '/api/v1';
API_URL = maybe.toString();
} else {
API_URL = maybe.toString();
}
} catch {
// If URL parsing fails, keep API_URL as-is
}
} catch {}
export const api: AxiosInstance = axios.create({
baseURL: API_URL,
+141
View File
@@ -0,0 +1,141 @@
import { Article } from './articles';
export interface MatchSnapshot {
external_match_id?: string;
competition?: string;
date_time?: string;
venue?: string;
home?: string;
away?: string;
score?: string;
}
export function stripHtml(html?: string): string {
if (!html) return '';
if (typeof window === 'undefined') {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
const div = document.createElement('div');
div.innerHTML = html;
return (div.textContent || div.innerText || '')?.replace(/\s+/g, ' ').trim();
}
export function composeInstagramPostFromArticle(params: {
article: Article;
trackingUrl: string;
clubName?: string;
hashtags?: string[];
match?: MatchSnapshot | null;
}): string {
const { article, trackingUrl, clubName, hashtags = [], match } = params;
const title = article.title?.trim() || '';
const plain = stripHtml(article.content).slice(0, 280);
const defaultTags = hashtags.length ? hashtags : [
`#${normalizeTag(clubName || 'FKKrnov')}`,
'#fotbal',
'#modrazluta',
];
if (match && (match.home || match.away)) {
const home = match.home || '';
const away = match.away || '';
const comp = match.competition ? `${match.competition}` : '';
const date = match.date_time ? formatDateTime(match.date_time) : '';
const score = match.score && /\d/.test(match.score) ? match.score : '';
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const lines = [
header,
'',
score ? `Výsledek: ${home} ${score} ${away}` : `${home} vs ${away}`,
comp || date ? `${comp}${comp && date ? ' • ' : ''}${date}` : '',
match.venue ? `Místo: ${match.venue}` : '',
'',
plain ? `${plain}${plain.length === 280 ? '…' : ''}` : '',
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
'',
defaultTags.join(' '),
'💙💛'
].filter(Boolean);
return lines.join('\n');
}
// Informative/general article
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const lines = [
header,
'',
plain,
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
'',
defaultTags.join(' '),
'💙💛'
];
return lines.join('\n');
}
export function composeInstagramPostFromActivity(params: {
activity: any;
trackingUrl: string;
clubName?: string;
hashtags?: string[];
}): string {
const { activity, trackingUrl, clubName, hashtags = [] } = params;
const title = String(activity?.title || '').trim();
const desc = stripHtml(String(activity?.description || '')).slice(0, 280);
const date = activity?.start_time ? formatDateTime(activity.start_time) : '';
const place = activity?.location ? String(activity.location) : '';
const defaultTags = hashtags.length ? hashtags : [
`#${normalizeTag(clubName || 'FKKrnov')}`,
'#aktivity',
'#fotbal',
];
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const lines = [
header,
'',
date || place ? `${date}${date && place ? ' • ' : ''}${place}` : '',
desc,
'',
'📸 Více informací najdeš tady 👇',
`🔗 ${trackingUrl}`,
'',
defaultTags.join(' '),
'💙💛'
].filter(Boolean);
return lines.join('\n');
}
function formatDateTime(dt: string): string {
try {
const d = new Date(dt);
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
} catch {
return dt;
}
}
function normalizeTag(s: string): string {
return s
.replace(/[^\p{L}\p{N}]+/gu, '') // remove spaces/symbols
.replace(/[áä]/gi, 'a')
.replace(/[č]/gi, 'c')
.replace(/[ď]/gi, 'd')
.replace(/[éěë]/gi, 'e')
.replace(/[íï]/gi, 'i')
.replace(/[ľĺ]/gi, 'l')
.replace(/[ň]/gi, 'n')
.replace(/[óö]/gi, 'o')
.replace(/[ř]/gi, 'r')
.replace(/[š]/gi, 's')
.replace(/[ť]/gi, 't')
.replace(/[úůü]/gi, 'u')
.replace(/[ý]/gi, 'y')
.replace(/[ž]/gi, 'z');
}
+1
View File
@@ -215,6 +215,7 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'carousel', label: 'Karusel', description: 'Horizontální karusel zápasů' },
{ value: 'scroller', label: 'Posuvník', description: 'Plynulý horizontální posuvník' },
{ value: 'ticker', label: 'Ticker', description: 'Úzký ticker výsledků a zápasů' },
{ value: 'compact_split', label: 'Kompaktní rozdělený', description: 'Slider vlevo a taby jako svislé menu vpravo' },
],
sponsors: [
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' },
+76 -6
View File
@@ -1,5 +1,6 @@
import api, { API_URL } from './api';
import { getArticles } from './articles';
import { getCategories } from './categories';
import { getPlayers } from './public';
import { getUpcomingEvents } from './eventService';
import { getSponsors } from './sponsors';
@@ -7,7 +8,7 @@ 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';
type: 'club' | 'match' | 'match_past' | 'article' | 'player' | 'event' | 'sponsor' | 'team' | 'contact' | 'gallery' | 'category';
id: string | number;
title: string;
subtitle?: string;
@@ -32,6 +33,7 @@ export interface SearchResults {
teams: SearchResult[];
contacts: SearchResult[];
gallery: SearchResult[];
categories: SearchResult[];
total: number;
}
@@ -128,6 +130,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams: [],
contacts: [],
gallery: [],
categories: [],
total: 0,
};
}
@@ -149,6 +152,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teamsRes,
contactsRes,
galleryRes,
categoriesRes,
] = await Promise.allSettled([
relatedClubsPromise,
facrApi.searchClubs(query).catch(() => ({ results: [] })),
@@ -243,6 +247,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
return [];
}
})(),
getCategories(),
]);
const relatedClubs: RelatedClub[] = relatedClubsRes.status === 'fulfilled' ? relatedClubsRes.value : [];
@@ -336,6 +341,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
venue: m.venue,
external_match_id: m.match_id || m.id,
},
score: Math.max(
scoreMatch(m.home || '', q),
@@ -380,6 +386,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
venue: m.venue,
external_match_id: m.match_id || m.id,
},
score: Math.max(
scoreMatch(m.home || '', q),
@@ -389,14 +396,15 @@ export async function searchAll(query: string): Promise<SearchResults> {
),
})).sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
// Process articles
// Process articles (base by query)
const articlesData = articlesRes.status === 'fulfilled' ? articlesRes.value?.data || [] : [];
const articles: SearchResult[] = articlesData
const baseArticles: 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;
const categoryMatch = scoreMatch((a?.category?.name || a?.category_name || '') as string, q);
return titleMatch > 0 || excerptMatch > 0 || contentMatch > 0 || categoryMatch > 0;
})
.map((a: any) => ({
type: 'article' as const,
@@ -409,11 +417,56 @@ export async function searchAll(query: string): Promise<SearchResults> {
score: Math.max(
scoreMatch(a.title || '', q),
scoreMatch(a.excerpt || '', q) * 0.7,
scoreMatch(a.content || '', q) * 0.3
scoreMatch(a.content || '', q) * 0.3,
scoreMatch((a?.category?.name || a?.category_name || '') as string, q) * 0.8
),
}))
.sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
// Enrichment: articles linked to top matched matches
const collectTopMatchIds = (arr: SearchResult[], limit: number): string[] => {
const out: string[] = [];
for (const item of arr) {
if (out.length >= limit) break;
const mid = String(item?.metadata?.external_match_id || '').trim();
if (mid && !out.includes(mid)) out.push(mid);
}
return out;
};
const topUpcoming = collectTopMatchIds(matches, 3);
const topPast = collectTopMatchIds(matchesPast, 3);
const matchIds = Array.from(new Set([...topUpcoming, ...topPast])).slice(0, 5);
let linkedArticlesData: any[] = [];
if (matchIds.length > 0) {
const linkedSettled = await Promise.allSettled(
matchIds.map((id) => getArticles({ match_id: id, published: true, page: 1, page_size: 10 }))
);
for (const r of linkedSettled) {
if (r.status === 'fulfilled' && r.value && Array.isArray((r.value as any).data)) {
linkedArticlesData.push(...((r.value as any).data as any[]));
}
}
}
const mapArticle = (a: any): SearchResult => ({
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,
scoreMatch((a?.category?.name || a?.category_name || '') as string, q) * 0.8
) + 5, // slight boost for match-linked relevance
});
const linkedMapped: SearchResult[] = (linkedArticlesData || []).map(mapArticle);
const seen = new Set<number | string>(baseArticles.map((a) => a.id));
const linkedUnique = linkedMapped.filter((a) => !seen.has(a.id));
const articles: SearchResult[] = [...baseArticles, ...linkedUnique].sort((a, b) => (b.score || 0) - (a.score || 0));
// Process players
const playersData = playersRes.status === 'fulfilled' ? playersRes.value : [];
const players: SearchResult[] = (Array.isArray(playersData) ? playersData : [])
@@ -551,6 +604,20 @@ export async function searchAll(query: string): Promise<SearchResults> {
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
// Process categories
const categoriesList: any[] = categoriesRes && categoriesRes.status === 'fulfilled' ? (categoriesRes.value as any[]) : [];
const categories: SearchResult[] = (Array.isArray(categoriesList) ? categoriesList : [])
.filter((c: any) => scoreMatch(c?.name || '', q) > 0)
.map((c: any) => ({
type: 'category' as const,
id: c.id,
title: c.name,
description: c.description,
url: `/blog?category_id=${c.id}`,
score: scoreMatch(c?.name || '', q),
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
const total =
clubs.length +
matches.length +
@@ -561,7 +628,8 @@ export async function searchAll(query: string): Promise<SearchResults> {
sponsors.length +
teams.length +
contacts.length +
gallery.length;
gallery.length +
categories.length;
return {
clubs,
@@ -574,6 +642,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams,
contacts,
gallery,
categories,
total,
};
} catch (error) {
@@ -589,6 +658,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams: [],
contacts: [],
gallery: [],
categories: [],
total: 0,
};
}