import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; import { useParams, Link as RouterLink } from 'react-router-dom'; import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles'; import { articleRead } from '../services/engagement'; import MainLayout from '../components/layout/MainLayout'; import DOMPurify from 'dompurify'; import { Helmet } from 'react-helmet-async'; import NewsletterCTA from '../components/common/NewsletterCTA'; import EmbeddedPoll from '../components/polls/EmbeddedPoll'; import { ArrowRight, Eye, Clock, SearchX } from 'lucide-react'; import React from 'react'; import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami'; import { assetUrl } from '../utils/url'; import { API_URL } from '../services/api'; import TeamLogo from '../components/common/TeamLogo'; import MatchModal from '../components/home/MatchModal'; import { extractPalette } from '../utils/colors'; import { getTeamLogo } from '../utils/sportLogosAPI'; import FilePreview from '../components/common/FilePreview'; import { usePublicSettings } from '../hooks/usePublicSettings'; import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton'; import { MatchSnapshot } from '../services/instagram'; import { Widget } from '../components/widgets/Widget'; import { MatchesWidget } from '../components/widgets/MatchesWidget'; import { getUpcomingEvents } from '../services/eventService'; import CommentsSection from '../components/comments/CommentsSection'; const toText = (html?: string) => { if (!html) return ''; const div = document.createElement('div'); div.innerHTML = html; return (div.textContent || div.innerText || '').replace(/\s+/g, ' ').trim(); }; const ArticleDetailPage: React.FC = () => { const { id, slug } = useParams<{ id?: string; slug?: string }>(); const { data, isLoading, isError } = useQuery({ queryKey: ['article', slug ? `slug:${slug}` : `id:${id}`], queryFn: () => (slug ? getArticleBySlug(slug!) : getArticle(id!)), enabled: Boolean(slug || id), }); // UI colors and public settings const { data: publicSettings } = usePublicSettings(); const cardBg = useColorModeValue('white','gray.900'); const videoBg = useColorModeValue('gray.50','gray.800'); const textMuted = useColorModeValue('gray.600','gray.400'); // Hoist all color mode values to top-level to avoid conditional hook calls const videoTitleColor = useColorModeValue('gray.700','gray.300'); const galleryBg = useColorModeValue('blue.50','blue.900'); const galleryBorder = useColorModeValue('blue.200','blue.700'); const attachmentsBg = useColorModeValue('gray.50','gray.800'); // Derive opponent color (for right edge fade) from team logo const [opponentColor, setOpponentColor] = React.useState(null); const [isMatchModalOpen, setIsMatchModalOpen] = React.useState(false); const [selectedMatch, setSelectedMatch] = React.useState(null); // Placeholders; moved tracking effects below to avoid using variables before declaration // Track article view when data is loaded React.useEffect(() => { if (data && (data as any).id) { trackArticleView((data as any).id); // Umami + backend generic event umamiTrackArticleView((data as any).id, (data as any).title); } }, [data]); // Award engagement for article read after 15s dwell (once per article per device) React.useEffect(() => { const aid = (data as any)?.id; if (!aid) return; let timer: any; const key = `fc_ar_read_${aid}`; const already = (() => { try { return localStorage.getItem(key) === '1'; } catch { return false; } })(); if (!already) { timer = setTimeout(async () => { try { await articleRead(Number(aid)); try { localStorage.setItem(key, '1'); } catch {} } catch {} }, 15000); } return () => { if (timer) clearTimeout(timer); }; }, [(data as any)?.id]); // Delegated click tracking for normal links inside content const contentRef = React.useRef(null); React.useEffect(() => { const el = contentRef.current; if (!el) return; const handler = (e: MouseEvent) => { let target = e.target as HTMLElement | null; if (!target) return; // Find nearest anchor const anchor = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null; if (anchor && anchor.href) { try { const href = anchor.getAttribute('href') || anchor.href; umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'article_content' }); } catch {} } }; el.addEventListener('click', handler); return () => { el.removeEventListener('click', handler); }; }, [contentRef.current]); // Fetch linked match (public) const matchLinkQuery = useQuery({ queryKey: ['article-match-link', (data as any)?.id], queryFn: () => getArticleMatchLink((data as any)?.id), enabled: Boolean((data as any)?.id), staleTime: 60_000, }); // Track match view when resolved React.useEffect(() => { const mid = (matchLinkQuery.data as any)?.external_match_id; if (mid) { umamiTrackMatchView(mid); } }, [(matchLinkQuery.data as any)?.external_match_id]); // From cached FACR club info, resolve the match details const facrMatchQuery = useQuery({ queryKey: ['facr-cached-match', (matchLinkQuery.data as any)?.external_match_id], enabled: Boolean((matchLinkQuery.data as any)?.external_match_id), queryFn: async () => { const origin = new URL(API_URL, window.location.origin).origin; const url = `${origin}/cache/prefetch/facr_club_info.json`; const res = await fetch(url, { cache: 'no-cache' }); if (!res.ok) return null as any; const json = await res.json(); const comps = Array.isArray(json?.competitions) ? json.competitions : []; for (const c of comps) { const matches = Array.isArray(c.matches) ? c.matches : []; for (const m of matches) { const mid = String(m.match_id || m.id); if (mid === String((matchLinkQuery.data as any)?.external_match_id)) { return { ...m, competitionName: c.name }; } } } return null as any; }, staleTime: 60_000, }); // Build a snapshot usable for sharing if available (FACR data or article fallback) const matchSnapshot: MatchSnapshot | null = React.useMemo(() => { const m: any = facrMatchQuery?.data as any; if (m) { let score = ''; if (m?.score && m.score !== 'vs') score = String(m.score); else if (m?.result_home != null && m?.result_away != null) score = `${m.result_home}:${m.result_away}`; return { external_match_id: String((matchLinkQuery.data as any)?.external_match_id || ''), competition: String(m.competitionName || ''), date_time: String(m.date_time || m.date || ''), venue: m.venue ? String(m.venue) : undefined, home: String(m.home || m.home_team || ''), away: String(m.away || m.away_team || ''), score, }; } const snap: any = (data as any)?.match_snapshot; if (snap) { return { external_match_id: snap.external_match_id, competition: snap.competition || snap.competitionName, date_time: snap.date_time || snap.date, venue: snap.venue, home: snap.home, away: snap.away, score: snap.score, } as MatchSnapshot; } return null; }, [facrMatchQuery?.data, (matchLinkQuery.data as any)?.external_match_id, (data as any)?.match_snapshot]); // Fetch gallery album if article has one (fallback to URL when ID is missing) const galleryAlbumQuery = useQuery({ queryKey: ['article-gallery-album', (data as any)?.gallery_album_id || (data as any)?.gallery_album_url], enabled: Boolean((data as any)?.gallery_album_id || (data as any)?.gallery_album_url), queryFn: async () => { const albumId = (data as any)?.gallery_album_id; let photoIds: string[] = []; const raw = (data as any)?.gallery_photo_ids; if (Array.isArray(raw)) { photoIds = raw as string[]; } else if (typeof raw === 'string') { // Try JSON parse, otherwise split by comma try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { photoIds = parsed.map(String); } else { photoIds = raw.split(',').map(s => s.trim()).filter(Boolean); } } catch { photoIds = raw.split(',').map(s => s.trim()).filter(Boolean); } } const origin = new URL(API_URL, window.location.origin).origin; // Try to find album in both sources const [profileRes, albumsRes] = await Promise.allSettled([ fetch(`${origin}/cache/prefetch/zonerama_profile.json`, { cache: 'no-cache' }), fetch(`${origin}/cache/prefetch/zonerama_albums.json`, { cache: 'no-cache' }) ]); // Check profile albums first if (profileRes.status === 'fulfilled' && profileRes.value.ok) { const profileData = await profileRes.value.json(); const album = (profileData.albums || []).find((a: any) => a.id === albumId); if (album) { // Filter photos by selected IDs if available const photos = photoIds.length > 0 ? album.photos.filter((p: any) => photoIds.includes(p.id)) : album.photos; return { ...album, photos }; } } // Check blog albums if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) { const albumsData = await albumsRes.value.json(); const blogAlbums = Array.isArray(albumsData) ? albumsData : []; const album = blogAlbums.find((a: any) => a.id === albumId); if (album) { const photos = photoIds.length > 0 ? album.photos.filter((p: any) => photoIds.includes(p.id)) : album.photos; return { ...album, photos }; } } // Fallback: load by URL via proxy endpoint const albumUrl = (data as any)?.gallery_album_url; if (albumUrl) { const params = new URLSearchParams({ link: albumUrl, photo_limit: '12', rendered: 'true' }); const resp = await fetch(`${API_URL}/zonerama-album?${params.toString()}`); if (resp.ok) { const payload = await resp.json(); let photos: any[] = []; if (Array.isArray(payload?.albums) && payload.albums.length > 0) { photos = payload.albums[0]?.photos || []; } else if (Array.isArray(payload?.photos)) { photos = payload.photos; } if (photoIds.length > 0) photos = photos.filter((p: any) => photoIds.includes(p.id)); return { id: albumUrl, title: 'Album', date: '', photos } as any; } } return null; }, staleTime: 60_000, }); // Track gallery section when loaded React.useEffect(() => { const albumId = (data as any)?.gallery_album_id; if (albumId && galleryAlbumQuery.data) { umamiTrackEvent('Gallery Section Shown', { album_id: albumId, photos: galleryAlbumQuery.data?.photos?.length || 0 }); } }, [(data as any)?.gallery_album_id, galleryAlbumQuery.data]); // Transform content to ensure /uploads URLs resolve against API origin and allow iframes // Memoize the transformation function to prevent infinite loops const toAbsoluteUploads = React.useCallback((html?: string) => { if (!html) return ''; try { const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin; // Replace src="/uploads... and href="/uploads... return html .replace(/src=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`) .replace(/href=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`) // Also rewrite absolute localhost/127.0.0.1 uploads links to backend origin .replace(/src=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`) .replace(/href=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`); } catch { return html; } }, []); const safeContentHTML = React.useMemo(() => { const transformed = toAbsoluteUploads((data as any)?.content); return DOMPurify.sanitize(transformed || '', { USE_PROFILES: { html: true }, ADD_TAGS: ['iframe'], ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'], }); }, [(data as any)?.content, toAbsoluteUploads]); const relatedArticlesQuery = useQuery({ queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id], enabled: Boolean((data as any)?.id), queryFn: () => getArticles({ page: 1, page_size: 6, published: true, ...(((data as any)?.category?.id) ? { category_id: (data as any).category.id } : {}), }), staleTime: 60_000, }); const upcomingEventsQuery = useQuery({ queryKey: ['upcoming-events-sidebar'], queryFn: getUpcomingEvents, staleTime: 60_000, }); const latestArticlesQuery = useQuery({ queryKey: ['latest-articles'], queryFn: () => getArticles({ page: 1, page_size: 6, published: true }), enabled: Boolean((isError || !data) && (slug || id)), staleTime: 60_000, }); if (isLoading) return ; if (isError || !data) return ( Článek nenalezen Článek nenalezen Je nám líto, ale hledaný článek neexistuje, byl smazán nebo byl přesunut. {Array.isArray((latestArticlesQuery.data as any)?.data) && ((latestArticlesQuery.data as any)?.data?.length || 0) > 0 && ( Nejnovější články {((latestArticlesQuery.data as any).data || []).slice(0, 6).map((a: any) => { const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`; return ( {a.title} {a.title} {a.published_at && ( {new Date(a.published_at).toLocaleDateString('cs-CZ')} )} ); })} )} ); const title = (data as any).seo_title || data.title; const description = (data as any).seo_description || toText(data.content).slice(0, 160); const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png'; const ogImage = assetUrl(ogImageRaw) || ogImageRaw; const canonical = typeof window !== 'undefined' ? window.location.href : undefined; const publishedAt = (data as any).published_at || (data as any).created_at; const monthParam = (() => { if (!publishedAt) return ''; try { const d = new Date(publishedAt); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } catch { return ''; } })(); return ( {title} {canonical && } {data.title} {((data as any).read_time || (data as any).estimated_read_minutes) ? ( {(data as any).read_time || (data as any).estimated_read_minutes} min čtení ) : null} {publishedAt && ( {new Date(publishedAt).toLocaleDateString('cs-CZ')} )} {(data as any)?.category?.id && ( {(data as any).category.name || 'Kategorie'} )} {(matchLinkQuery.data as any)?.external_match_id && ( Zápas )} {(data as any).view_count ? ( {(data as any).view_count} zobrazení ) : null} {/* Featured Image - smaller with subtle overlay */} {data.image_url && ( {data.title} )} {(data as any)?.id ? ( ) : null} {/* Match Section - Card with logos, score/countdown, venue/date */} {(matchLinkQuery.data as any)?.external_match_id && ( {/* Edge fades */} {opponentColor && ( )} Zápas k článku {facrMatchQuery.isLoading ? ( Načítám údaje o zápasu… ) : facrMatchQuery.data ? ( <> {facrMatchQuery.data.competitionName && ( {String(facrMatchQuery.data.competitionName)} )} {String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')} {String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')} {(() => { const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || ''); const d = new Date(dRaw); const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs'); if (hasScore) { const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`); return ({score}); } const now = Date.now(); const ms = d.getTime() - now; const days = Math.max(0, Math.floor(ms / (1000*60*60*24))); const hours = Math.max(0, Math.floor((ms % (1000*60*60*24))/(1000*60*60))); const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60))); return (Za {days} d {hours} h {mins} min); })()} {(() => { const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || ''); const d = new Date(dRaw); return {d.toLocaleDateString('cs-CZ')} {d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}; })()} {(facrMatchQuery.data as any).venue && {String((facrMatchQuery.data as any).venue)}} {String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')} {(facrMatchQuery.data as any).report_url && ( Protokol zápasu (fotbal.cz) )} ) : ( Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id} )} )} {/* Article Content - Main Section with editor-like lists */} {/* YouTube Video Section - simplified */} {(data as any)?.youtube_video_id && ( umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })} onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)} /> {(data as any).youtube_video_title ? ( {(data as any).youtube_video_title} ) : null} )} {/* Gallery Section - Mosaic of 5 images with grayscale + hover color */} {((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && ( Fotogalerie k článku {Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => { const photos = (galleryAlbumQuery.data?.photos ?? []).slice(0, 5); if (photos.length < 5) { return ( {photos.map((p: any) => ( {String(p.id)} ))} ); } return ( {String(photos[0].id)} {String(photos[1].id)} {String(photos[2].id)} {String(photos[3].id)} {String(photos[4].id)} ); })()} )} {relatedArticlesQuery.isLoading ? null : (() => { const list = ((relatedArticlesQuery.data as any)?.data || []) .filter((a: any) => a?.id !== (data as any)?.id) .slice(0, 4); if (!list.length) return null; return ( {list.map((a: any) => { const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`; return ( {a.title} {a.title} {a.published_at && ( {new Date(a.published_at).toLocaleDateString('cs-CZ')} )} ); })} ); })()} { setSelectedMatch({ ...m, competition: (m as any).competitionName, competitionName: (m as any).competitionName }); setIsMatchModalOpen(true); }} /> {(() => { const all = Array.isArray(upcomingEventsQuery.data) ? (upcomingEventsQuery.data as any[]) : []; const cat = (data as any)?.category?.name; const items = cat ? all.filter((ev: any) => !ev?.category_name || String(ev.category_name) === String(cat)).slice(0, 3) : all.slice(0, 3); if (!items.length) return null; return ( {items.map((ev: any) => ( {ev.title} {(() => { try { const d = new Date(ev.start_time); return d.toLocaleDateString('cs-CZ') + (ev.location ? ` • ${ev.location}` : ''); } catch { return ev.start_time; } })()} ))} ); })()} {/* Attachments - bottom above CTA */} {Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && ( Přílohy {(data as any).attachments.map((f: any, idx: number) => ( {f.name || f.url} ))} )} {/* Polls (Ankety) above CTA */} {data?.id && } {/* Newsletter CTA */} setIsMatchModalOpen(false)} match={selectedMatch} /> ); }; export default ArticleDetailPage;