Initial commit

This commit is contained in:
2026-01-11 22:04:05 +01:00
commit 87a8b6b844
549 changed files with 96211 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
"use client";
import React, { useState } from "react";
import { HelpCircle, X } from "lucide-react";
import { cn } from "@/lib/utils";
interface HelpButtonProps {
/**
* Contenu de l'aide à afficher
*/
title: string;
content: string | React.ReactNode;
/**
* Position du tooltip
* @default "top"
*/
position?: "top" | "bottom" | "left" | "right";
/**
* Variante du bouton
* @default "icon"
*/
variant?: "icon" | "text" | "inline";
/**
* Classe CSS personnalisée
*/
className?: string;
/**
* Si vrai, le tooltip est toujours visible
* @default false
*/
alwaysOpen?: boolean;
}
export function HelpButton({
title,
content,
position = "top",
variant = "icon",
className,
alwaysOpen = false,
}: HelpButtonProps) {
const [isOpen, setIsOpen] = useState(alwaysOpen);
const positionClasses = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-3",
bottom: "top-full left-1/2 -translate-x-1/2 mt-3",
left: "right-full top-1/2 -translate-y-1/2 mr-3",
right: "left-full top-1/2 -translate-y-1/2 ml-3",
};
const arrowClasses = {
top: "top-full left-1/2 -translate-x-1/2 -mt-1 border-t-slate-900 border-r-transparent border-b-transparent border-l-transparent",
bottom: "bottom-full left-1/2 -translate-x-1/2 -mb-1 border-b-slate-900 border-r-transparent border-t-transparent border-l-transparent",
left: "left-full top-1/2 -translate-y-1/2 -ml-1 border-l-slate-900 border-t-transparent border-r-transparent border-b-transparent",
right: "right-full top-1/2 -translate-y-1/2 -mr-1 border-r-slate-900 border-t-transparent border-l-transparent border-b-transparent",
};
const buttonVariants = {
icon: (
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"inline-flex items-center justify-center w-5 h-5 rounded-full transition-all duration-200",
"text-slate-400 hover:text-indigo-600 hover:bg-indigo-50",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1",
isOpen && "bg-indigo-100 text-indigo-700",
className
)}
aria-label={`Aide: ${title}`}
>
<HelpCircle className="w-4 h-4" />
</button>
),
text: (
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md transition-all duration-200",
"text-xs font-medium text-slate-500 hover:text-indigo-600 hover:bg-indigo-50",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1",
isOpen && "bg-indigo-100 text-indigo-700",
className
)}
aria-label={`Aide: ${title}`}
>
<HelpCircle className="w-3.5 h-3.5" />
<span>Aide</span>
</button>
),
inline: (
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-700 underline underline-dotted underline-offset-2",
"transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 rounded px-1",
className
)}
aria-label={`Aide: ${title}`}
>
<span>{title}</span>
<HelpCircle className="w-3 h-3" />
</button>
),
};
return (
<div className="relative inline-flex items-center">
{buttonVariants[variant]}
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
{/* Tooltip */}
<div
className={cn(
"absolute z-50 w-80 max-w-sm",
positionClasses[position]
)}
>
<div className="relative bg-slate-900 text-slate-100 rounded-lg shadow-xl p-4">
{/* Arrow */}
<div
className={cn(
"absolute w-0 h-0 border-8",
arrowClasses[position]
)}
aria-hidden="true"
/>
{/* Close button */}
<button
onClick={() => setIsOpen(false)}
className="absolute top-2 right-2 text-slate-400 hover:text-white transition-colors"
aria-label="Fermer"
>
<X className="w-4 h-4" />
</button>
{/* Content */}
<div className="pr-6">
<h4 className="text-sm font-bold text-white mb-2">{title}</h4>
<div className="text-xs text-slate-300 leading-relaxed space-y-2">
{typeof content === "string" ? (
<p>{content}</p>
) : (
content
)}
</div>
</div>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import React from "react";
import { X } from "lucide-react";
import * as Dialog from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
interface HelpModalProps {
/**
* Contrôle l'ouverture du modal
*/
open?: boolean;
/**
* Callback lors de la fermeture
*/
onOpenChange?: (open: boolean) => void;
/**
* Titre du modal
*/
title: string;
/**
* Contenu du modal
*/
children: React.ReactNode;
/**
* Classe CSS personnalisée pour le contenu
*/
contentClassName?: string;
/**
* Taille du modal
* @default "md"
*/
size?: "sm" | "md" | "lg" | "xl" | "full";
}
const sizeClasses = {
sm: "max-w-md",
md: "max-w-lg",
lg: "max-w-2xl",
xl: "max-w-4xl",
full: "max-w-6xl",
};
export function HelpModal({
open,
onOpenChange,
title,
children,
contentClassName,
size = "md",
}: HelpModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
className={cn(
"fixed inset-0 bg-black/50 backdrop-blur-sm z-50",
"transition-opacity duration-200",
"data-[state=open]:opacity-100",
"data-[state=closed]:opacity-0"
)}
/>
<Dialog.Content
className={cn(
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"w-full",
sizeClasses[size],
"bg-white rounded-2xl shadow-2xl z-50",
"border border-slate-200",
"transition-all duration-200",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2",
"data-[state=closed]:slide-out-to-top-[48%]",
"data-[state=open]:slide-in-from-left-1/2",
"data-[state=open]:slide-in-from-top-[48%]"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<Dialog.Title className="text-lg font-bold text-slate-900">
{title}
</Dialog.Title>
<Dialog.Close
className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
"text-slate-400 hover:text-slate-700 hover:bg-slate-100",
"transition-colors duration-150",
"focus:outline-none focus:ring-2 focus:ring-indigo-500"
)}
>
<X className="w-5 h-5" />
</Dialog.Close>
</div>
{/* Content */}
<div className={cn("px-6 py-4 max-h-[60vh] overflow-y-auto", contentClassName)}>
{children}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,383 @@
"use client";
import React, { useState, useEffect } from "react";
import {
X,
Search,
BookOpen,
FileText,
ArrowRight,
ChevronRight,
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
import * as Dialog from "@radix-ui/react-dialog";
// Types pour la structure de documentation
interface DocSection {
id: string;
title: string;
description?: string;
icon?: React.ReactNode;
children?: DocSection[];
content?: string;
}
// Structure de documentation basée sur les fichiers markdown
const DOCUMENTATION_STRUCTURE: DocSection[] = [
{
id: "getting-started",
title: "Démarrage Rapide",
icon: <BookOpen className="w-5 h-5" />,
children: [
{
id: "import-data",
title: "Importer vos données",
description: "Formats supportés : CSV, Excel",
content: "user-guide#importer-vos-données",
},
{
id: "explore-data",
title: "Explorer vos données",
description: "Table intelligente et détection d'outliers",
content: "user-guide#explorer-vos-données",
},
{
id: "start-analysis",
title: "Lancer une analyse",
description: "Corrélation et régression",
content: "user-guide#lancer-une-analyse",
},
],
},
{
id: "correlation",
title: "Analyse de Corrélation",
icon: <FileText className="w-5 h-5" />,
children: [
{
id: "correlation-methods",
title: "Méthodes de corrélation",
description: "Pearson, Spearman, Kendall",
content: "correlation-guide#les-trois-méthodes",
},
{
id: "interpret-heatmap",
title: "Interpréter la heatmap",
description: "Comprendre les couleurs et valeurs",
content: "correlation-guide#interpréter-la-matrice",
},
{
id: "multicollinearity",
title: "Multicolinéarité",
description: "Détecter et éviter les corrélations entre prédicteurs",
content: "correlation-guide#multicolinéarité",
},
{
id: "p-values",
title: "P-values et significativité",
description: "Comprendre la significativité statistique",
content: "correlation-guide#p-values-et-significativité",
},
],
},
{
id: "regression",
title: "Régression Statistique",
icon: <FileText className="w-5 h-5" />,
children: [
{
id: "model-types",
title: "Types de modèles",
description: "Linéaire, logistique, polynomial, exponentielle",
content: "regression-guide#types-de-modèles",
},
{
id: "configure-model",
title: "Configuration du modèle",
description: "Choisir cible et prédicteurs",
content: "regression-guide#configuration-du-modèle",
},
{
id: "interpret-results",
title: "Interpréter les résultats",
description: "R², coefficients, p-values",
content: "regression-guide#interprétation-des-résultats",
},
{
id: "model-equations",
title: "Équations du modèle",
description: "Exporter en Python, Excel, LaTeX",
content: "regression-guide#équations-du-modèle",
},
],
},
{
id: "outliers",
title: "Gestion des Outliers",
icon: <FileText className="w-5 h-5" />,
children: [
{
id: "outlier-types",
title: "Types d'outliers",
description: "Univariés vs multivariés",
content: "outlier-guide#types-doutliers",
},
{
id: "detection-methods",
title: "Méthodes de détection",
description: "IQR et Isolation Forest",
content: "outlier-guide#méthodes-de-détection",
},
{
id: "visual-indicators",
title: "Indicateurs visuels",
description: "Cercles rouge et violet",
content: "outlier-guide#indicateurs-visuels",
},
{
id: "manage-outliers",
title: "Gérer les outliers",
description: "Processus d'exclusion",
content: "outlier-guide#processus-de-gestion",
},
],
},
];
interface HelpPanelProps {
/**
* Contrôle l'ouverture du panel
*/
open?: boolean;
/**
* Callback lors de la fermeture
*/
onOpenChange?: (open: boolean) => void;
}
export function HelpPanel({ open: controlledOpen, onOpenChange }: HelpPanelProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(["getting-started"])
);
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const setOpen = (newOpen: boolean) => {
if (controlledOpen === undefined) {
setInternalOpen(newOpen);
}
onOpenChange?.(newOpen);
};
const toggleSection = (sectionId: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(sectionId)) {
next.delete(sectionId);
} else {
next.add(sectionId);
}
return next;
});
};
// Filtrer les sections basées sur la recherche
const filteredStructure = searchQuery
? DOCUMENTATION_STRUCTURE.map((section) => ({
...section,
children: section.children?.filter(
(child) =>
child.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
child.description?.toLowerCase().includes(searchQuery.toLowerCase())
),
})).filter((section) => (section.children?.length || 0) > 0)
: DOCUMENTATION_STRUCTURE;
// Ouvrir automatiquement les sections lors de la recherche
useEffect(() => {
if (searchQuery) {
setExpandedSections(new Set(filteredStructure.map((s) => s.id)));
}
}, [searchQuery, filteredStructure]);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Overlay
className={cn(
"fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
"transition-opacity duration-200",
open ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={() => setOpen(false)}
/>
<Dialog.Content
className={cn(
"fixed top-0 right-0 bottom-0 w-[480px] max-w-[90vw] bg-white z-50",
"shadow-2xl border-l border-slate-200",
"transition-transform duration-200 ease-out",
open ? "translate-x-0" : "translate-x-full"
)}
>
{/* Header */}
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 z-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md">
?
</div>
<div>
<Dialog.Title className="text-lg font-bold text-slate-900">
Centre d'Aide
</Dialog.Title>
<Dialog.Description className="text-sm text-slate-500">
Guides et documentation
</Dialog.Description>
</div>
</div>
<Dialog.Close
className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
"text-slate-400 hover:text-slate-700 hover:bg-slate-100",
"transition-colors duration-150"
)}
>
<X className="w-5 h-5" />
</Dialog.Close>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Rechercher dans les guides..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"w-full pl-10 pr-4 py-2.5 rounded-lg",
"bg-slate-50 border border-slate-200",
"text-sm text-slate-900 placeholder:text-slate-400",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent",
"transition-all duration-150"
)}
/>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-2">
{filteredStructure.map((section) => {
const isExpanded = expandedSections.has(section.id);
const hasChildren = section.children && section.children.length > 0;
return (
<div key={section.id} className="border border-slate-200 rounded-lg overflow-hidden">
{/* Section Header */}
<button
onClick={() => toggleSection(section.id)}
disabled={!hasChildren}
className={cn(
"w-full flex items-center justify-between px-4 py-3",
"bg-slate-50 hover:bg-slate-100",
"transition-colors duration-150",
!hasChildren && "cursor-default"
)}
>
<div className="flex items-center gap-3">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
"bg-indigo-100 text-indigo-600"
)}>
{section.icon}
</div>
<span className="font-semibold text-sm text-slate-900">
{section.title}
</span>
</div>
{hasChildren && (
<ChevronRight
className={cn(
"w-4 h-4 text-slate-400 transition-transform duration-200",
isExpanded && "rotate-90"
)}
/>
)}
</button>
{/* Section Children */}
{isExpanded && hasChildren && (
<div className="border-t border-slate-200 bg-white divide-y divide-slate-100">
{section.children!.map((child) => (
<a
key={child.id}
href={`/docs/${child.content}`}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-start gap-3 px-4 py-3",
"hover:bg-indigo-50/50",
"transition-colors duration-150",
"group"
)}
>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-900 group-hover:text-indigo-700 transition-colors">
{child.title}
</span>
<ExternalLink className="w-3.5 h-3.5 text-slate-400 group-hover:text-indigo-600 transition-colors shrink-0" />
</div>
{child.description && (
<p className="text-xs text-slate-500 leading-relaxed">
{child.description}
</p>
)}
</div>
</a>
))}
</div>
)}
</div>
);
})}
{filteredStructure.length === 0 && (
<div className="text-center py-12">
<Search className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="text-sm text-slate-500">Aucun résultat pour &quot;{searchQuery}&quot;</p>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-white border-t border-slate-200 px-6 py-4">
<a
href="/docs/readme"
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center justify-center gap-2 w-full",
"px-4 py-2.5 rounded-lg",
"bg-gradient-to-r from-indigo-600 to-purple-600",
"text-white text-sm font-semibold",
"hover:from-indigo-700 hover:to-purple-700",
"transition-all duration-150",
"shadow-md hover:shadow-lg"
)}
>
<BookOpen className="w-4 h-4" />
<span>Voir toute la documentation</span>
<ArrowRight className="w-4 h-4" />
</a>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import React, { useState } from "react";
import { Info } from "lucide-react";
import { cn } from "@/lib/utils";
interface HelpTooltipProps {
/**
* Contenu du tooltip
*/
content: string | React.ReactNode;
/**
* Position du tooltip
* @default "top"
*/
position?: "top" | "bottom" | "left" | "right";
/**
* Largeur maximale du tooltip
* @default "250px"
*/
maxWidth?: string;
/**
* Classe CSS personnalisée pour le wrapper
*/
className?: string;
/**
* Si vrai, affiche une icône d'information
* @default true
*/
showIcon?: boolean;
/**
* Contenu personnalisé pour déclencher le tooltip
*/
trigger?: React.ReactNode;
}
export function HelpTooltip({
content,
position = "top",
maxWidth = "250px",
className,
showIcon = true,
trigger,
}: HelpTooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const positionClasses = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2 pb-1",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2 pt-1",
left: "right-full top-1/2 -translate-y-1/2 mr-2 pr-1",
right: "left-full top-1/2 -translate-y-1/2 ml-2 pl-1",
};
const arrowClasses = {
top: "top-full left-1/2 -translate-x-1/2 border-t-slate-800 border-r-transparent border-b-transparent border-l-transparent",
bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-slate-800 border-r-transparent border-t-transparent border-l-transparent",
left: "left-full top-1/2 -translate-y-1/2 border-l-slate-800 border-t-transparent border-r-transparent border-b-transparent",
right: "right-full top-1/2 -translate-y-1/2 border-r-slate-800 border-t-transparent border-l-transparent border-b-transparent",
};
const defaultTrigger = (
<button
type="button"
className={cn(
"inline-flex items-center justify-center w-4 h-4 rounded-full",
"text-slate-400 hover:text-indigo-600 hover:bg-indigo-50",
"transition-colors duration-150",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1",
className
)}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
aria-label="Information"
>
<Info className="w-3 h-3" strokeWidth={2.5} />
</button>
);
return (
<div className="relative inline-flex">
<div
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{trigger || defaultTrigger}
</div>
{isVisible && (
<div
className={cn(
"absolute z-50 pointer-events-none",
positionClasses[position]
)}
style={{ maxWidth }}
role="tooltip"
aria-hidden={!isVisible}
>
{/* Arrow */}
<div
className={cn(
"absolute w-0 h-0 border-4",
arrowClasses[position]
)}
/>
{/* Content */}
<div className="relative bg-slate-800 text-slate-100 text-xs rounded-md shadow-lg px-3 py-2 leading-relaxed">
{typeof content === "string" ? (
<p>{content}</p>
) : (
<div>{content}</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,376 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { X, ChevronLeft, ChevronRight, Check } from "lucide-react";
import * as Dialog from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
export interface TourStep {
/**
* Identifiant unique de l'étape
*/
id: string;
/**
* Titre de l'étape
*/
title: string;
/**
* Contenu de l'étape
*/
content: string | React.ReactNode;
/**
* Sélecteur CSS de l'élément à highlighter
* @example "#file-uploader", ".tab-button", "[data-tab='correlation']"
*/
target?: string;
/**
* Position du tooltip par rapport à la cible
* @default "bottom"
*/
position?: "top" | "bottom" | "left" | "right" | "center";
/**
* Image optionnelle pour illustrer l'étape
*/
image?: string;
/**
* Action optionnelle à exécuter quand l'étape est affichée
*/
onEnter?: () => void;
/**
* Action optionnelle à exécuter quand l'étape est quittée
*/
onExit?: () => void;
}
interface TourGuideProps {
/**
* Étapes du tour
*/
steps: TourStep[];
/**
* Contrôle l'ouverture du tour
*/
open?: boolean;
/**
* Callback lors de la fermeture
*/
onOpenChange?: (open: boolean) => void;
/**
* Callback à la fin du tour
*/
onComplete?: () => void;
/**
* Callback quand une étape est_skippée
*/
onSkip?: () => void;
/**
* Texte du bouton de fin
* @default "Compris !"
*/
completeButtonText?: string;
/**
* Texte du bouton pour passer
* @default "Passer"
*/
skipButtonText?: string;
/**
* Si true, affiche une barre de progression
* @default true
*/
showProgress?: boolean;
/**
* Si true, le tour ne peut pas être fermé en cliquant en dehors
* @default false
*/
closeOnOutsideClick?: boolean;
}
export function TourGuide({
steps,
open: controlledOpen,
onOpenChange,
onComplete,
onSkip,
completeButtonText = "Compris !",
skipButtonText = "Passer",
showProgress = true,
closeOnOutsideClick = false,
}: TourGuideProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isHighlighted, setIsHighlighted] = useState(false);
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const currentStep = steps[currentStepIndex];
const isLastStep = currentStepIndex === steps.length - 1;
const isFirstStep = currentStepIndex === 0;
const setOpen = (newOpen: boolean) => {
if (controlledOpen === undefined) {
setInternalOpen(newOpen);
}
onOpenChange?.(newOpen);
};
// Gérer l'entrée dans une étape
useEffect(() => {
if (open && currentStep) {
currentStep.onEnter?.();
setIsHighlighted(true);
// Scroll vers l'élément cible si nécessaire
if (currentStep.target) {
const element = document.querySelector(currentStep.target);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}
return () => {
if (currentStep) {
currentStep.onExit?.();
}
setIsHighlighted(false);
};
}, [currentStepIndex, open, currentStep]);
const nextStep = useCallback(() => {
if (isLastStep) {
onComplete?.();
setOpen(false);
setCurrentStepIndex(0);
} else {
setCurrentStepIndex((prev) => prev + 1);
}
}, [isLastStep, onComplete, setOpen]);
const prevStep = useCallback(() => {
if (!isFirstStep) {
setCurrentStepIndex((prev) => prev - 1);
}
}, [isFirstStep]);
const handleSkip = useCallback(() => {
onSkip?.();
setOpen(false);
setCurrentStepIndex(0);
}, [onSkip, setOpen]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (newOpen === false && !closeOnOutsideClick) {
// Empêcher la fermeture si closeOnOutsideClick est false
return;
}
setOpen(newOpen);
if (!newOpen) {
setCurrentStepIndex(0);
}
},
[setOpen, closeOnOutsideClick]
);
// Calculer la position du tooltip
const getTooltipPosition = () => {
if (!currentStep?.target) return "center";
const element = document.querySelector(currentStep.target);
if (!element) return "center";
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Déterminer la meilleure position automatiquement si non spécifiée
if (!currentStep.position || currentStep.position === "center") {
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
if (centerY < viewportHeight * 0.3) return "bottom";
if (centerY > viewportHeight * 0.7) return "top";
if (centerX < viewportWidth * 0.3) return "right";
if (centerX > viewportWidth * 0.7) return "left";
return "bottom";
}
return currentStep.position;
};
const position = getTooltipPosition();
if (!open || !currentStep) return null;
return (
<>
{/* Overlay avec spotlight */}
<div
className={cn(
"fixed inset-0 z-[60] bg-black/60 transition-opacity duration-300",
isHighlighted && "opacity-100"
)}
onClick={() => handleOpenChange(false)}
aria-hidden="true"
/>
{/* Spotlight sur l'élément cible */}
{currentStep.target && isHighlighted && (
<div
className={cn(
"fixed z-[61] pointer-events-none transition-all duration-300",
"border-4 border-indigo-500 rounded-lg shadow-[0_0_0_9999px_rgba(0,0,0,0.6)]"
)}
style={{
...((() => {
const element = document.querySelector(currentStep.target!);
if (!element) return {};
const rect = element.getBoundingClientRect();
return {
top: rect.top - 4,
left: rect.left - 4,
width: rect.width + 8,
height: rect.height + 8,
};
})()),
}}
/>
)}
{/* Tooltip du tour */}
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Portal>
<Dialog.Content
className={cn(
"fixed z-[70] w-[400px] max-w-[90vw]",
"bg-white rounded-2xl shadow-2xl border border-slate-200",
"transition-all duration-300",
position === "center" && "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
position === "top" && "left-1/2 -translate-x-1/2 bottom-8",
position === "bottom" && "left-1/2 -translate-x-1/2 top-8",
position === "left" && "right-8 top-1/2 -translate-y-1/2",
position === "right" && "left-8 top-1/2 -translate-y-1/2"
)}
onPointerDownOutside={(e) => {
if (!closeOnOutsideClick) {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
if (!closeOnOutsideClick) {
e.preventDefault();
}
}}
>
{/* Header */}
<div className="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4">
<Dialog.Title className="text-lg font-bold text-slate-900">
{currentStep.title}
</Dialog.Title>
<button
onClick={() => handleOpenChange(false)}
className={cn(
"shrink-0 w-6 h-6 rounded flex items-center justify-center",
"text-slate-400 hover:text-slate-600 hover:bg-slate-100",
"transition-colors duration-150"
)}
>
<X className="w-4 h-4" />
</button>
</div>
{/* Progress */}
{showProgress && steps.length > 1 && (
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-slate-500 mb-2">
<span>Étape {currentStepIndex + 1} sur {steps.length}</span>
<span>{Math.round(((currentStepIndex + 1) / steps.length) * 100)}%</span>
</div>
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-indigo-500 to-purple-600 transition-all duration-300 ease-out"
style={{
width: `${((currentStepIndex + 1) / steps.length) * 100}%`,
}}
/>
</div>
</div>
)}
</div>
{/* Content */}
<div className="px-6 pb-4">
{currentStep.image && (
<div className="mb-4 rounded-lg overflow-hidden border border-slate-200">
<img
src={currentStep.image}
alt={currentStep.title}
className="w-full h-auto"
/>
</div>
)}
<div className="text-sm text-slate-600 leading-relaxed space-y-2">
{typeof currentStep.content === "string" ? (
<p>{currentStep.content}</p>
) : (
currentStep.content
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 bg-slate-50 border-t border-slate-200 rounded-b-2xl">
<button
onClick={handleSkip}
className="text-sm text-slate-500 hover:text-slate-700 transition-colors"
>
{skipButtonText}
</button>
<div className="flex items-center gap-2">
{!isFirstStep && (
<button
onClick={prevStep}
className={cn(
"flex items-center gap-1.5 px-4 py-2 rounded-lg",
"text-sm font-medium text-slate-700",
"hover:bg-slate-200",
"transition-colors duration-150"
)}
>
<ChevronLeft className="w-4 h-4" />
Précédent
</button>
)}
<button
onClick={nextStep}
className={cn(
"flex items-center gap-1.5 px-4 py-2 rounded-lg",
"text-sm font-semibold text-white",
"bg-gradient-to-r from-indigo-600 to-purple-600",
"hover:from-indigo-700 hover:to-purple-700",
"shadow-md hover:shadow-lg",
"transition-all duration-150"
)}
>
{isLastStep ? (
<>
<Check className="w-4 h-4" />
{completeButtonText}
</>
) : (
<>
Suivant
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import React, { useEffect } from "react";
import { TourGuide } from "./TourGuide";
import { useHelpStore } from "../lib/help-store";
import { WELCOME_TOUR_STEPS } from "../lib/tours";
export function WelcomeTour() {
const { hasSeenWelcomeTour, autoShowTours, markTourAsSeen } = useHelpStore();
// Ne pas afficher si déjà vu ou si les tours sont désactivés
if (hasSeenWelcomeTour || !autoShowTours) {
return null;
}
const handleComplete = () => {
markTourAsSeen("welcome");
};
const handleSkip = () => {
markTourAsSeen("welcome");
};
return (
<TourGuide
steps={WELCOME_TOUR_STEPS}
open={!hasSeenWelcomeTour && autoShowTours}
onOpenChange={(open) => {
if (!open) {
markTourAsSeen("welcome");
}
}}
onComplete={handleComplete}
onSkip={handleSkip}
closeOnOutsideClick={false}
showProgress={true}
/>
);
}
export function CorrelationTour() {
const { hasSeenCorrelationTour, autoShowTours, markTourAsSeen } = useHelpStore();
if (hasSeenCorrelationTour || !autoShowTours) {
return null;
}
// Ce tour sera déclenché manuellement depuis l'onglet corrélation
return null;
}
export function RegressionTour() {
const { hasSeenRegressionTour, autoShowTours, markTourAsSeen } = useHelpStore();
if (hasSeenRegressionTour || !autoShowTours) {
return null;
}
// Ce tour sera déclenché manuellement depuis la config de régression
return null;
}
export function OutlierTour() {
const { hasSeenOutlierTour, autoShowTours, markTourAsSeen } = useHelpStore();
if (hasSeenOutlierTour || !autoShowTours) {
return null;
}
// Ce tour sera déclenché manuellement depuis la grille
return null;
}

View File

@@ -0,0 +1,11 @@
// Composants
export { HelpButton } from "./components/HelpButton";
export { HelpTooltip } from "./components/HelpTooltip";
export { HelpPanel } from "./components/HelpPanel";
export { HelpModal } from "./components/HelpModal";
export { TourGuide } from "./components/TourGuide";
export type { TourStep } from "./components/TourGuide";
export { WelcomeTour } from "./components/WelcomeTour";
// Store
export { useHelpStore } from "./lib/help-store";