/** * Onboarding System for TimeTracker * Interactive product tours and first-time user experience */ class OnboardingManager { constructor() { this.currentStep = 0; this.steps = []; this.overlay = null; this.tooltip = null; this.storageKey = 'onboarding_completed'; } /** * Initialize onboarding */ init(steps) { if (this.isCompleted()) { return; } // Disable tour on mobile devices (width < 768px) const isMobile = window.innerWidth <= 768; if (isMobile) { // Mark as completed to prevent future attempts localStorage.setItem(this.storageKey, 'true'); return; } this.steps = steps; this.createOverlay(); this.createTooltip(); // Add resize handler to handle window resizing during tour this.resizeHandler = () => { const isMobile = window.innerWidth <= 768; if (isMobile) { // If window is resized to mobile size, cancel the tour this.complete(); } }; window.addEventListener('resize', this.resizeHandler); this.showStep(0); } /** * Create overlay element */ createOverlay() { this.overlay = document.createElement('div'); this.overlay.className = 'onboarding-overlay'; this.overlay.innerHTML = ` `; document.body.appendChild(this.overlay); } /** * Create tooltip element */ createTooltip() { this.tooltip = document.createElement('div'); this.tooltip.className = 'onboarding-tooltip'; document.body.appendChild(this.tooltip); } /** * Show a specific step */ showStep(index) { if (index < 0 || index >= this.steps.length) { this.complete(); return; } this.currentStep = index; const step = this.steps[index]; // Find target element with better selector handling let target = null; // Try to find the element const elements = document.querySelectorAll(step.target); if (elements.length === 0) { console.warn(`Onboarding target not found: ${step.target}`); // Try once more after a short delay in case element is still loading setTimeout(() => { const retryElements = document.querySelectorAll(step.target); if (retryElements.length > 0) { target = retryElements[0]; this.displayStep(target, step, index); } else { console.warn(`Onboarding target still not found after retry: ${step.target}, skipping step`); this.showStep(index + 1); } }, 200); return; } // If multiple elements found, prefer visible ones or first one for (const el of elements) { const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); // Check if element is visible (not hidden by display:none or visibility:hidden) if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0') { target = el; break; } } // If no visible element found, use first one anyway if (!target && elements.length > 0) { target = elements[0]; // Try to make it visible if it's in a hidden dropdown const dropdown = target.closest('[id*="Dropdown"]'); if (dropdown && dropdown.classList.contains('hidden')) { dropdown.classList.remove('hidden'); } } if (!target) { console.warn(`Onboarding target not accessible: ${step.target}`); this.showStep(index + 1); return; } this.displayStep(target, step, index); } /** * Display a step for a given target element */ displayStep(target, step, index) { // Ensure element is visible (expand dropdowns, etc.) const dropdown = target.closest('[id*="Dropdown"]'); if (dropdown && dropdown.classList.contains('hidden')) { dropdown.classList.remove('hidden'); } // Scroll target into view first target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); // Wait for scroll animation to complete, then proceed setTimeout(() => { // Update tooltip content first (so we can measure it) this.updateTooltip(step, index); // Wait for tooltip to render with content, then position both highlight and tooltip requestAnimationFrame(() => { requestAnimationFrame(() => { // Get fresh element position after scroll const finalRect = target.getBoundingClientRect(); const style = window.getComputedStyle(target); // Validate that element is actually visible if (finalRect.width === 0 || finalRect.height === 0) { console.warn('Target element has zero dimensions, but continuing anyway'); // Don't skip - just proceed with positioning } // Highlight target after content is rendered this.highlightElement(target); // Position tooltip this.positionTooltip(target, step); }); }); }, 400); } /** * Highlight target element */ highlightElement(element) { let highlight = document.querySelector('.onboarding-highlight'); let mask = document.querySelector('.onboarding-mask'); if (!highlight) { highlight = document.createElement('div'); highlight.className = 'onboarding-highlight'; document.body.appendChild(highlight); } if (!mask) { mask = document.createElement('div'); mask.className = 'onboarding-mask'; mask.style.cssText = ` position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); z-index: 9999; pointer-events: none; transition: opacity 0.3s ease-out; `; document.body.appendChild(mask); } // Use getBoundingClientRect which already accounts for scroll const rect = element.getBoundingClientRect(); const padding = 10; // Calculate highlight position const highlightTop = Math.round(rect.top - padding); const highlightLeft = Math.round(rect.left - padding); const highlightWidth = Math.round(rect.width + padding * 2); const highlightHeight = Math.round(rect.height + padding * 2); // Use fixed positioning to match viewport coordinates highlight.style.position = 'fixed'; highlight.style.top = `${highlightTop}px`; highlight.style.left = `${highlightLeft}px`; highlight.style.width = `${highlightWidth}px`; highlight.style.height = `${highlightHeight}px`; highlight.style.display = 'block'; highlight.style.zIndex = '10000'; highlight.style.visibility = 'visible'; // Create a mask that reveals the highlighted area // Use CSS radial gradient for the cutout const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; // Make cutout larger than the highlight for better visibility const ellipseWidth = Math.max(highlightWidth * 2, 200); const ellipseHeight = Math.max(highlightHeight * 2, 200); // Apply mask to overlay mask element using CSS radial gradient // In CSS mask-image: white/opaque = visible (shows), black/transparent = hidden (hides) // We want: hide overlay at center (reveal element), show overlay at edges (cover everything else) // So: transparent at center (hides overlay), white at edges (shows overlay) const overlayMask = document.querySelector('.onboarding-mask'); if (overlayMask) { // Radial gradient: transparent at center (hides overlay, reveals element), white at edges (shows overlay, covers background) const maskGradient = `radial-gradient(ellipse ${ellipseWidth}px ${ellipseHeight}px at ${centerX}px ${centerY}px, transparent 0%, transparent 50%, rgba(255,255,255,0.2) 65%, rgba(255,255,255,0.6) 80%, white 100%)`; overlayMask.style.maskImage = maskGradient; overlayMask.style.webkitMaskImage = maskGradient; overlayMask.style.maskSize = '100% 100%'; overlayMask.style.maskPosition = '0 0'; overlayMask.style.maskRepeat = 'no-repeat'; } // Also apply mask to the base overlay const baseOverlay = document.querySelector('.onboarding-overlay'); if (baseOverlay) { const overlayGradient = `radial-gradient(ellipse ${ellipseWidth}px ${ellipseHeight}px at ${centerX}px ${centerY}px, transparent 0%, transparent 50%, rgba(255,255,255,0.2) 65%, rgba(255,255,255,0.6) 80%, white 100%)`; baseOverlay.style.maskImage = overlayGradient; baseOverlay.style.webkitMaskImage = overlayGradient; baseOverlay.style.maskSize = '100% 100%'; baseOverlay.style.maskPosition = '0 0'; baseOverlay.style.maskRepeat = 'no-repeat'; } } /** * Position tooltip relative to target */ positionTooltip(element, step) { // Get element position first - getBoundingClientRect already accounts for scroll const rect = element.getBoundingClientRect(); // Validate element is visible if (rect.width === 0 || rect.height === 0) { console.warn('Cannot position tooltip: element has zero dimensions'); return; } // Ensure tooltip is positioned off-screen for measurement this.tooltip.style.position = 'fixed'; this.tooltip.style.top = '-9999px'; this.tooltip.style.left = '-9999px'; this.tooltip.style.visibility = 'hidden'; this.tooltip.style.display = 'block'; this.tooltip.style.opacity = '0'; this.tooltip.style.transform = 'scale(0)'; // Force a reflow to ensure tooltip is rendered void this.tooltip.offsetWidth; // Now measure the tooltip const tooltipRect = this.tooltip.getBoundingClientRect(); const position = step.position || 'bottom'; // If tooltip has no dimensions, use default dimensions const tooltipWidth = tooltipRect.width > 0 ? tooltipRect.width : 400; const tooltipHeight = tooltipRect.height > 0 ? tooltipRect.height : 200; let top, left; // Calculate position based on preference // All coordinates are relative to viewport (from getBoundingClientRect) switch (position) { case 'top': top = rect.top - tooltipHeight - 20; left = rect.left + (rect.width / 2) - (tooltipWidth / 2); break; case 'bottom': top = rect.bottom + 20; left = rect.left + (rect.width / 2) - (tooltipWidth / 2); break; case 'left': top = rect.top + (rect.height / 2) - (tooltipHeight / 2); left = rect.left - tooltipWidth - 20; break; case 'right': top = rect.top + (rect.height / 2) - (tooltipHeight / 2); left = rect.right + 20; break; default: top = rect.bottom + 20; left = rect.left + (rect.width / 2) - (tooltipWidth / 2); } // Keep within viewport const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const padding = 10; // Adjust horizontal position if needed if (left < padding) { left = padding; } else if (left + tooltipWidth > viewportWidth - padding) { left = Math.max(padding, viewportWidth - tooltipWidth - padding); } // Adjust vertical position if needed if (top < padding) { // If tooltip would go above viewport, try bottom instead if (position === 'top') { top = rect.bottom + 20; } else { top = padding; } } else if (top + tooltipHeight > viewportHeight - padding) { // If tooltip would go below viewport, try top instead if (position === 'bottom') { top = Math.max(padding, rect.top - tooltipHeight - 20); } else { top = Math.max(padding, viewportHeight - tooltipHeight - padding); } } // Validate final coordinates are reasonable if (isNaN(top) || isNaN(left) || top < 0 || left < 0) { console.error('Invalid tooltip position calculated:', { top, left, rect }); // Fallback to center of viewport top = (viewportHeight - tooltipHeight) / 2; left = (viewportWidth - tooltipWidth) / 2; } // Apply final position using fixed positioning (viewport coordinates) this.tooltip.style.position = 'fixed'; this.tooltip.style.top = `${Math.round(top)}px`; this.tooltip.style.left = `${Math.round(left)}px`; this.tooltip.style.visibility = 'visible'; this.tooltip.style.display = 'block'; this.tooltip.style.opacity = '1'; this.tooltip.style.transform = 'scale(1)'; this.tooltip.style.zIndex = '10001'; // Ensure it's above overlay (z-index 9998) and highlight (10000) } /** * Update tooltip content */ updateTooltip(step, index) { const isLast = index === this.steps.length - 1; // Store reference to manager for event handlers const manager = this; this.tooltip.innerHTML = `