From 605edee033214683420a250504ace88cf3b08b9b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:37:49 +0530 Subject: [PATCH] feat: add onboarding tour component and integrate into dashboard layout - Introduced the OnboardingTour component to guide users through key features. - Integrated the OnboardingTour into the DashboardClientLayout for improved user experience. - Updated connector popup and sidebar navigation with data attributes for tour steps. --- .../[search_space_id]/client-layout.tsx | 2 + .../assistant-ui/connector-popup.tsx | 1 + surfsense_web/components/onboarding-tour.tsx | 523 ++++++++++++++++++ surfsense_web/components/sidebar/nav-main.tsx | 24 +- 4 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 surfsense_web/components/onboarding-tour.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 6e61ff7ac..c78cc7762 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -17,6 +17,7 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { OnboardingTour } from "@/components/onboarding-tour"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -242,6 +243,7 @@ export function DashboardClientLayout({ return ( + {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} { return ( + {/* Dark overlay with cutout using box-shadow technique */} +
+ {/* Blue shadow behind the button - starts from button border */} +
+ + ); +} + +function TourTooltip({ + step, + stepIndex, + totalSteps, + position, + targetRect, + onNext, + onPrev, + onSkip, + isDarkMode, +}: { + step: TourStep; + stepIndex: number; + totalSteps: number; + position: TooltipPosition; + targetRect: DOMRect; + onNext: () => void; + onPrev: () => void; + onSkip: () => void; + isDarkMode: boolean; +}) { + const isLastStep = stepIndex === totalSteps - 1; + const isFirstStep = stepIndex === 0; + + const bgColor = isDarkMode ? "#18181b" : "#18181b"; // Dark tooltip for both modes as shown in image + const textColor = "#ffffff"; + const mutedTextColor = "#a1a1aa"; + + // Calculate pointer line position + const getPointerStyles = (): React.CSSProperties => { + const lineLength = 16; + const dotSize = 6; + // Check if this is the documents step (stepIndex === 1) + const isDocumentsStep = stepIndex === 1; + + if (position.pointerPosition === "left") { + return { + position: "absolute", + left: -lineLength - dotSize, + top: isDocumentsStep ? "calc(50% - 8px)" : "50%", + transform: "translateY(-50%)", + display: "flex", + alignItems: "center", + }; + } + if (position.pointerPosition === "top") { + return { + position: "absolute", + top: -lineLength - dotSize, + left: "50%", + transform: "translateX(-50%)", + display: "flex", + flexDirection: "column", + alignItems: "center", + }; + } + return {}; + }; + + const renderPointer = () => { + const lineColor = "#18181B"; + + if (position.pointerPosition === "left") { + return ( +
+
+
+
+ ); + } + if (position.pointerPosition === "top") { + return ( +
+
+
+
+ ); + } + return null; + }; + + // Render step dots + const renderStepDots = () => { + return ( +
+ {Array.from({ length: totalSteps }).map((_, i) => ( +
+ ))} +
+ ); + }; + + return ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {/* Pointer line */} + {renderPointer()} + +
+ {/* Content */} +
+

+ {step.title} +

+

+ {step.content} +

+
+ + {/* Footer */} +
+ {/* Step dots */} + {renderStepDots()} + + {/* Navigation buttons */} +
+ {!isFirstStep && ( + + )} + {isFirstStep && ( + + )} + +
+
+
+
+ ); +} + +export function OnboardingTour() { + const [isActive, setIsActive] = useState(false); + const [stepIndex, setStepIndex] = useState(0); + const [targetEl, setTargetEl] = useState(null); + const [position, setPosition] = useState(null); + const [targetRect, setTargetRect] = useState(null); + const [mounted, setMounted] = useState(false); + const { resolvedTheme } = useTheme(); + const retryCountRef = useRef(0); + const maxRetries = 10; + + const isDarkMode = resolvedTheme === "dark"; + const currentStep = TOUR_STEPS[stepIndex]; + + // Handle mounting for portal + useEffect(() => { + setMounted(true); + }, []); + + // Find and track target element with retry logic + const updateTarget = useCallback(() => { + if (!currentStep) return; + + const el = document.querySelector(currentStep.target); + if (el) { + setTargetEl(el); + setTargetRect(el.getBoundingClientRect()); + setPosition(calculatePosition(el, currentStep.placement)); + retryCountRef.current = 0; + } else if (retryCountRef.current < maxRetries) { + retryCountRef.current++; + setTimeout(() => { + const retryEl = document.querySelector(currentStep.target); + if (retryEl) { + setTargetEl(retryEl); + setTargetRect(retryEl.getBoundingClientRect()); + setPosition(calculatePosition(retryEl, currentStep.placement)); + retryCountRef.current = 0; + } + }, 200); + } + }, [currentStep]); + + // Start tour and find first target + useEffect(() => { + const timer = setTimeout(() => { + const el = document.querySelector(TOUR_STEPS[0].target); + if (el) { + setIsActive(true); + setTargetEl(el); + setTargetRect(el.getBoundingClientRect()); + setPosition(calculatePosition(el, TOUR_STEPS[0].placement)); + } + }, 1000); + + return () => clearTimeout(timer); + }, []); + + // Update position on resize/scroll + useEffect(() => { + if (!isActive || !targetEl) return; + + const handleUpdate = () => { + const rect = targetEl.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + setTargetRect(rect); + setPosition(calculatePosition(targetEl, currentStep?.placement || "bottom")); + } + }; + + window.addEventListener("resize", handleUpdate); + window.addEventListener("scroll", handleUpdate, true); + + return () => { + window.removeEventListener("resize", handleUpdate); + window.removeEventListener("scroll", handleUpdate, true); + }; + }, [isActive, targetEl, currentStep?.placement]); + + // Update target when step changes + useEffect(() => { + if (isActive && currentStep) { + const timer = setTimeout(() => { + updateTarget(); + }, 100); + return () => clearTimeout(timer); + } + }, [isActive, updateTarget, currentStep]); + + // Ensure target element is above overlay layers so content is fully visible + useEffect(() => { + if (!targetEl || !isActive) return; + + const originalZIndex = (targetEl as HTMLElement).style.zIndex; + const originalPosition = (targetEl as HTMLElement).style.position; + + // Ensure the element has a position that allows z-index + if (getComputedStyle(targetEl).position === "static") { + (targetEl as HTMLElement).style.position = "relative"; + } + (targetEl as HTMLElement).style.zIndex = "99999"; + + return () => { + (targetEl as HTMLElement).style.zIndex = originalZIndex; + if (originalPosition) { + (targetEl as HTMLElement).style.position = originalPosition; + } else if (getComputedStyle(targetEl).position === "relative" && originalPosition === "") { + (targetEl as HTMLElement).style.position = ""; + } + }; + }, [targetEl, isActive]); + + const handleNext = useCallback(() => { + if (stepIndex < TOUR_STEPS.length - 1) { + retryCountRef.current = 0; + setStepIndex(stepIndex + 1); + } else { + setIsActive(false); + } + }, [stepIndex]); + + const handlePrev = useCallback(() => { + if (stepIndex > 0) { + retryCountRef.current = 0; + setStepIndex(stepIndex - 1); + } + }, [stepIndex]); + + const handleSkip = useCallback(() => { + setIsActive(false); + }, []); + + // Handle overlay click to close + const handleOverlayClick = useCallback(() => { + setIsActive(false); + }, []); + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isActive) { + setIsActive(false); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isActive]); + + // Don't render if not active or not mounted + if (!mounted || !isActive || !targetEl || !position || !currentStep || !targetRect) { + return null; + } + + return createPortal( +
+ {/* Clickable backdrop to close */} +
, + document.body + ); +} diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx index 43c551875..a0dbe912f 100644 --- a/surfsense_web/components/sidebar/nav-main.tsx +++ b/surfsense_web/components/sidebar/nav-main.tsx @@ -131,7 +131,11 @@ export function NavMain({ items }: NavMainProps) { isActive={isActive} aria-label={`${translatedTitle} with submenu`} > - @@ -152,10 +156,18 @@ export function NavMain({ items }: NavMainProps) { {item.items?.map((subItem, subIndex) => { const translatedSubTitle = translateTitle(subItem.title); + const isDocumentsLink = + subItem.title === "Manage Documents" || + translatedSubTitle.toLowerCase().includes("documents"); return ( - + {translatedSubTitle} @@ -173,7 +185,13 @@ export function NavMain({ items }: NavMainProps) { isActive={isActive} aria-label={translatedTitle} > - + {translatedTitle}