/** * RuntimePresentation Component * * Renders a presentation for a specific project and environment. * Used by /p/[projectSlug] and /p/[projectSlug]/stage routes. */ import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js'; import axios from 'axios'; import Head from 'next/head'; import Image from 'next/image'; import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import BaseButton from './BaseButton'; import CardBox from './CardBox'; import { OfflineToggle } from './Offline/OfflineToggle'; import LayoutGuest from '../layouts/Guest'; import { getPageTitle } from '../config'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { extractPageLinksAndElements } from '../lib/extractPageLinks'; import { usePageSwitch } from '../hooks/usePageSwitch'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { logger } from '../lib/logger'; import { buildElementStyle } from '../lib/elementStyles'; import type { RuntimeProject, RuntimePage } from '../types/runtime'; interface RuntimePresentationProps { projectSlug: string; environment: 'stage' | 'production'; } const getRows = (response: any) => Array.isArray(response?.data?.rows) ? response.data.rows : []; export default function RuntimePresentation({ projectSlug, environment, }: RuntimePresentationProps) { const [project, setProject] = useState(null); const [pages, setPages] = useState([]); const [selectedPageId, setSelectedPageId] = useState(null); const [pageHistory, setPageHistory] = useState([]); const [transitionPreview, setTransitionPreview] = useState<{ targetPageId: string; videoUrl: string; isReverse: boolean; } | null>(null); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isFullscreen, setIsFullscreen] = useState(false); const [isBackgroundReady, setIsBackgroundReady] = useState(true); const [pendingTransitionComplete, setPendingTransitionComplete] = useState(false); const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false); const transitionVideoRef = useRef(null); const lastInitializedPageIdRef = useRef(null); // API request config with custom headers for project/environment const apiConfig = useMemo( () => ({ headers: { 'X-Runtime-Project-Slug': projectSlug, 'X-Runtime-Environment': environment, }, }), [projectSlug, environment], ); // Extract page links and preload elements from ui_schema_json // This enables the neighbor graph to find connected pages for preloading const { pageLinks, preloadElements } = useMemo(() => { const result = extractPageLinksAndElements(pages); if (result.pageLinks.length > 0 || result.preloadElements.length > 0) { logger.info('[PRELOAD] Extracted page links and elements', { pageLinksCount: result.pageLinks.length, preloadElementsCount: result.preloadElements.length, pageLinks: result.pageLinks.map((link) => ({ from: link.from_pageId?.slice(-8), to: link.to_pageId?.slice(-8), hasTransition: !!link.transition?.video_url, })), }); } return result; }, [pages]); // Initialize preload orchestrator with transformed data const preloadOrchestrator = usePreloadOrchestrator({ pages, pageLinks, elements: preloadElements, currentPageId: selectedPageId, pageHistory, enabled: !isLoading && !error, }); // Initialize page switch hook for smooth background transitions const pageSwitch = usePageSwitch({ preloadCache: preloadOrchestrator ? { getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, preloadedUrls: preloadOrchestrator.preloadedUrls, } : undefined, }); // Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern) const { isBuffering, phase: transitionPhase } = useTransitionPlayback({ videoRef: transitionVideoRef, transition: transitionPreview ? { videoUrl: transitionPreview.videoUrl, reverseMode: transitionPreview.isReverse ? 'reverse' : 'none', targetPageId: transitionPreview.targetPageId, displayName: 'Transition', } : null, onComplete: async (targetPageId) => { if (targetPageId) { const targetPage = pages.find((p) => p.id === targetPageId); // Mark this page as initialized to prevent redundant effect calls lastInitializedPageIdRef.current = targetPageId; // Use shared hook to resolve blob URLs and switch page await pageSwitch.switchToPage(targetPage, () => { setSelectedPageId(targetPageId); setPageHistory((prev) => [...prev, targetPageId]); }); setIsBackgroundReady(false); // Signal that transition is complete and waiting for Image onLoad setPendingTransitionComplete(true); } else { // No target page - clean up and remove overlay const video = transitionVideoRef.current; video?.removeAttribute('src'); video?.load(); setTransitionPreview(null); setPendingTransitionComplete(false); } }, features: { useBlobUrl: true, // Don't pre-decode images in the hook - we handle it via overlay: // Overlay shows last transition frame while new page background loads behind it preDecodeImages: false, }, preload: { preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(), getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl, }, }); const toggleFullscreen = useCallback(async () => { try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen(); setIsFullscreen(true); } else { await document.exitFullscreen(); setIsFullscreen(false); } } catch (err) { logger.error( 'Fullscreen toggle failed:', err instanceof Error ? err : { error: err }, ); } }, []); useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(Boolean(document.fullscreenElement)); }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); // Fade out and remove transition overlay when background is ready useEffect(() => { if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) { // Start fade-out animation setIsOverlayFadingOut(true); // After fade completes (300ms), remove the overlay const fadeTimer = setTimeout(() => { const video = transitionVideoRef.current; video?.removeAttribute('src'); video?.load(); setTransitionPreview(null); setPendingTransitionComplete(false); setIsOverlayFadingOut(false); // Clear previous background from shared hook pageSwitch.clearPreviousBackground(); }, 300); return () => clearTimeout(fadeTimer); } }, [ pendingTransitionComplete, isBackgroundReady, isOverlayFadingOut, pageSwitch.clearPreviousBackground, ]); // Clear previous background overlay when new background is ready (direct navigation) useEffect(() => { if ( pageSwitch.isSwitching && pageSwitch.isNewBgReady && pageSwitch.previousBgImageUrl ) { // New background is ready - clear the previous background overlay pageSwitch.clearPreviousBackground(); } }, [ pageSwitch.isSwitching, pageSwitch.isNewBgReady, pageSwitch.previousBgImageUrl, pageSwitch.clearPreviousBackground, ]); // Load presentation data useEffect(() => { let isCancelled = false; const loadPresentation = async () => { try { setIsLoading(true); setError(''); // Fetch project by slug const projectsResponse = await axios.get('/projects', { ...apiConfig, params: { slug: projectSlug }, }); if (isCancelled) return; const projectRows = getRows(projectsResponse); const foundProject = projectRows.find( (p: RuntimeProject) => p.slug === projectSlug, ); if (!foundProject) { setError(`Project "${projectSlug}" not found.`); return; } setProject(foundProject); // Fetch pages for this project // (Elements and navigation are extracted from ui_schema_json) const pagesResponse = await axios.get('/tour_pages', { ...apiConfig, params: { project: foundProject.id }, }); if (isCancelled) return; const pageRows = getRows(pagesResponse); // Filter by environment and sort by sort_order // STRICT: Only show pages matching the exact environment // Production = production only, Stage = stage only, Dev = dev only const envFilteredPages = pageRows .filter((p: any) => p.environment === environment) .sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); setPages(envFilteredPages); // Set initial page (first page by sort_order) if (envFilteredPages.length > 0) { const firstPage = envFilteredPages[0]; setSelectedPageId(firstPage.id); setPageHistory([firstPage.id]); } } catch (err: any) { if (isCancelled) return; const message = err?.response?.data?.message || err?.message || 'Failed to load presentation.'; setError(message); } finally { if (!isCancelled) { setIsLoading(false); } } }; loadPresentation(); return () => { isCancelled = true; }; }, [projectSlug, environment, apiConfig]); const selectedPage = useMemo( () => pages.find((p) => p.id === selectedPageId) || null, [pages, selectedPageId], ); const pageElements = useMemo(() => { if (!selectedPage) return []; try { const uiSchema = typeof selectedPage.ui_schema_json === 'string' ? JSON.parse(selectedPage.ui_schema_json) : selectedPage.ui_schema_json; return Array.isArray(uiSchema?.elements) ? uiSchema.elements : []; } catch { return []; } }, [selectedPage]); // Set initial backgrounds when page first loads (before preload cache is populated) // The condition ensures this only runs once on initial load when backgrounds are empty. // After that, navigateToPage handles all subsequent navigation explicitly. useEffect(() => { if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) { // Only initialize when backgrounds are empty (initial load) // navigateToPage handles subsequent navigation by calling switchToPage directly if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) { lastInitializedPageIdRef.current = selectedPage.id; pageSwitch.switchToPage(selectedPage); } } }, [ selectedPage, pageSwitch.currentBgImageUrl, pageSwitch.currentBgVideoUrl, pageSwitch.switchToPage, ]); // Handle background ready state for pages without images or with videos useEffect(() => { // If no background image, or if there's a video (video takes over), mark as ready if ( !selectedPage?.background_image_url || selectedPage?.background_video_url ) { setIsBackgroundReady(true); } }, [selectedPage?.background_image_url, selectedPage?.background_video_url]); const navigateToPage = useCallback( async ( targetPageId: string, transitionVideoUrl?: string, isBack = false, ) => { const targetPage = pages.find((p) => p.id === targetPageId); if (!targetPage) return; if (transitionVideoUrl) { // Reset states from previous transition before starting new one // This prevents the fade-out effect from re-triggering when isOverlayFadingOut resets setIsOverlayFadingOut(false); setPendingTransitionComplete(false); // Play transition using useTransitionPlayback hook setTransitionPreview({ targetPageId, videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl), isReverse: isBack, }); } else { // Direct navigation - use shared hook for smooth transition // Previous background stays visible until new one is ready setIsBackgroundReady(false); // Mark this page as initialized to prevent redundant effect calls lastInitializedPageIdRef.current = targetPageId; await pageSwitch.switchToPage(targetPage, () => { setSelectedPageId(targetPageId); setPageHistory((prev) => [...prev, targetPageId]); }); } }, [pages, pageSwitch], ); const handleElementClick = useCallback( (element: any) => { // Disable navigation while transition is actively playing or buffering // Only block during active phases, not during fade-out (completed phase) const isActivelyPlaying = transitionPhase === 'preparing' || transitionPhase === 'playing' || transitionPhase === 'reversing'; if (isActivelyPlaying || isBuffering) { return; } // Support both targetPageSlug (new) and targetPageId (legacy) const targetPageSlug = element.targetPageSlug; const legacyTargetPageId = element.targetPageId; // Resolve slug to page ID, or use legacy targetPageId let targetPageId: string | undefined; if (targetPageSlug) { const targetPage = pages.find((p) => p.slug === targetPageSlug); targetPageId = targetPage?.id; } else if (legacyTargetPageId) { targetPageId = legacyTargetPageId; } // Debug: log element navigation data logger.info('Element clicked', { elementType: element.type, targetPageSlug, legacyTargetPageId, resolvedTargetPageId: targetPageId, transitionVideoUrl: element.transitionVideoUrl, hasTransition: Boolean(element.transitionVideoUrl), }); if (targetPageId) { const isBack = element.navType === 'back' || element.type === 'navigation_prev'; // Get transition video URL from element itself const transitionVideoUrl = element.transitionVideoUrl; navigateToPage(targetPageId, transitionVideoUrl, isBack); } }, [navigateToPage, pages, transitionPhase, isBuffering], ); // Render element content based on type const renderElementContent = (element: any) => { // Navigation buttons if ( element.type === 'navigation_next' || element.type === 'navigation_prev' ) { if (element.iconUrl) { // Use img tag with flexible sizing - auto for dimensions not provided const imgStyle: React.CSSProperties = { width: element.width || 'auto', height: element.height || 'auto', objectFit: 'contain', }; return ( // eslint-disable-next-line @next/next/no-img-element Navigation ); } return (
{element.navLabel || (element.type === 'navigation_next' ? 'Next' : 'Back')}
); } // Description element if (element.type === 'description') { if (element.iconUrl) { return (
Description
); } const bgColor = element.descriptionBackgroundColor || 'transparent'; return (

{element.descriptionTitle || ''}

{element.descriptionText && (

{element.descriptionText}

)}
); } // Tooltip if (element.type === 'tooltip') { if (element.iconUrl) { return (
Tooltip
); } return (

{element.tooltipTitle}

{element.tooltipText}

); } // Video player if (element.type === 'video_player' && element.mediaUrl) { return (