import { useEffect, useMemo, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import { ArrowLeft, ArrowRight, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { useWalkthroughStore } from '@/stores/walkthroughStore' import { useWalkthroughActions } from './actions' import { getSteps, type StepPlacement } from './steps' import './walkthrough.css' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const CARD_W = 320 const CARD_H_EST = 210 const GAP = 16 const PAD = 6 // spotlight padding around the target /** Coach-card position from the target rect + requested placement. */ function cardPosition(rect: DOMRect | null, placement: StepPlacement): { left: number; top: number } { const vw = window.innerWidth const vh = window.innerHeight const clampL = (l: number) => Math.max(8, Math.min(l, vw - CARD_W - 8)) const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8)) if (!rect) return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) } let side = placement if (side === 'auto') { side = rect.right + GAP + CARD_W < vw ? 'right' : rect.left - GAP - CARD_W > 0 ? 'left' : 'bottom' } switch (side) { case 'right': return { left: clampL(rect.right + GAP), top: clampT(rect.top) } case 'left': return { left: clampL(rect.left - GAP - CARD_W), top: clampT(rect.top) } case 'top': return { left: clampL(rect.left), top: clampT(rect.top - GAP - CARD_H_EST) } case 'bottom': return { left: clampL(rect.left), top: clampT(rect.bottom + GAP) } case 'center': default: return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) } } } export function WalkthroughOverlay() { const { isActive, stepIndex, total, setTotal, next, prev, skip } = useWalkthroughStore() const actions = useWalkthroughActions() const steps = useMemo(() => getSteps(STANDALONE), []) const step = steps[stepIndex] // anchorRect: the element the step rings (sidebar/toolbar button). dialogRect: // any open app modal, always kept lit so it stays readable during modal steps. const [anchorRect, setAnchorRect] = useState(null) const [dialogRect, setDialogRect] = useState(null) // Keep the store's total in sync with the mode-filtered step list. useEffect(() => { if (isActive) setTotal(steps.length) }, [isActive, steps.length, setTotal]) // Run the step's action on enter (close leftovers, open the right modal / inject // demo data). Close everything when the tour ends. useEffect(() => { if (!actions) return if (!isActive) { actions.closeAll(); return } actions.closeAll() const key = step?.action as keyof typeof actions | undefined if (key && typeof actions[key] === 'function') (actions[key] as () => void)() // eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive, stepIndex, actions]) // Track target rects. Polls each frame because modals mount asynchronously after // the step action fires, and layout shifts as they animate in. useEffect(() => { if (!isActive) return let raf = 0 const update = () => { const anchorEl = step?.anchor ? document.querySelector(step.anchor) : null setAnchorRect(anchorEl ? anchorEl.getBoundingClientRect() : null) // Any open app modal, excluding our own coach card (inside the overlay). const dialogEl = Array.from(document.querySelectorAll('[role="dialog"]')) .find((el) => !el.closest('[data-walkthrough-overlay]')) setDialogRect(dialogEl ? dialogEl.getBoundingClientRect() : null) raf = window.requestAnimationFrame(update) } update() window.addEventListener('resize', update) window.addEventListener('scroll', update, true) return () => { window.cancelAnimationFrame(raf) window.removeEventListener('resize', update) window.removeEventListener('scroll', update, true) } }, [isActive, stepIndex, step?.anchor]) const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); next() } else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() } else if (e.key === 'Escape') { e.preventDefault(); skip() } }, [next, prev, skip]) useEffect(() => { if (!isActive) return window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey) }, [isActive, handleKey]) if (!isActive || !step) return null const isLast = stepIndex >= total - 1 // Ring / card follow the anchor if the step has one, else the open modal. const ringRect = anchorRect ?? dialogRect const pos = cardPosition(ringRect, step.placement ?? 'auto') // Cut-outs keep both the ringed control and any open modal lit. const holes = [anchorRect, dialogRect].filter((r): r is DOMRect => r !== null) return createPortal(
{/* Backdrop: dim everything except the cut-out holes (multi-hole SVG mask so a ringed button AND an open modal can both stay readable). */} {holes.map((r, i) => ( ))} {/* Pulsing ring on the primary target */} {ringRect && (
)} {/* Coach card */}

{step.title}

{step.body}

{step.link && ( {step.link.label} )}
{/* Progress dots */}
{steps.map((s, i) => ( ))}
{stepIndex + 1}/{steps.length}
, document.body, ) }