);
}
export function OnboardingTour() {
const isMobile = useIsMobile();
const [isActive, setIsActive] = useState(false);
const [stepIndex, setStepIndex] = useState(0);
const [targetEl, setTargetEl] = useState(null);
const [spotlightTargetEl, setSpotlightTargetEl] = useState(null);
const [spotlightStepTarget, setSpotlightStepTarget] = useState(null);
const [position, setPosition] = useState(null);
const [targetRect, setTargetRect] = useState(null);
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useTheme();
const pathname = usePathname();
const retryCountRef = useRef(0);
const maxRetries = 10;
// Track previous user ID to detect user changes
const previousUserIdRef = useRef(null);
// Get user data
const { data: user } = useAtomValue(currentUserAtom);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
// Fetch threads data
const { data: threadsData } = useQuery({
queryKey: ["threads", searchSpaceId, { limit: 1 }],
queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist
enabled: !!searchSpaceId,
});
// Get document type counts
const { data: documentTypeCounts } = useAtomValue(documentTypeCountsAtom);
// Get connectors
const { data: connectors = [] } = useAtomValue(connectorsAtom);
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]);
// Check if tour should run: localStorage + data validation with user ID tracking
useEffect(() => {
// Don't check if not mounted or no user
if (!mounted || !user?.id || !searchSpaceId) return;
// Check if on new-chat page
const isNewChatPage = pathname?.includes("/new-chat");
if (!isNewChatPage) return;
// Wait for all data to be loaded before making decision
// Data is considered loaded when:
// - threadsData is defined (query completed, even if empty)
// - documentTypeCounts is defined (query completed, even if empty object)
// - connectors is an array (always defined with default [])
// If searchSpaceId is not set, connectors query won't run, but that's okay
const dataLoaded = threadsData !== undefined && documentTypeCounts !== undefined;
if (!dataLoaded) return;
const currentUserId = user.id;
const previousUserId = previousUserIdRef.current;
// Detect user change - if user ID changed, reset tour state
if (previousUserId !== null && previousUserId !== currentUserId) {
// User changed - reset tour state and re-evaluate for new user
setIsActive(false);
setStepIndex(0);
setTargetEl(null);
setSpotlightTargetEl(null);
setSpotlightStepTarget(null);
setPosition(null);
setTargetRect(null);
retryCountRef.current = 0;
}
// Update previous user ID ref
previousUserIdRef.current = currentUserId;
// Check localStorage for CURRENT user ID (not stale cache)
// This ensures we check the correct user's tour status
const tourKey = `surfsense-tour-${currentUserId}`;
const hasSeenTour = localStorage.getItem(tourKey);
if (hasSeenTour === "true") {
return; // Current user has seen tour, don't show
}
// Validate user is actually new (reliable check)
const threads = threadsData?.threads ?? [];
const hasThreads = threads.length > 0;
// Check document counts - sum all document type counts
const totalDocuments = documentTypeCounts
? Object.values(documentTypeCounts).reduce((sum, count) => sum + count, 0)
: 0;
const hasDocuments = totalDocuments > 0;
const hasConnectors = connectors.length > 0;
// User is new if they have no threads, documents, or connectors
const isNewUser = !hasThreads && !hasDocuments && !hasConnectors;
// Only show tour if user is new and hasn't seen it
// Don't auto-mark as seen if user has data - let them explicitly dismiss it
if (!isNewUser) {
return; // User has data, don't show tour
}
// User is new and hasn't seen tour - wait for DOM elements and start tour
const checkAndStartTour = () => {
// Check if all required elements exist
const connectorEl = document.querySelector(TOUR_STEPS[0].target);
const documentsEl = document.querySelector(TOUR_STEPS[1].target);
const inboxEl = document.querySelector(TOUR_STEPS[2].target);
if (connectorEl && documentsEl && inboxEl) {
// All elements found, start tour
setIsActive(true);
setTargetEl(connectorEl);
setSpotlightTargetEl(connectorEl);
setSpotlightStepTarget(TOUR_STEPS[0].target);
setTargetRect(connectorEl.getBoundingClientRect());
setPosition(calculatePosition(connectorEl, TOUR_STEPS[0].placement));
} else {
// Retry after delay
setTimeout(checkAndStartTour, 200);
}
};
// Start checking after initial delay
const timer = setTimeout(checkAndStartTour, 500);
return () => clearTimeout(timer);
}, [mounted, user?.id, searchSpaceId, pathname, threadsData, documentTypeCounts, connectors]);
// 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) {
// Try to find element synchronously first to prevent any delay
const el = document.querySelector(currentStep.target);
if (el) {
// Found immediately - update state synchronously to prevent flicker
const rect = el.getBoundingClientRect();
const newPosition = calculatePosition(el, currentStep.placement);
// React 18+ automatically batches these updates
setTargetEl(el);
setTargetRect(rect);
setPosition(newPosition);
retryCountRef.current = 0;
} else {
// Not found immediately, use updateTarget with retry logic
// Use requestAnimationFrame to batch with next paint
const frameId = requestAnimationFrame(() => {
updateTarget();
});
return () => cancelAnimationFrame(frameId);
}
}
}, [isActive, updateTarget, currentStep]);
// Delay spotlight update to sync with tooltip animation
useEffect(() => {
if (targetEl && currentStep) {
const timer = setTimeout(() => {
setSpotlightTargetEl(targetEl);
setSpotlightStepTarget(currentStep.target);
}, 100);
return () => clearTimeout(timer);
}
}, [targetEl, 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 {
// Tour completed - save to localStorage
if (user?.id) {
const tourKey = `surfsense-tour-${user.id}`;
localStorage.setItem(tourKey, "true");
}
setIsActive(false);
}
}, [stepIndex, user?.id]);
const handlePrev = useCallback(() => {
if (stepIndex > 0) {
retryCountRef.current = 0;
setStepIndex(stepIndex - 1);
}
}, [stepIndex]);
const handleSkip = useCallback(() => {
// Tour skipped - save to localStorage
if (user?.id) {
const tourKey = `surfsense-tour-${user.id}`;
localStorage.setItem(tourKey, "true");
}
setIsActive(false);
}, [user?.id]);
// Handle overlay click to close
const handleOverlayClick = useCallback(() => {
// Tour closed - save to localStorage
if (user?.id) {
const tourKey = `surfsense-tour-${user.id}`;
localStorage.setItem(tourKey, "true");
}
setIsActive(false);
}, [user?.id]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isActive) {
// Tour closed via escape - save to localStorage
if (user?.id) {
const tourKey = `surfsense-tour-${user.id}`;
localStorage.setItem(tourKey, "true");
}
setIsActive(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isActive, user?.id]);
// Don't render on mobile, or if not active or not mounted
if (isMobile || !mounted || !isActive) {
return null;
}
return createPortal(
<>
{/* Clickable backdrop to close */}
{/* Only render Spotlight and TourTooltip when we have target data */}
{targetEl && position && currentStep && targetRect && (
<>
{spotlightTargetEl && spotlightStepTarget && (
)}
>
)}