/** * FeatureTour - Interactive spotlight-based product tour. * * Highlights specific elements on the page by targeting them via * CSS selectors. Displays a tooltip with step information next to * each highlighted element. Supports next, back, and skip actions. * * Steps are provided as an array of { target, title, description, placement }. * The target is a CSS selector string pointing to the DOM element to highlight. */ import { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { motion, AnimatePresence } from "framer-motion"; import { ChevronRightIcon, ChevronLeftIcon, XMarkIcon, } from "@heroicons/react/24/outline"; import PropTypes from "prop-types"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const TOOLTIP_OFFSET = 12; const SPOTLIGHT_PADDING = 8; const TOOLTIP_VARIANTS = { hidden: { opacity: 0, y: 8, scale: 0.95 }, visible: { opacity: 1, y: 0, scale: 1 }, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Compute the tooltip position relative to the target rect. * * @param {DOMRect} targetRect - Bounding rect of the target element. * @param {string} placement - One of: top, bottom, left, right. * @param {number} tooltipWidth * @returns {{ top: number, left: number }} */ function computeTooltipPosition(targetRect, placement, tooltipWidth = 300) { const centerX = targetRect.left + targetRect.width / 2 - tooltipWidth / 2; const clampedX = Math.max(16, Math.min(centerX, window.innerWidth - tooltipWidth - 16)); switch (placement) { case "top": return { top: targetRect.top - TOOLTIP_OFFSET - 10, left: clampedX, transformOrigin: "bottom center", }; case "left": return { top: targetRect.top + targetRect.height / 2 - 40, left: Math.max(16, targetRect.left - tooltipWidth - TOOLTIP_OFFSET), transformOrigin: "right center", }; case "right": return { top: targetRect.top + targetRect.height / 2 - 40, left: targetRect.right + TOOLTIP_OFFSET, transformOrigin: "left center", }; case "bottom": default: return { top: targetRect.bottom + TOOLTIP_OFFSET, left: clampedX, transformOrigin: "top center", }; } } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- function FeatureTour({ steps, isOpen, onClose, onComplete, startAt = 0, }) { const [currentStep, setCurrentStep] = useState(startAt); const [targetRect, setTargetRect] = useState(null); const tooltipRef = useRef(null); const step = steps[currentStep]; const isFirst = currentStep === 0; const isLast = currentStep === steps.length - 1; // --------------------------------------------------------------------------- // Find and measure the target element // --------------------------------------------------------------------------- useEffect(() => { if (!isOpen || !step?.target) { setTargetRect(null); return; } function measure() { const el = document.querySelector(step.target); if (el) { const rect = el.getBoundingClientRect(); setTargetRect({ top: rect.top - SPOTLIGHT_PADDING, left: rect.left - SPOTLIGHT_PADDING, width: rect.width + SPOTLIGHT_PADDING * 2, height: rect.height + SPOTLIGHT_PADDING * 2, }); el.scrollIntoView({ behavior: "smooth", block: "nearest" }); } else { setTargetRect(null); } } // Measure after a short delay to allow DOM to settle const timer = setTimeout(measure, 150); window.addEventListener("resize", measure); window.addEventListener("scroll", measure, true); return () => { clearTimeout(timer); window.removeEventListener("resize", measure); window.removeEventListener("scroll", measure, true); }; }, [isOpen, currentStep, step]); // Reset step when opening useEffect(() => { if (isOpen) { setCurrentStep(startAt); } }, [isOpen, startAt]); // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- const handleNext = useCallback(() => { if (isLast) { onComplete?.(); onClose(); } else { setCurrentStep((prev) => prev + 1); } }, [isLast, onClose, onComplete]); const handlePrev = useCallback(() => { if (!isFirst) { setCurrentStep((prev) => prev - 1); } }, [isFirst]); const handleSkip = useCallback(() => { onClose(); }, [onClose]); // Keyboard navigation useEffect(() => { if (!isOpen) return; function handleKey(e) { if (e.key === "Escape") handleSkip(); if (e.key === "ArrowRight" || e.key === "Enter") handleNext(); if (e.key === "ArrowLeft") handlePrev(); } document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [isOpen, handleNext, handlePrev, handleSkip]); // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- if (!isOpen || !step) return null; const placement = step.placement || "bottom"; const tooltipPos = targetRect ? computeTooltipPosition(targetRect, placement) : { top: "50%", left: "50%", transform: "translate(-50%, -50%)" }; const content = ( {isOpen && ( <> {/* Dark overlay */}