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() {