fix(onboarding): prevent invisible overlay blocking clicks and stuck tour

- Tie spotlight/tooltip pointer-events to isVisible so opacity:0 stops
  trapping taps during step transitions
- Render target click-catcher only when overlay is fully visible and
  clear stale targetRect on step change
- Retry target lookup up to 6 times and auto-skip step (or complete
  tour) when target is missing, instead of leaving overlay stuck
  invisible-but-interactive
- Persist completion to localStorage before unmounting the tour, so
  the flag survives even if unmount throws
This commit is contained in:
Fringg
2026-05-15 05:34:38 +03:00
parent 7ae2787596
commit abbbc6a216
2 changed files with 49 additions and 15 deletions

View File

@@ -43,27 +43,52 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
const step = steps[currentStep]; const step = steps[currentStep];
// Find and highlight target element
useEffect(() => { useEffect(() => {
const findTarget = () => { let cancelled = false;
let attempts = 0;
const maxAttempts = 6;
const isLastStep = currentStep === steps.length - 1;
setIsVisible(false);
setTargetRect(null);
const tryFind = () => {
if (cancelled) return;
const target = document.querySelector(`[data-onboarding="${step.target}"]`); const target = document.querySelector(`[data-onboarding="${step.target}"]`);
if (target) { if (target) {
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
setTargetRect(rect); setTargetRect(rect);
// Scroll element into view if needed
target.scrollIntoView({ behavior: 'smooth', block: 'center' }); target.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => {
// Delay visibility for smooth animation if (!cancelled) setIsVisible(true);
setTimeout(() => setIsVisible(true), 100); }, 100);
return;
}
attempts += 1;
if (attempts < maxAttempts) {
window.setTimeout(tryFind, 200);
return;
}
if (isLastStep) {
onCompleteRef.current();
} else {
setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
} }
}; };
setIsVisible(false); const timer = window.setTimeout(tryFind, 300);
const timer = setTimeout(findTarget, 300); return () => {
return () => clearTimeout(timer); cancelled = true;
window.clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step.target]); }, [step.target]);
// Recalculate position on resize/scroll // Recalculate position on resize/scroll
@@ -170,13 +195,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
return createPortal( return createPortal(
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}> <div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
{/* Spotlight */} {/* Spotlight */}
<div className="onboarding-spotlight" style={getSpotlightStyle()} /> <div
className="onboarding-spotlight"
style={{
...getSpotlightStyle(),
pointerEvents: isVisible ? 'auto' : 'none',
}}
/>
{/* Tooltip */} {/* Tooltip */}
<div <div
ref={tooltipRef} ref={tooltipRef}
className={`onboarding-tooltip tooltip-${step.placement}`} className={`onboarding-tooltip tooltip-${step.placement}`}
style={getTooltipStyle()} style={{
...getTooltipStyle(),
pointerEvents: isVisible ? 'auto' : 'none',
}}
> >
{/* Progress indicator */} {/* Progress indicator */}
<div className="mb-4 flex items-center gap-1.5"> <div className="mb-4 flex items-center gap-1.5">
@@ -222,8 +256,8 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
</div> </div>
</div> </div>
{/* Click handler to advance on target click */} {/* Click handler to advance on target click — only when overlay is fully visible */}
{targetRect && ( {targetRect && isVisible && (
<div <div
className="absolute cursor-pointer" className="absolute cursor-pointer"
style={{ style={{

View File

@@ -246,8 +246,8 @@ export default function Dashboard() {
}, [t, subscription]); }, [t, subscription]);
const handleOnboardingComplete = () => { const handleOnboardingComplete = () => {
setShowOnboarding(false);
completeOnboarding(); completeOnboarding();
setShowOnboarding(false);
}; };
return ( return (