import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { X, ChevronLeft, ChevronRight } from 'lucide-react' import { getTourStepCopy, getTourTargetSelector, getTourTargetRetryDelay, isCenteredTourStep, useAppTour } from '../context/AppTourContext.tsx' interface SpotlightRect { top: number left: number width: number height: number } const TOOLTIP_EDGE_MARGIN = 16 const TOOLTIP_ESTIMATED_HEIGHT = 240 const TOOLTIP_WIDTH = 420 const TARGET_VIEWPORT_MARGIN = 24 function clampTooltipTop(preferred: number): number { const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop)) } function computeTooltipLeft(spotlight: SpotlightRect): number { const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2) const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2 const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft)) } function buildCutoutClipPath(rect: SpotlightRect): string { const right = rect.left + rect.width const bottom = rect.top + rect.height return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)` } function computeTooltipTop(spotlight: SpotlightRect): number { const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN const below = spotlight.top + spotlight.height + 12 if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) { return clampTooltipTop(below) } const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT if (above >= TOOLTIP_EDGE_MARGIN) { return clampTooltipTop(above) } return clampTooltipTop(below) } function isTargetVisibleInViewport(rect: DOMRect): boolean { return ( rect.top >= TARGET_VIEWPORT_MARGIN && rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN ) } function measureSpotlight(el: Element): SpotlightRect | null { const rect = el.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return null const padding = 8 return { top: Math.max(8, rect.top - padding), left: Math.max(8, rect.left - padding), width: rect.width + padding * 2, height: rect.height + padding * 2 } } export default function AppTourOverlay() { const { t } = useTranslation() const { isActive, isDemoTour, currentStepId, currentStepIndex, totalSteps, layoutTick, nextStep, prevStep, skipTour } = useAppTour() const [spotlight, setSpotlight] = useState(null) const skipTourRef = useRef(skipTour) skipTourRef.current = skipTour useLayoutEffect(() => { if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) { setSpotlight(null) return } let cancelled = false const updateSpotlight = () => { if (cancelled) return const selector = getTourTargetSelector(currentStepId) if (!selector) { setSpotlight(null) return } const el = document.querySelector(selector) if (!el) { setSpotlight(null) return } const rect = el.getBoundingClientRect() if (!isTargetVisibleInViewport(rect)) { el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' }) window.requestAnimationFrame(() => { if (cancelled) return const next = measureSpotlight(el) setSpotlight(next) }) return } setSpotlight(measureSpotlight(el)) } updateSpotlight() window.addEventListener('resize', updateSpotlight) window.addEventListener('scroll', updateSpotlight, true) const retryDelays = currentStepId === 'entry_track' ? [400, 700, 1100, 1600] : [getTourTargetRetryDelay(currentStepId), 120, 280, 480] const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay)) return () => { cancelled = true for (const timer of timers) window.clearTimeout(timer) window.removeEventListener('resize', updateSpotlight) window.removeEventListener('scroll', updateSpotlight, true) } }, [currentStepId, isActive, layoutTick]) useEffect(() => { if (!isActive) return document.body.classList.add('app-tour-active') return () => document.body.classList.remove('app-tour-active') }, [isActive]) useEffect(() => { if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) return const selector = getTourTargetSelector(currentStepId) if (!selector) return const el = document.querySelector(selector) el?.classList.add('app-tour-target-active') return () => el?.classList.remove('app-tour-target-active') }, [currentStepId, isActive]) useEffect(() => { if (!isActive) return const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') skipTourRef.current() } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [isActive]) if (!isActive || !currentStepId) return null const { title, body } = getTourStepCopy(currentStepId, t, { demoMode: isDemoTour }) const centered = isCenteredTourStep(currentStepId) const tooltipStyle = centered ? undefined : spotlight ? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) } : { top: '20%' } const tooltipClassName = [ 'app-tour-tooltip', centered ? 'centered' : '', !centered && spotlight ? 'app-tour-tooltip--anchored' : '' ] .filter(Boolean) .join(' ') const backdropStyle = spotlight && !centered ? { clipPath: buildCutoutClipPath(spotlight) } : undefined return (
{!centered && spotlight && (
)}

{t('tour.progress', { current: currentStepIndex + 1, total: totalSteps })}

{title}

{body}

) }