Files
Meal-Planner/frontend/src/App.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

126 lines
5.0 KiB
TypeScript

import { BrowserRouter, Routes, Route, Link, useLocation, useNavigate, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'
import { ErrorBoundary } from './components/ErrorBoundary'
import { ShortcutHelpBanner, SHOW_SHORTCUT_HELP_EVENT } from './components/ShortcutHelpBanner'
import { OnboardingTour, useOnboarding } from './components/OnboardingTour'
import { showApiError } from './lib/toast'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { requestFocusSearch } from './hooks/useFocusSearch'
import Dashboard from './pages/Dashboard'
import MealDetail from './pages/MealDetail'
import Pantry from './pages/Pantry'
import Recipes from './pages/Recipes'
import Recommended from './pages/Recommended'
import RecipeDetail from './pages/RecipeDetail'
import ShoppingList from './pages/ShoppingList'
import NotFound from './pages/NotFound'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
queryCache: new QueryCache({
onError: (error) => {
showApiError(error, 'Failed to load data')
},
}),
mutationCache: new MutationCache({
onError: (error) => {
showApiError(error)
},
}),
})
function Navigation() {
const location = useLocation()
const path = location.pathname
const isActive = (prefix: string) => path === prefix || path.startsWith(prefix + '/')
const linkClass = (prefix: string) =>
`inline-flex items-center px-2 sm:px-3 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
isActive(prefix)
? 'text-primary-700 bg-primary-50'
: 'text-surface-600 hover:bg-surface-100'
}`
return (
<nav className="bg-white border-b border-surface-200 sticky top-0 z-50" aria-label="Primary">
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
<div className="flex items-center gap-1 min-h-14 py-2">
<Link to="/" className={linkClass('/')} aria-current={isActive('/') ? 'page' : undefined}>MealPlanner</Link>
<Link to="/recipes" className={linkClass('/recipes')} aria-current={isActive('/recipes') ? 'page' : undefined}>Recipes</Link>
<Link to="/pantry" className={linkClass('/pantry')} aria-current={isActive('/pantry') ? 'page' : undefined}>Pantry</Link>
<Link to="/shopping-list" className={linkClass('/shopping-list')} aria-current={isActive('/shopping-list') ? 'page' : undefined}>Shopping List</Link>
</div>
</div>
</nav>
)
}
function GlobalShortcuts() {
const navigate = useNavigate()
useKeyboardShortcuts({
'g d': () => navigate('/'),
'g r': () => navigate('/recipes'),
'g p': () => navigate('/pantry'),
'g s': () => navigate('/shopping-list'),
'/': () => requestFocusSearch(),
'?': () => window.dispatchEvent(new CustomEvent(SHOW_SHORTCUT_HELP_EVENT)),
})
return null
}
function App() {
// Onboarding tour state lives at the App root so the localStorage
// key is read once on mount. The tour itself is mounted inside the
// router (it needs useLocation / useNavigate).
const onboarding = useOnboarding()
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<GlobalShortcuts />
<div className="min-h-screen bg-surface-50">
<Navigation />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" id="main-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/meals/:id" element={<MealDetail />} />
<Route path="/recipes" element={<Recipes />} />
<Route path="/recipes/recommended" element={<Recommended />} />
<Route path="/recommended" element={<Navigate to="/recipes/recommended" replace />} />
<Route path="/recipes/:id" element={<RecipeDetail />} />
<Route path="/pantry" element={<Pantry />} />
<Route path="/shopping-list" element={<ShoppingList />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<ShortcutHelpBanner />
<OnboardingTour
isComplete={onboarding.isComplete}
onComplete={() => {
// Dismiss path (X / Skip / Esc / "Got it"). The tour
// already wrote the localStorage key; flip the
// App-level flag to true so the early-return fires and
// the dialog disappears.
onboarding.markComplete()
}}
onReset={() => {
// Re-show path (?reset-tour=1). The tour already
// cleared the localStorage key; flip the App-level
// flag to false so the tour re-appears.
onboarding.reset()
}}
/>
</div>
</BrowserRouter>
</QueryClientProvider>
</ErrorBoundary>
)
}
export default App