Public Access
feat(ui): Sprint 9 — F1 onboarding tour (4-step welcome)
Hand-rolled 4-step tour (no react-joyride) anchors to existing [data-tour="<id>"] attributes. localStorage key mealplanner:onboarding-complete is the source of truth; ?reset-tour=1 clears the key and re-shows. Steps: Dashboard / Pantry / Recipes / Shopping List. Keyboard: 1-4 jump, ←/→ step, Esc dismiss. Off-route fallback renders a centered card with an 'Open <page>' CTA. A11y: role=dialog, aria-modal=true, focus captured on open and restored on close. 5 lines of code across 4 pages; 1 new component (~420 lines). No new dependencies. No backend changes. No migration. Frontend-only deploy. Tracking: Review/sprint9-verification.md (8-step browser smoke + a11y check + reset-link test).
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user