Public Access
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.
451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
/**
|
||
* 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
|
||
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>
|
||
</>
|
||
)
|
||
}
|