/** * Onboarding tour — first-visit welcome walkthrough. * * Hand-rolled to avoid pulling in `react-joyride` (no new npm dep). * Anchors to elements via [data-tour=""] attributes. The tour is * controlled by a single useState pair: `step` (0..N) and a * `dismissed` boolean persisted in localStorage. * * Behaviour: * - First visit (no localStorage key): auto-shows on Dashboard mount. * - User clicks "Got it" on the last step → writes the key, hides. * - User clicks "Skip" on any step → same as "Got it". * - User presses Escape → same as "Skip". * - User presses 1..N (when tour is visible) → jumps to that step. * - User presses ArrowLeft / ArrowRight → steps back / forward. * - User adds `?reset-tour=1` to any URL → clears the key, shows. * - Subsequent visits (key is set) → tour is hidden. * * Tour re-shows when the user navigates to a new step's anchor page * (the anchor for step N must be on the current route; otherwise the * step is rendered as a centered card with a "Go to " button). * * Accessibility: * - role="dialog", aria-modal="true", aria-labelledby points to the * step title. * - Focus is moved to the primary action button when the step opens. * - The previous focused element is restored on dismiss. * - Tooltip is a real
not a portal, so screen readers find it. */ import { useCallback, useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { CalendarDays, ShoppingBasket, SlidersHorizontal, Truck, X } from 'lucide-react' const STORAGE_KEY = 'mealplanner:onboarding-complete' const RESET_PARAM = 'reset-tour' export type TourStepId = 'dashboard' | 'pantry' | 'recipes' | 'shopping-list' interface TourStep { id: TourStepId route: string title: string body: string icon: typeof CalendarDays } const STEPS: TourStep[] = [ { id: 'dashboard', route: '/', title: 'Your weekly meal plan', body: 'The dashboard shows the upcoming Mon–Sun week. Approve, deny, or skip meals, and the planner learns what your family likes.', icon: CalendarDays, }, { id: 'pantry', route: '/pantry', title: 'What you have in stock', body: 'Pantry tracks ingredients already at home. The planner uses it to avoid duplicates and the shopping list subtracts from it.', icon: Truck, }, { id: 'recipes', route: '/recipes', title: 'Browse + filter recipes', body: 'Open the Filters panel to narrow by cuisine, protein, dietary tag, prep time, spice, or calories. Search by name or ingredient.', icon: SlidersHorizontal, }, { id: 'shopping-list', route: '/shopping-list', title: 'Plan → shop → restock', body: 'The shopping list is generated from the plan, minus what is in your pantry. Check items and bulk-add them back to pantry when you return from the store.', icon: ShoppingBasket, }, ] function readComplete(): boolean { try { return localStorage.getItem(STORAGE_KEY) === '1' } catch { return false } } function writeComplete(): void { try { localStorage.setItem(STORAGE_KEY, '1') } catch { // localStorage may be disabled (private mode, etc.) — silently // skip; the tour will just re-show on the next visit. } } function clearComplete(): void { try { localStorage.removeItem(STORAGE_KEY) } catch { // see above } } export function useOnboarding(): { reset: () => void show: () => void markComplete: () => void isComplete: boolean } { const [isComplete, setIsComplete] = useState(readComplete) // Used by ?reset-tour=1 and any other "re-show" path. Clears the // localStorage key and flips the App-level flag so the tour re-appears. const reset = useCallback(() => { clearComplete() setIsComplete(false) }, []) const show = useCallback(() => { clearComplete() setIsComplete(false) }, []) // Used by the tour's dismiss path (X / Skip / Esc / "Got it" on the // last step). Called from App when onComplete fires. Just flips // the App-level flag to true so the tour's early-return fires on // the next render. The localStorage key is already written by the // tour's finish() before it invokes onComplete. const markComplete = useCallback(() => { setIsComplete(true) }, []) return { reset, show, markComplete, isComplete } } export function OnboardingTour({ isComplete, onComplete, onReset, }: { isComplete: boolean /** Called when the user dismisses the tour (X / Skip / Esc / "Got it"). */ onComplete: () => void /** Called when the tour re-shows (e.g. ?reset-tour=1). Inverse of onComplete. */ onReset: () => void }) { const location = useLocation() const navigate = useNavigate() const [step, setStep] = useState(0) const [anchorRect, setAnchorRect] = useState(null) const dialogRef = useRef(null) const primaryRef = useRef(null) const previouslyFocused = useRef(null) const currentStep = STEPS[step] const isLast = step === STEPS.length - 1 // Handle ?reset-tour=1 (clears the key; tour shows on next render). // Calls onReset() to flip the App-level flag to false (the inverse // of onComplete, which flips it to true on dismiss). useEffect(() => { const sp = new URLSearchParams(location.search) if (sp.get(RESET_PARAM) === '1') { clearComplete() onReset() // Strip the param so a refresh doesn't re-trigger the reset. sp.delete(RESET_PARAM) const next = sp.toString() navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true }) setStep(0) } // We intentionally don't depend on `onReset` (changes per render) // — the only effect we want is when the URL search changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.search]) // Auto-show on first visit (Dashboard route). Don't auto-show on // every route — only on `/`, which is the app's landing page. useEffect(() => { if (isComplete) return if (location.pathname === '/') { setStep(0) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isComplete, location.pathname]) // Track the anchor element's position. We poll on a rAF loop while // the tour is open because the user can resize the window and the // page can scroll. requestAnimationFrame keeps the tooltip glued to // the anchor without burning CPU. useEffect(() => { if (isComplete) return if (!currentStep) return let raf = 0 const tick = () => { const el = document.querySelector(`[data-tour="${currentStep.id}"]`) if (el) { setAnchorRect(el.getBoundingClientRect()) } else { setAnchorRect(null) } raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) }, [isComplete, currentStep]) // Capture focus on open, restore on close. useEffect(() => { if (isComplete) return previouslyFocused.current = document.activeElement as HTMLElement | null // Defer focus to next tick so the dialog is in the DOM. const id = window.setTimeout(() => primaryRef.current?.focus(), 0) return () => { window.clearTimeout(id) previouslyFocused.current?.focus?.() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isComplete, step]) // Keyboard: 1..N jump, ←/→ step, Esc dismiss. useEffect(() => { if (isComplete) return const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() finish() return } if (e.key === 'ArrowRight') { e.preventDefault() if (isLast) { finish() } else { setStep(s => s + 1) } return } if (e.key === 'ArrowLeft') { e.preventDefault() setStep(s => Math.max(0, s - 1)) return } // 1..N jump if (e.key.length === 1 && /^[1-9]$/.test(e.key)) { const idx = parseInt(e.key, 10) - 1 if (idx >= 0 && idx < STEPS.length) { e.preventDefault() setStep(idx) } } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) // eslint-disable-next-line react-hooks/exhaustive-deps }, [isComplete, isLast]) const finish = useCallback(() => { writeComplete() onComplete() }, [onComplete]) const goNext = useCallback(() => { if (isLast) finish() else setStep(s => s + 1) }, [isLast, finish]) const goJumpToAnchor = useCallback(() => { navigate(currentStep.route) }, [navigate, currentStep]) if (isComplete || !currentStep) return null const Icon = currentStep.icon const isOffRoute = location.pathname !== currentStep.route // If the user is on the wrong route, render a centered card with a // "Go to " CTA. The tooltip-pointer layout needs a real anchor // to point at; without one we'd just be a floating rectangle. if (isOffRoute || !anchorRect) { return (

{currentStep.title}

{currentStep.body}

Step {step + 1} of {STEPS.length} — open the highlighted page to see it in context.

) } // Anchored tooltip: position the card 16px below the anchor (or above // if it would clip the viewport). Clamp horizontally to keep the card // on-screen. Mobile: prefer the top of the viewport so the card never // gets clipped. const isMobile = typeof window !== 'undefined' && window.innerWidth < 640 const ANCHOR_GAP = 16 const CARD_WIDTH = 320 const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800 const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024 const placeBelow = !isMobile && anchorRect.bottom + ANCHOR_GAP + 200 < viewportH const top = placeBelow ? anchorRect.bottom + ANCHOR_GAP : Math.max(16, anchorRect.top - ANCHOR_GAP - 200) const left = isMobile ? 16 : Math.max(16, Math.min(viewportW - CARD_WIDTH - 16, anchorRect.left + anchorRect.width / 2 - CARD_WIDTH / 2)) return ( <> {/* Soft scrim. The page is still visible — this is a hint, not a modal. We don't use a full overlay because the user must be able to see the page element being explained. */}