mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-10 22:32:16 +02:00
- Added the ensure_publication function to create and verify the zero_publication if it is missing, ensuring idempotency during database initialization. - Integrated ensure_publication into the create_db_and_tables function to prevent zero-cache crash loops on startup. - Introduced a self-check script to validate the ensure_publication functionality on a create_all-bootstrapped database. - Updated various components to reflect the transition from search space to workspace, including adjustments in imports and routing paths.
760 lines
22 KiB
TypeScript
760 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAtomValue } from "jotai";
|
|
import { usePathname } from "next/navigation";
|
|
import { useTheme } from "next-themes";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
|
|
import { fetchThreads } from "@/lib/chat/thread-persistence";
|
|
|
|
interface TourStep {
|
|
target: string;
|
|
title: string;
|
|
content: string;
|
|
placement: "top" | "bottom" | "left" | "right";
|
|
}
|
|
|
|
const TOUR_STEPS: TourStep[] = [
|
|
{
|
|
target: '[data-joyride="connector-icon"]',
|
|
title: "Manage your tools",
|
|
content: "Enable or disable AI tools and configure capabilities.",
|
|
placement: "bottom",
|
|
},
|
|
{
|
|
target: '[data-joyride="upload-button"]',
|
|
title: "Upload documents",
|
|
content: "Upload files to your search space.",
|
|
placement: "left",
|
|
},
|
|
{
|
|
target: '[data-joyride="inbox-sidebar"]',
|
|
title: "Check your inbox",
|
|
content: "View mentions and notifications in one place.",
|
|
placement: "right",
|
|
},
|
|
];
|
|
|
|
interface TooltipPosition {
|
|
top: number;
|
|
left: number;
|
|
pointerPosition: "top" | "bottom" | "left" | "right";
|
|
}
|
|
|
|
function calculatePosition(targetEl: Element, placement: TourStep["placement"]): TooltipPosition {
|
|
const rect = targetEl.getBoundingClientRect();
|
|
const scrollTop = window.scrollY;
|
|
const scrollLeft = window.scrollX;
|
|
const tooltipWidth = 280;
|
|
const tooltipHeight = 120;
|
|
const offset = 16;
|
|
|
|
let top = 0;
|
|
let left = 0;
|
|
let pointerPosition: TooltipPosition["pointerPosition"] = "left";
|
|
|
|
switch (placement) {
|
|
case "bottom":
|
|
top = rect.bottom + scrollTop + offset;
|
|
left = rect.left + scrollLeft + rect.width / 2 - tooltipWidth / 2;
|
|
pointerPosition = "top";
|
|
break;
|
|
case "top":
|
|
top = rect.top + scrollTop - tooltipHeight - offset;
|
|
left = rect.left + scrollLeft + rect.width / 2 - tooltipWidth / 2;
|
|
pointerPosition = "bottom";
|
|
break;
|
|
case "right":
|
|
top = rect.top + scrollTop + rect.height / 2 - tooltipHeight / 2;
|
|
left = rect.right + scrollLeft + offset;
|
|
pointerPosition = "left";
|
|
break;
|
|
case "left":
|
|
top = rect.top + scrollTop + rect.height / 2 - tooltipHeight / 2;
|
|
left = rect.left + scrollLeft - tooltipWidth - offset;
|
|
pointerPosition = "right";
|
|
break;
|
|
}
|
|
|
|
// Ensure tooltip stays within viewport
|
|
left = Math.max(10, Math.min(left, window.innerWidth - tooltipWidth - 10));
|
|
top = Math.max(10, top);
|
|
|
|
return { top, left, pointerPosition };
|
|
}
|
|
|
|
function Spotlight({
|
|
targetEl,
|
|
isDarkMode,
|
|
currentStepTarget,
|
|
}: {
|
|
targetEl: Element;
|
|
isDarkMode: boolean;
|
|
currentStepTarget: string;
|
|
}) {
|
|
const rect = targetEl.getBoundingClientRect();
|
|
const padding = 6;
|
|
const shadowColor = isDarkMode ? "#3f3f46" : "#3b82f6";
|
|
|
|
// Check if this is the connector icon step - verify both the selector matches AND the element matches
|
|
// This prevents the shape from changing before targetEl updates
|
|
const isConnectorSelector = currentStepTarget === '[data-joyride="connector-icon"]';
|
|
const isConnectorElement = targetEl.matches('[data-joyride="connector-icon"]');
|
|
const isConnectorStep = isConnectorSelector && isConnectorElement;
|
|
|
|
// For circle, use the larger dimension to ensure it's a perfect circle
|
|
const circleSize = isConnectorStep ? Math.max(rect.width, rect.height) : 0;
|
|
const circleTop = isConnectorStep ? rect.top + (rect.height - circleSize) / 2 : rect.top;
|
|
const circleLeft = isConnectorStep ? rect.left + (rect.width - circleSize) / 2 : rect.left;
|
|
|
|
return (
|
|
<>
|
|
{/* Dark overlay with cutout using box-shadow technique */}
|
|
<div
|
|
className="fixed pointer-events-none"
|
|
style={{
|
|
top: isConnectorStep ? circleTop - padding : rect.top - padding,
|
|
left: isConnectorStep ? circleLeft - padding : rect.left - padding,
|
|
width: isConnectorStep ? circleSize + padding * 2 : rect.width + padding * 2,
|
|
height: isConnectorStep ? circleSize + padding * 2 : rect.height + padding * 2,
|
|
borderRadius: isConnectorStep ? "50%" : 8,
|
|
boxShadow: isDarkMode
|
|
? `0 0 0 9999px rgba(0, 0, 0, 0.6)`
|
|
: `0 0 0 9999px rgba(0, 0, 0, 0.3)`,
|
|
backgroundColor: "transparent",
|
|
zIndex: 99996,
|
|
}}
|
|
/>
|
|
{/* Blue shadow behind the button - starts from button border */}
|
|
<div
|
|
className="fixed pointer-events-none"
|
|
style={{
|
|
top: isConnectorStep ? circleTop : rect.top,
|
|
left: isConnectorStep ? circleLeft : rect.left,
|
|
width: isConnectorStep ? circleSize : rect.width,
|
|
height: isConnectorStep ? circleSize : rect.height,
|
|
borderRadius: isConnectorStep ? "50%" : 8,
|
|
boxShadow: isDarkMode
|
|
? `0 0 10px 2px ${shadowColor}CC, 0 0 20px 6px ${shadowColor}99, 0 0 40px 12px ${shadowColor}66`
|
|
: `0 0 6px 1px ${shadowColor}80, 0 0 12px 3px ${shadowColor}50, 0 0 20px 6px ${shadowColor}30`,
|
|
backgroundColor: "transparent",
|
|
zIndex: 99997,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TourTooltip({
|
|
step,
|
|
stepIndex,
|
|
totalSteps,
|
|
position,
|
|
onNext,
|
|
onPrev,
|
|
onSkip,
|
|
isDarkMode,
|
|
shouldAnimate,
|
|
onAnimationEnd,
|
|
}: {
|
|
step: TourStep;
|
|
stepIndex: number;
|
|
totalSteps: number;
|
|
position: TooltipPosition;
|
|
targetRect: DOMRect;
|
|
onNext: () => void;
|
|
onPrev: () => void;
|
|
onSkip: () => void;
|
|
isDarkMode: boolean;
|
|
shouldAnimate: boolean;
|
|
onAnimationEnd: () => void;
|
|
}) {
|
|
const isLastStep = stepIndex === totalSteps - 1;
|
|
const isFirstStep = stepIndex === 0;
|
|
|
|
// Calculate pointer line position
|
|
const getPointerStyles = (): React.CSSProperties => {
|
|
const lineLength = 16;
|
|
const dotSize = 6;
|
|
const isUploadStep = stepIndex === 1;
|
|
const isInboxStep = stepIndex === 2;
|
|
|
|
if (position.pointerPosition === "left") {
|
|
return {
|
|
position: "absolute",
|
|
left: -lineLength - dotSize,
|
|
top: isInboxStep ? "calc(50% - 8px)" : "50%",
|
|
transform: "translateY(-50%)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
};
|
|
}
|
|
if (position.pointerPosition === "right") {
|
|
return {
|
|
position: "absolute",
|
|
right: -lineLength - dotSize,
|
|
top: isUploadStep ? "calc(50% - 12px)" : "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 = () => {
|
|
if (position.pointerPosition === "left") {
|
|
return (
|
|
<div style={getPointerStyles()}>
|
|
<div className="size-1.5 rounded-full bg-main-panel" />
|
|
<div className="h-0.5 w-4 bg-main-panel" />
|
|
</div>
|
|
);
|
|
}
|
|
if (position.pointerPosition === "right") {
|
|
return (
|
|
<div style={getPointerStyles()}>
|
|
<div className="h-0.5 w-4 bg-main-panel" />
|
|
<div className="size-1.5 rounded-full bg-main-panel" />
|
|
</div>
|
|
);
|
|
}
|
|
if (position.pointerPosition === "top") {
|
|
return (
|
|
<div style={getPointerStyles()}>
|
|
<div className="size-1.5 rounded-full bg-main-panel" />
|
|
<div className="h-4 w-0.5 bg-main-panel" />
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Render step dots
|
|
const renderStepDots = () => {
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
{Array.from({ length: totalSteps }).map((_, i) => (
|
|
<div
|
|
key={TOUR_STEPS[i]?.target ?? `step-${i}`}
|
|
style={{
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: "50%",
|
|
backgroundColor:
|
|
i === stepIndex
|
|
? isDarkMode
|
|
? "#ffffff"
|
|
: "#18181b"
|
|
: isDarkMode
|
|
? "#52525b"
|
|
: "#d4d4d8",
|
|
transition: "background-color 0.2s",
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="tour-title"
|
|
className="fixed z-[99999]"
|
|
style={{
|
|
top: position.top,
|
|
left: position.left,
|
|
width: 280,
|
|
transition: "top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Pointer line */}
|
|
{renderPointer()}
|
|
|
|
<div
|
|
className="relative rounded-lg bg-main-panel p-4 text-foreground"
|
|
style={{
|
|
boxShadow: isDarkMode
|
|
? "0 25px 50px -12px rgba(0, 0, 0, 0.5)"
|
|
: "0 25px 50px -12px rgba(0, 0, 0, 0.15)",
|
|
}}
|
|
>
|
|
{/* Content */}
|
|
<div
|
|
key={stepIndex}
|
|
style={{
|
|
animation: shouldAnimate ? "fadeInSlide 0.3s ease-out" : "none",
|
|
}}
|
|
onAnimationEnd={onAnimationEnd}
|
|
>
|
|
<h3 id="tour-title" className="mb-1.5 text-sm font-semibold">
|
|
{step.title}
|
|
</h3>
|
|
<p className="text-sm leading-relaxed text-muted-foreground">{step.content}</p>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between mt-4">
|
|
{/* Step dots */}
|
|
{renderStepDots()}
|
|
|
|
{/* Navigation buttons */}
|
|
<div className="flex items-center gap-3">
|
|
{!isFirstStep && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onPrev();
|
|
}}
|
|
className="h-auto px-0 py-0 text-sm text-muted-foreground hover:bg-transparent hover:text-muted-foreground hover:opacity-80"
|
|
>
|
|
Back
|
|
</Button>
|
|
)}
|
|
{isFirstStep && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSkip();
|
|
}}
|
|
className="h-auto px-0 py-0 text-sm text-muted-foreground hover:bg-transparent hover:text-muted-foreground hover:opacity-80"
|
|
>
|
|
Skip
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onNext();
|
|
}}
|
|
className="h-auto px-0 py-0 text-sm text-foreground hover:bg-transparent hover:text-foreground hover:opacity-80"
|
|
>
|
|
{isLastStep ? "Done" : "Next"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function OnboardingTour() {
|
|
const isMobile = useIsMobile();
|
|
const [isActive, setIsActive] = useState(false);
|
|
const [stepIndex, setStepIndex] = useState(0);
|
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
|
const [targetEl, setTargetEl] = useState<Element | null>(null);
|
|
const [spotlightTargetEl, setSpotlightTargetEl] = useState<Element | null>(null);
|
|
const [spotlightStepTarget, setSpotlightStepTarget] = useState<string | null>(null);
|
|
const [position, setPosition] = useState<TooltipPosition | null>(null);
|
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
|
const [mounted, setMounted] = useState(false);
|
|
const { resolvedTheme } = useTheme();
|
|
const pathname = usePathname();
|
|
const retryCountRef = useRef(0);
|
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const startCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const maxRetries = 10;
|
|
// Track previous user ID to detect user changes
|
|
const previousUserIdRef = useRef<string | null>(null);
|
|
|
|
// Get user data
|
|
const { data: user } = useAtomValue(currentUserAtom);
|
|
const searchSpaceId = useAtomValue(activeWorkspaceIdAtom);
|
|
|
|
// Fetch threads data
|
|
const { data: threadsData } = useQuery({
|
|
queryKey: ["threads", searchSpaceId, { limit: 40 }], // Same key as layout
|
|
queryFn: () => fetchThreads(Number(searchSpaceId), 40),
|
|
enabled: !!searchSpaceId,
|
|
});
|
|
|
|
// Real-time document type counts via Zero
|
|
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
|
|
|
|
// 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 (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
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++;
|
|
retryTimerRef.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]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// 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
|
|
let cancelled = false;
|
|
|
|
const checkAndStartTour = () => {
|
|
if (cancelled) return;
|
|
|
|
// 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
|
|
startCheckTimerRef.current = setTimeout(checkAndStartTour, 200);
|
|
}
|
|
};
|
|
|
|
// Start checking after initial delay
|
|
startCheckTimerRef.current = setTimeout(checkAndStartTour, 500);
|
|
return () => {
|
|
cancelled = true;
|
|
if (startCheckTimerRef.current) clearTimeout(startCheckTimerRef.current);
|
|
};
|
|
}, [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, { capture: true, passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleUpdate);
|
|
window.removeEventListener("scroll", handleUpdate, { capture: 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(() => {
|
|
retryCountRef.current = 0;
|
|
setShouldAnimate(true);
|
|
setStepIndex((prev) => {
|
|
if (prev < TOUR_STEPS.length - 1) {
|
|
return prev + 1;
|
|
} else {
|
|
// Tour completed - save to localStorage
|
|
if (user?.id) {
|
|
const tourKey = `surfsense-tour-${user.id}`;
|
|
localStorage.setItem(tourKey, "true");
|
|
}
|
|
setIsActive(false);
|
|
return prev;
|
|
}
|
|
});
|
|
}, [user?.id]);
|
|
|
|
const handlePrev = useCallback(() => {
|
|
retryCountRef.current = 0;
|
|
setShouldAnimate(true);
|
|
setStepIndex((prev) => {
|
|
if (prev > 0) {
|
|
return prev - 1;
|
|
}
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
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]);
|
|
|
|
const handleAnimationEnd = useCallback(() => {
|
|
setShouldAnimate(false);
|
|
}, []);
|
|
|
|
// 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(
|
|
<>
|
|
<style>{`
|
|
@keyframes fadeInSlide {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
`}</style>
|
|
<div className="fixed inset-0 z-[99995]">
|
|
{/* Clickable backdrop to close */}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="fixed inset-0 h-full w-full rounded-none border-0 bg-transparent p-0 hover:bg-transparent cursor-default"
|
|
onClick={handleOverlayClick}
|
|
aria-label="Close tour"
|
|
/>
|
|
{/* Only render Spotlight and TourTooltip when we have target data */}
|
|
{targetEl && position && currentStep && targetRect && (
|
|
<>
|
|
{spotlightTargetEl && spotlightStepTarget && (
|
|
<Spotlight
|
|
targetEl={spotlightTargetEl}
|
|
isDarkMode={isDarkMode}
|
|
currentStepTarget={spotlightStepTarget}
|
|
/>
|
|
)}
|
|
<TourTooltip
|
|
step={currentStep}
|
|
stepIndex={stepIndex}
|
|
totalSteps={TOUR_STEPS.length}
|
|
position={position}
|
|
targetRect={targetRect}
|
|
onNext={handleNext}
|
|
onPrev={handlePrev}
|
|
onSkip={handleSkip}
|
|
isDarkMode={isDarkMode}
|
|
shouldAnimate={shouldAnimate}
|
|
onAnimationEnd={handleAnimationEnd}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>,
|
|
document.body
|
|
);
|
|
}
|