import React, {JSX, useEffect, useRef, useState} from 'react'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {faXmark} from '@fortawesome/free-solid-svg-icons'; export type GuidePosition = 'top' | 'bottom' | 'left' | 'right' | 'auto' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; export interface GuideStep { id: number; x?: number; y?: number; title: string; content: React.ReactNode; targetSelector?: string; highlightRadius?: number; position?: GuidePosition; } interface GuideTourProps { stepId: number; steps: GuideStep[]; onClose: () => void; onComplete: () => void; } /** * Generates the spotlight background style for a given guide step. * * @param {GuideStep} step - The guide step containing information about the target element, * position, and properties for spotlight rendering. * @return {string} The CSS background string representing the spotlight effect. */ function getSpotlightBackground(step: GuideStep): string { if (step.x !== undefined && step.y !== undefined) { return 'rgba(0, 0, 0, 0.5)'; } if (!step.targetSelector) { return 'rgba(0, 0, 0, 0.5)'; } const element = document.querySelector(step.targetSelector) as HTMLElement | null; if (!element) { return 'rgba(0, 0, 0, 0.5)'; } const rect: DOMRect = element.getBoundingClientRect(); const centerX: number = rect.left + rect.width / 2; const centerY: number = rect.top + rect.height / 2; const radius: number = Math.max(rect.width, rect.height) / 2 + (step.highlightRadius || 10); return `radial-gradient(circle at ${centerX}px ${centerY}px, transparent ${radius}px, rgba(0, 0, 0, 0.65) ${radius + 20}px)`; } /** * Determines the position of a popover element based on the provided guide step properties. * * @param {GuideStep} step - An object containing the configuration for positioning the popover, including its x and y coordinates, target selector, and preferred position. * @return {React.CSSProperties} An object representing the CSS properties to position the popover, including `left`, `top`, and optionally `transform` values. */ function getPopoverPosition(step: GuideStep): React.CSSProperties { if (step.x !== undefined && step.y !== undefined) { return { left: `${step.x}%`, top: `${step.y}%`, transform: 'translate(-50%, -50%)' }; } if (!step.targetSelector) { return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }; } const element = document.querySelector(step.targetSelector) as HTMLElement | null; if (!element) { return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }; } const rect: DOMRect = element.getBoundingClientRect(); const {left, top, width, height} = rect; const popoverWidth = 420; const popoverHeight = 300; const margin = 20; const position: GuidePosition = step.position || 'auto'; switch (position) { case 'top': return { left: `${Math.max(margin, Math.min(left + width / 2 - popoverWidth / 2, window.innerWidth - popoverWidth - margin))}px`, top: `${Math.max(margin, top - popoverHeight - margin)}px`, }; case 'bottom': return { left: `${Math.max(margin, Math.min(left + width / 2 - popoverWidth / 2, window.innerWidth - popoverWidth - margin))}px`, top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`, }; case 'left': return { left: `${Math.max(margin, left - popoverWidth - margin)}px`, top: `${Math.max(margin, Math.min(top + height / 2 - popoverHeight / 2, window.innerHeight - popoverHeight - margin))}px`, }; case 'right': return { left: `${Math.min(left + width + margin, window.innerWidth - popoverWidth - margin)}px`, top: `${Math.max(margin, Math.min(top + height / 2 - popoverHeight / 2, window.innerHeight - popoverHeight - margin))}px`, }; case 'top-left': return { left: `${Math.max(margin, left)}px`, top: `${Math.max(margin, top - popoverHeight - margin)}px`, }; case 'top-right': return { left: `${Math.max(margin, Math.min(left + width - popoverWidth, window.innerWidth - popoverWidth - margin))}px`, top: `${Math.max(margin, top - popoverHeight - margin)}px`, }; case 'bottom-left': return { left: `${Math.max(margin, left)}px`, top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`, }; case 'bottom-right': return { left: `${Math.max(margin, Math.min(left + width - popoverWidth, window.innerWidth - popoverWidth - margin))}px`, top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`, }; case 'auto': default: let x: number = left + width + margin; let y: number = top + height / 2 - popoverHeight / 2; if (x + popoverWidth > window.innerWidth - margin) { x = left - popoverWidth - margin; } if (x < margin) { x = left + width / 2 - popoverWidth / 2; y = top + height + margin; } x = Math.max(margin, Math.min(x, window.innerWidth - popoverWidth - margin)); y = Math.max(margin, Math.min(y, window.innerHeight - popoverHeight - margin)); return { left: `${x}px`, top: `${y}px`, }; } } /** * A component that guides the user through a series of steps. * Displays a sequence of instructional overlay elements based on the provided steps. * Handles navigation between steps and supports custom actions upon completion or closure. * * @param {Object} props - The properties object. * @param {number} props.stepId - The initial step ID to start the guide. * @param {Array} props.steps - An array of objects representing each step of the guide. * Each step should include necessary details such as its ID and other metadata. * @param {Function} props.onClose - Callback function executed when the guide is closed manually. * @param {Function} props.onComplete - Callback function executed when the guide is completed after the last step. * * @return {JSX.Element|null} The guide tour component that renders the step-by-step instructions, * or null if no steps are available or the initial conditions aren't met. */ export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTourProps): JSX.Element | null { const [currentStep, setCurrentStep] = useState(0); const [isVisible, setIsVisible] = useState(false); const [rendered, setRendered] = useState(false); const filteredSteps: GuideStep[] = React.useMemo((): GuideStep[] => { return steps.filter((step: GuideStep): boolean => step.id >= stepId); }, [steps, stepId]); const currentStepData: GuideStep = filteredSteps[currentStep]; const timeoutRef = useRef(null); const showStep = (index: number) => { setIsVisible(false); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout((): void => { setCurrentStep(index); setRendered(false); const step: GuideStep = filteredSteps[index]; if (step?.targetSelector) { const element = document.querySelector(step.targetSelector) as HTMLElement; if (element) { element.scrollIntoView({behavior: 'smooth', block: 'center'}); } } timeoutRef.current = setTimeout((): void => { setRendered(true); timeoutRef.current = setTimeout((): void => { setIsVisible(true); }, 50); }, 600); }, 200); }; useEffect((): () => void => { showStep(0); return (): void => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const handleNext: () => void = (): void => { if (currentStep < filteredSteps.length - 1) { showStep(currentStep + 1); } else { onComplete(); } }; const handlePrevious: () => void = (): void => { if (currentStep > 0) { showStep(currentStep - 1); } }; if (!filteredSteps.length || !currentStepData) { return null; } return (
{rendered && ( )}
); } /** * Functional component that displays a guide popup. This popup includes step-based navigation, * title, content, and control buttons for navigating between steps or closing the popup. * * @param {object} params - The parameters for the GuidePopup component. * @param {GuideStep} params.step - The current guide step data, containing title and content. * @param {boolean} params.isVisible - Determines whether the popup is visible. * @param {number} params.currentStep - The index of the current step in the guide. * @param {number} params.totalSteps - Total number of steps in the guide. * @param {function} params.onPrevious - Callback invoked when navigating to the previous step. * @param {function} params.onNext - Callback invoked when navigating to the next step. * @param {function} params.onClose - Callback invoked when closing the popup. * @return {JSX.Element} The rendered GuidePopup component. */ function GuidePopup( { step, isVisible, currentStep, totalSteps, onPrevious, onNext, onClose }: { step: GuideStep; isVisible: boolean; currentStep: number; totalSteps: number; onPrevious: () => void; onNext: () => void; onClose: () => void; }): JSX.Element { const positionStyle = React.useMemo(() => { return getPopoverPosition(step); }, [step]); return (

{step.title}

Étape {currentStep + 1} sur {totalSteps}
{Array.from({length: totalSteps}).map((_, index) => (
))}
{step.content}
{currentStep > 0 ? ( ) : (
)}
); }