@@ -0,0 +1,428 @@
/**
* Onboarding tour — first-visit welcome walkthrough.
*
* Hand-rolled to avoid pulling in `react-joyride` (no new npm dep).
* Anchors to elements via [data-tour="<id>"] 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 <page>" 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 <div> 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 ; isComplete : boolean } {
const [ isComplete , setIsComplete ] = useState < boolean > ( readComplete )
const reset = useCallback ( ( ) = > {
clearComplete ( )
setIsComplete ( false )
} , [ ] )
const show = useCallback ( ( ) = > {
clearComplete ( )
setIsComplete ( false )
} , [ ] )
return { reset , show , isComplete }
}
export function OnboardingTour ( {
isComplete ,
onComplete ,
} : {
isComplete : boolean
onComplete : ( ) = > void
} ) {
const location = useLocation ( )
const navigate = useNavigate ( )
const [ step , setStep ] = useState ( 0 )
const [ anchorRect , setAnchorRect ] = useState < DOMRect | null > ( null )
const dialogRef = useRef < HTMLDivElement | null > ( null )
const primaryRef = useRef < HTMLButtonElement | null > ( null )
const previouslyFocused = useRef < HTMLElement | null > ( null )
const currentStep = STEPS [ step ]
const isLast = step === STEPS . length - 1
// Handle ?reset-tour=1 (clears the key; tour shows on next render).
useEffect ( ( ) = > {
const sp = new URLSearchParams ( location . search )
if ( sp . get ( RESET_PARAM ) === '1' ) {
clearComplete ( )
onComplete ( )
// 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 `onComplete` (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 < HTMLElement > ( ` [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 <page>" 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 (
< div
ref = { dialogRef }
role = "dialog"
aria-modal = "true"
aria-labelledby = "onboarding-title"
className = "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 animate-fade-in"
>
< div className = "bg-white rounded-2xl shadow-2xl border border-surface-200 w-full max-w-md p-6 space-y-4" >
< div className = "flex items-start justify-between gap-3" >
< div className = "flex items-center gap-3" >
< div className = "w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0" >
< Icon className = "w-5 h-5 text-primary-600" / >
< / div >
< h2 id = "onboarding-title" className = "text-lg font-semibold text-surface-900" >
{ currentStep . title }
< / h2 >
< / div >
< button
type = "button"
onClick = { finish }
aria-label = "Dismiss tour"
className = "p-1 rounded hover:bg-surface-100 text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
< X className = "w-4 h-4" / >
< / button >
< / div >
< p className = "text-sm text-surface-600 leading-relaxed" > { currentStep . body } < / p >
< p className = "text-xs text-surface-500" >
Step { step + 1 } of { STEPS . length } — open the highlighted page to see it in context .
< / p >
< div className = "flex items-center justify-between gap-2 pt-2" >
< button
type = "button"
onClick = { finish }
className = "text-xs text-surface-500 hover:text-surface-700 focus:outline-none focus:ring-2 focus:ring-primary-400 rounded px-1"
>
Skip tour
< / button >
< div className = "flex items-center gap-2" >
< button
type = "button"
onClick = { goJumpToAnchor }
className = "text-xs font-medium px-3 py-1.5 rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
Open { currentStep . route === '/' ? 'Dashboard' : currentStep . route . slice ( 1 ) }
< / button >
< button
ref = { primaryRef }
type = "button"
onClick = { goNext }
className = "text-xs font-medium px-3 py-1.5 rounded-lg border border-primary-300 text-primary-700 hover:bg-primary-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
{ isLast ? 'Got it' : 'Next' }
< / button >
< / div >
< / div >
< / div >
< / div >
)
}
// 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. */ }
< div
className = "fixed inset-0 z-40 bg-black/20 pointer-events-none animate-fade-in"
aria-hidden = "true"
/ >
{ /* Anchor highlight ring. Purely decorative; the tooltip is the
real "look here" signal. */ }
< div
className = "fixed z-40 pointer-events-none rounded-xl ring-4 ring-primary-400 ring-offset-2 ring-offset-white animate-fade-in"
style = { {
top : anchorRect.top - 4 ,
left : anchorRect.left - 4 ,
width : anchorRect.width + 8 ,
height : anchorRect.height + 8 ,
} }
aria-hidden = "true"
/ >
< div
ref = { dialogRef }
role = "dialog"
aria-modal = "true"
aria-labelledby = "onboarding-title"
className = "fixed z-50 animate-fade-in"
style = { { top , left , width : CARD_WIDTH } }
>
< div className = "bg-white rounded-2xl shadow-2xl border border-surface-200 p-5 space-y-3" >
< div className = "flex items-start justify-between gap-3" >
< div className = "flex items-center gap-2.5" >
< div className = "w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center flex-shrink-0" >
< Icon className = "w-4 h-4 text-primary-600" / >
< / div >
< h2 id = "onboarding-title" className = "text-base font-semibold text-surface-900 leading-tight" >
{ currentStep . title }
< / h2 >
< / div >
< button
type = "button"
onClick = { finish }
aria-label = "Dismiss tour"
className = "p-1 rounded hover:bg-surface-100 text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-400 -mt-1 -mr-1"
>
< X className = "w-4 h-4" / >
< / button >
< / div >
< p className = "text-sm text-surface-600 leading-relaxed" > { currentStep . body } < / p >
< div className = "flex items-center gap-1 pt-1" aria-label = "Tour progress" >
{ STEPS . map ( ( s , i ) = > (
< span
key = { s . id }
className = { ` h-1.5 flex-1 rounded-full transition-colors ${
i <= step ? 'bg-primary-500' : 'bg-surface-200'
} ` }
/ >
) ) }
< / div >
< div className = "flex items-center justify-between gap-2 pt-1" >
< button
type = "button"
onClick = { finish }
className = "text-xs text-surface-500 hover:text-surface-700 focus:outline-none focus:ring-2 focus:ring-primary-400 rounded px-1"
>
Skip tour
< / button >
< div className = "flex items-center gap-2" >
{ step > 0 && (
< button
type = "button"
onClick = { ( ) = > setStep ( s = > s - 1 ) }
className = "text-xs font-medium px-2.5 py-1.5 rounded-lg border border-surface-300 text-surface-700 hover:bg-surface-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
Back
< / button >
) }
< button
ref = { primaryRef }
type = "button"
onClick = { goNext }
className = "text-xs font-medium px-3 py-1.5 rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
{ isLast ? 'Got it' : 'Next' }
< / button >
< / div >
< / div >
< / div >
< / div >
< / >
)
}