import { RefObject, useEffect } from 'react'; const CUP_COMP_SLUGS: Record = { '4': 'world-cup' }; /** Number of milliseconds before kickoff at which PreMatch fixtures become clickable (1 hour). */ const PRE_MATCH_CLICKABLE_WINDOW_MS = 1 * 60 * 60 * 1000; /** Grace period past kickoff to keep fixture clickable while Opta updates status (5 minutes). */ const POST_KICKOFF_GRACE_MS = 5 * 60 * 1000; const LIVE_STATUSES = new Set([ 'FirstHalf', 'HalfTime', 'SecondHalf', 'ExtraFirstHalf', 'ExtraHalfTime', 'ExtraSecondHalf', 'PenaltyShootout' ]); export const useAddFixtureNavigation = (args: { ref: RefObject; sport: string; competition: string; season: string; matchStatsBasePath?: string; isApp?: boolean; liveOnly?: boolean; }) => { const { ref, sport, competition, season, matchStatsBasePath, isApp, liveOnly } = args; useEffect( () => { return setupFixtureNavigation( ref, sport, competition, season, matchStatsBasePath, isApp, liveOnly ); }, [ref] ); }; const getMatchStatsBasePath = (sport: string, competition: string) => { const cupSlug = CUP_COMP_SLUGS[competition]; return cupSlug ? `/sport/${sport}/${cupSlug}/match-stats` : `/sport/${sport}/match-stats`; }; const getTeamIdFromClassList = (classList: DOMTokenList): string | null => { let teamId: string | null = null; classList.forEach(cls => { if (cls.includes('Opta-Team-')) { teamId = cls.replace('Opta-Team-', ''); } }); return teamId; }; const isWithinClickableWindow = (kickoffTime: number, now: number): boolean => { const diff = kickoffTime - now; return ( diff >= -POST_KICKOFF_GRACE_MS && diff <= PRE_MATCH_CLICKABLE_WINDOW_MS ); }; /** * Returns true when a PreMatch fixture is within the clickable window * (i.e. kickoff is at most 1 hour from now). */ export const isPreMatchClickable = ( fixtureEl: Element, now: number = Date.now() ): boolean => { // 1. data-date attribute (epoch ms set by Opta SDK on every fixture) const dataDate = fixtureEl.getAttribute('data-date'); if (dataDate) { const kickoffTime = Number(dataDate); if (!isNaN(kickoffTime)) { return isWithinClickableWindow(kickoffTime, now); } } // 2. (full fixtures template) const abbrEl = fixtureEl.querySelector('.Opta-Time abbr'); if (abbrEl) { const kickoff = abbrEl.getAttribute('title'); if (kickoff) { const kickoffTime = new Date(kickoff).getTime(); if (!isNaN(kickoffTime)) { return isWithinClickableWindow(kickoffTime, now); } } } // 3. Fallback: parse time text from .Opta-Time (strip template, e.g. "20:15") const timeEl = fixtureEl.querySelector('.Opta-Time'); if (!timeEl) { return false; } const timeText = (timeEl.textContent || '').trim(); const timeMatch = timeText.match(/^(\d{1,2}):(\d{2})$/); if (!timeMatch) { return false; } const hours = parseInt(timeMatch[1], 10); const minutes = parseInt(timeMatch[2], 10); const today = new Date(now); const kickoffDate = new Date( today.getFullYear(), today.getMonth(), today.getDate(), hours, minutes, 0 ); return isWithinClickableWindow(kickoffDate.getTime(), now); }; /** Extract clickable fixture data. Returns null if data is missing or fixture should not be clickable. */ const getClickableFixtureData = ( el: Element, competition: string, liveOnly?: boolean ): { match: string; status: string; homeTeam: string; awayTeam: string; } | null => { const match = el.getAttribute('data-match'); const status = el.getAttribute('data-period'); let homeTeam: string | null = null; let awayTeam: string | null = null; const homeTeamClasses = el.querySelector('.Opta-Team.Opta-Home'); const awayTeamClasses = el.querySelector('.Opta-Team.Opta-Away'); if (homeTeamClasses && homeTeamClasses.classList) { homeTeam = getTeamIdFromClassList(homeTeamClasses.classList); } if (awayTeamClasses && awayTeamClasses.classList) { awayTeam = getTeamIdFromClassList(awayTeamClasses.classList); } if (!match || !status || !homeTeam || !awayTeam) { return null; } if ( (competition !== '8' && !CUP_COMP_SLUGS[competition]) || status === 'Abandoned' || (liveOnly && !LIVE_STATUSES.has(status)) || (!liveOnly && status === 'PreMatch' && !isPreMatchClickable(el)) ) { return null; } return { match, status, homeTeam, awayTeam }; }; /** Mark clickable fixtures with data-has-fixtures-page-link for CSS cursor styling. */ const markClickableFixtures = ( container: HTMLElement, competition: string, liveOnly?: boolean ) => { const fixtureCards = container.querySelectorAll('.Opta-fixture'); fixtureCards.forEach(el => { if (el.getAttribute('data-has-fixtures-page-link') === 'true') { return; } if (getClickableFixtureData(el, competition, liveOnly)) { el.setAttribute('data-has-fixtures-page-link', 'true'); } }); }; const setupFixtureNavigation = ( ref: RefObject, sport: string, competition: string, season: string, matchStatsBasePath?: string, isApp?: boolean, liveOnly?: boolean ): (() => void) => { let observer: MutationObserver | null = null; // Poll for initial Opta widget load to mark clickable fixtures let count = 0; const interval = setInterval(() => { if (count >= 25) { clearInterval(interval); return; } count++; if (!ref.current) { return; } markClickableFixtures(ref.current, competition, liveOnly); // Once container is available, set up MutationObserver to handle // Opta SDK re-renders (e.g. on resize or live data refresh) if (!observer) { observer = new MutationObserver(() => { if (ref.current) { markClickableFixtures(ref.current, competition, liveOnly); } }); observer.observe(ref.current, { childList: true, subtree: true }); } }, 500); // Event delegation: single click handler on the stable container element. // Unlike per-element listeners, this survives Opta SDK DOM re-renders. const handleClick = (event: Event) => { if (!ref.current) { return; } const target = event.target as Element; const fixtureEl = target.closest('.Opta-fixture'); if (!fixtureEl || !ref.current.contains(fixtureEl)) { return; } const data = getClickableFixtureData(fixtureEl, competition, liveOnly); if (!data) { return; } const safeWindow = window as any; const isIOS = safeWindow.webkit && safeWindow.webkit.messageHandlers && safeWindow.webkit.messageHandlers.interactiveSliceEvent; const isAndroid = safeWindow.interactiveSliceEvent && safeWindow.interactiveSliceEvent.postMessage; const baseMatchStatsPath = matchStatsBasePath || getMatchStatsBasePath(sport, competition); let navigationUrl = `${baseMatchStatsPath}?competition=${competition}` + `&season=${season}` + `&match=${data.match}` + `&status=${data.status}` + `&homeTeam=${data.homeTeam}` + `&awayTeam=${data.awayTeam}`; const appMessage = { type: 'openPage', title: CUP_COMP_SLUGS[competition] ? 'World Cup 2026' : 'Football', url: `${navigationUrl}&enableEmbeddedMode=true` }; // Remove selected state from all fixtures const allFixtures = ref.current.querySelectorAll('.Opta-fixture'); allFixtures.forEach(card => { card.classList.remove('fixture-selected'); }); if (isIOS) { fixtureEl.classList.add('fixture-selected'); safeWindow.webkit.messageHandlers.interactiveSliceEvent.postMessage( JSON.stringify(appMessage) ); } else if (isAndroid) { fixtureEl.classList.add('fixture-selected'); safeWindow.interactiveSliceEvent.postMessage(JSON.stringify(appMessage)); } else if (isApp) { // March '26: The fixture navigation is currently happening in iOS webviews, within the iframe, whilst waiting for postMessage support. return; } else { fixtureEl.classList.add('fixture-selected'); window.location.href = navigationUrl; } }; if (ref.current) { ref.current.addEventListener('click', handleClick); } return () => { clearInterval(interval); if (observer) { observer.disconnect(); } if (ref.current) { ref.current.removeEventListener('click', handleClick); } }; };