diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 80ebfb7..55d236f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query' import { ErrorBoundary } from './components/ErrorBoundary' +import { showApiError } from './lib/toast' import Dashboard from './pages/Dashboard' import MealDetail from './pages/MealDetail' import Pantry from './pages/Pantry' @@ -10,7 +11,24 @@ import RecipeDetail from './pages/RecipeDetail' import ShoppingList from './pages/ShoppingList' import NotFound from './pages/NotFound' -const queryClient = new QueryClient() +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() diff --git a/frontend/src/lib/toast.tsx b/frontend/src/lib/toast.tsx index eb2e856..5808944 100644 --- a/frontend/src/lib/toast.tsx +++ b/frontend/src/lib/toast.tsx @@ -1,5 +1,55 @@ import toast from 'react-hot-toast'; +export type ApiError = unknown; + +function isHttpError(err: unknown): err is { response?: { data?: { detail?: unknown } }; message?: string } { + return typeof err === 'object' && err !== null && 'response' in (err as object); +} + +function getFastApiDetail(err: unknown): string | null { + if (!isHttpError(err)) return null; + const detail = err.response?.data?.detail; + if (typeof detail === 'string') return detail; + if (Array.isArray(detail) && detail.length > 0) { + const first = detail[0] as { msg?: string } | string; + if (typeof first === 'string') return first; + if (typeof first === 'object' && first !== null && 'msg' in first) { + return (first as { msg?: string }).msg ?? null; + } + } + return null; +} + +function getNetworkMessage(err: unknown): string | null { + if (!isHttpError(err)) return null; + const msg = err.message; + if (typeof msg === 'string' && msg && msg !== 'Network Error') return msg; + return null; +} + +/** + * Normalize any thrown value (FastAPI HTTP error, network error, JS error) + * to a short user-facing string. Falls back to a generic message so we + * never surface a raw "[object Object]" or stack trace. + */ +export function extractErrorMessage(err: unknown, fallback = 'Something went wrong'): string { + return ( + getFastApiDetail(err) ?? + getNetworkMessage(err) ?? + (err instanceof Error ? err.message : null) ?? + fallback + ); +} + +/** + * Show a toast for any error. Use as the MutationCache/QueryCache + * global onError, or call directly from per-mutation onError handlers + * that need a normalized message. + */ +export function showApiError(err: unknown, fallback?: string): void { + showToast.error(extractErrorMessage(err, fallback)); +} + export const showToast = { success: (message: string) => toast.success(message), error: (message: string) => toast.error(message), diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ee5f381..abb2a69 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import { GripVertical, X } from 'lucide-react' import toast from 'react-hot-toast' -import { showToast } from '../lib/toast' +import { showToast, showApiError } from '../lib/toast' import { DragDropContext, Droppable, @@ -282,8 +282,8 @@ function VoteEmailButton() { const res = await mealPlannerApi.admin.triggerOrchestrate('email') const status = res.data?.status || res.data?.message || 'sent' toast.success(status) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to send vote emails') + } catch (err) { + showApiError(err, 'Failed to send vote emails') } finally { setSendingVoteEmail(false) } @@ -316,8 +316,8 @@ export default function Dashboard() { await mealPlannerApi.meals.moveItem(itemId, newDay, newType) toast.success('Meal moved') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to move meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -334,8 +334,8 @@ export default function Dashboard() { await mealPlannerApi.meals.approveItem(itemId) toast.success('Meal approved') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to approve meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -344,8 +344,8 @@ export default function Dashboard() { await mealPlannerApi.meals.denyItem(itemId) toast.success('Meal denied') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to deny meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -367,13 +367,13 @@ export default function Dashboard() { ) queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) toast.success('Slot filled with a new meal') - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to refill slot') + } catch (err) { + showApiError(err, 'Failed to refill slot') } } ) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to delete meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -383,8 +383,8 @@ export default function Dashboard() { await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType) toast.success('Meal generated') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to generate meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -435,7 +435,10 @@ export default function Dashboard() {
- + {mealPlan.status.replace(/_/g, ' ')} {mealPlan.total_estimated_cost !== undefined && ( diff --git a/frontend/src/pages/MealDetail.tsx b/frontend/src/pages/MealDetail.tsx index bffd713..619317b 100644 --- a/frontend/src/pages/MealDetail.tsx +++ b/frontend/src/pages/MealDetail.tsx @@ -113,9 +113,6 @@ export default function MealDetail() { setEditingFeedback(false) showToast.success('Feedback saved!') }, - onError: () => { - showToast.error('Failed to save feedback. Please try again.') - }, }) if (isLoading) { diff --git a/frontend/src/pages/Pantry.tsx b/frontend/src/pages/Pantry.tsx index 0f254ef..591854a 100644 --- a/frontend/src/pages/Pantry.tsx +++ b/frontend/src/pages/Pantry.tsx @@ -9,7 +9,7 @@ import { Input } from '../components/ui/Input' import { Select } from '../components/ui/Select' import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' -import { showToast } from '../lib/toast' +import { showToast, showApiError } from '../lib/toast' const AISLE_OPTIONS = [ { value: '', label: 'Select aisle…' }, @@ -62,9 +62,6 @@ export default function Pantry() { setAisle('') showToast.success('Item added to pantry') }, - onError: () => { - showToast.error('Failed to add item') - }, }) const removeMutation = useMutation({ @@ -74,10 +71,6 @@ export default function Pantry() { showToast.success('Item removed') setRemoveId(null) }, - onError: () => { - showToast.error('Failed to remove item') - setRemoveId(null) - }, }) async function handleRemove(item: HomePantryItem) { @@ -100,13 +93,13 @@ export default function Pantry() { }) queryClient.invalidateQueries({ queryKey: ['pantry'] }) showToast.success('Item restored') - } catch { - showToast.error('Failed to restore item') + } catch (err) { + showApiError(err, 'Failed to restore item') } } ) } catch { - showToast.error('Failed to remove item') + // Error toast fires from the global MutationCache handler. } finally { setRemoveId(null) } @@ -133,7 +126,7 @@ export default function Pantry() { // refresh ingredient list so next add sees it await queryClient.invalidateQueries({ queryKey: ['ingredients'] }) } catch { - showToast.error('Failed to create ingredient') + // Error toast fires from the global MutationCache handler. return } }