Files
Meal-Planner/frontend/src/components/OnboardingTour.tsx
T
admin 1562929f6b fix(ui): Sprint 9 — dismiss X / Skip tour did not hide the dialog
Root cause: the OnboardingTour early-return is gated on
isComplete=true, but App.tsx was calling onboarding.reset() on
onComplete. reset() does the inverse: clears the localStorage key
and flips isComplete to FALSE. The user clicked X, the localStorage
key got written, but the App-level flag flipped to false, so the
tour re-rendered and the early-return did not fire — the dialog
stayed visible.

Fix: split the dismiss and reset paths into two distinct callbacks
onComplete (dismiss) and onReset (re-show). Added markComplete to
useOnboarding: flips isComplete to true. App wires:
  onComplete -> onboarding.markComplete()
  onReset    -> onboarding.reset()
The tour itself still calls writeComplete() before invoking
onComplete, so the localStorage key is written once on dismiss.

Also cleaned markComplete: it now only flips state (the tour already
wrote the key), removing a redundant double-write.

Verified npm run build green on docker-willester. No regression
expected; all other Sprint 9 code paths untouched.
2026-06-05 13:40:51 -07:00

451 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 MonSun 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<boolean>(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<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).
// 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<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>
</>
)
}