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.
126 lines
5.0 KiB
TypeScript
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
|