Files
natreex c9cf99e166 Update imports and Electron compatibility
- Removed unnecessary React imports.
- Adjusted package.json scripts for Electron integration.
- Updated components to replace Next.js-specific imports with Electron-compatible alternatives.
- Minor tsconfig.json changes for better compatibility.
2025-11-16 11:55:52 -05:00

383 lines
13 KiB
TypeScript

import {JSX, useEffect, useMemo, 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<number>(0);
const [isVisible, setIsVisible] = useState<boolean>(false);
const [rendered, setRendered] = useState<boolean>(false);
const filteredSteps: GuideStep[] = useMemo((): GuideStep[] => {
return steps.filter((step: GuideStep): boolean => step.id >= stepId);
}, [steps, stepId]);
const currentStepData: GuideStep = filteredSteps[currentStep];
const timeoutRef = useRef<NodeJS.Timeout | null>(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 (
<div className="fixed inset-0 z-50 font-['Lora']">
<div
className="absolute inset-0 transition-opacity duration-500"
style={{
background: rendered ? getSpotlightBackground(currentStepData) : 'rgba(0, 0, 0, 0.5)',
opacity: isVisible ? 1 : 0
}}
onClick={onClose}
/>
{rendered && (
<GuidePopup
step={currentStepData}
isVisible={isVisible}
currentStep={currentStep}
totalSteps={filteredSteps.length}
onPrevious={handlePrevious}
onNext={handleNext}
onClose={onClose}
/>
)}
</div>
);
}
/**
* 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 = useMemo(() => {
return getPopoverPosition(step);
}, [step]);
return (
<div
className={`absolute bg-tertiary border border-primary/30 rounded-xl shadow-2xl w-96 transition-all duration-300 ${
isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
}`}
style={positionStyle}
>
<div className="px-8 py-6 border-b border-secondary/40">
<div className="flex items-start justify-between">
<div className="flex-1 mr-6">
<h3 className="text-text-primary font-semibold text-xl mb-3">
{step.title}
</h3>
<div className="flex items-center space-x-4">
<span className="text-primary text-sm font-medium bg-primary/10 px-3 py-1 rounded-full">
Étape {currentStep + 1} sur {totalSteps}
</span>
<div className="flex items-center space-x-2">
{Array.from({length: totalSteps}).map((_, index) => (
<div
key={index}
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
index <= currentStep ? 'bg-primary scale-110' : 'bg-secondary/60'
}`}
/>
))}
</div>
</div>
</div>
<button
onClick={onClose}
className="text-muted hover:text-text-primary transition-colors p-2 hover:bg-secondary/30 rounded-lg"
type="button"
>
<FontAwesomeIcon icon={faXmark} className="text-lg"/>
</button>
</div>
</div>
<div className="px-8 py-8">
<div className="text-text-secondary text-base leading-relaxed space-y-4">
{step.content}
</div>
</div>
<div className="px-8 py-6 bg-secondary/20 border-t border-secondary/30 rounded-b-xl">
<div className="flex items-center justify-between">
{currentStep > 0 ? (
<button
onClick={onPrevious}
className="text-muted hover:text-text-primary text-sm px-4 py-2 rounded-lg hover:bg-secondary/30 transition-all"
type="button"
>
Précédent
</button>
) : (
<div></div>
)}
<button
onClick={onNext}
className="bg-primary hover:bg-primary-dark text-text-primary px-6 py-3 rounded-lg transition-all duration-200 text-sm font-medium shadow-lg hover:shadow-xl transform hover:scale-105"
type="button"
>
{currentStep === totalSteps - 1 ? '🎉 Terminer' : 'Continuer →'}
</button>
</div>
</div>
</div>
);
}