diff --git a/package-lock.json b/package-lock.json index 4b8563c..f6a332c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "posthog-js": "^1.376.2", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-hot-toast": "^2.6.0", "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.0", "tailwindcss": "^4.2.2" @@ -2170,7 +2171,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -2632,6 +2632,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3452,6 +3461,23 @@ "react": "^19.2.4" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-leaflet": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", diff --git a/package.json b/package.json index 1a3bce8..df4b751 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "posthog-js": "^1.376.2", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-hot-toast": "^2.6.0", "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.0", "tailwindcss": "^4.2.2" diff --git a/src/App.tsx b/src/App.tsx index 339d66b..651f57e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; import Layout from './components/Layout'; import LandingPage from './pages/LandingPage'; import AuthPage from './pages/AuthPage'; @@ -8,12 +9,17 @@ import AnalysisDashboard from './pages/AnalysisDashboard'; import MarketMapPage from './pages/MarketMapPage'; import ResultsPage from './pages/ResultsPage'; import PostHogPageView from './components/PostHogPageView'; +import NotFound from './pages/NotFound'; // Importing the new 404 component export default function App() { return ( + {/* Toast provider for global error notifications */} + + {/* Fires a $pageview event to PostHog on every SPA route change */} + }> } /> @@ -23,8 +29,11 @@ export default function App() { } /> } /> } /> + + {/* Catch-all route for broken links/404s */} + } /> ); -} +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 3ce7b8e..b8df5e6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,3 +1,4 @@ +import toast from 'react-hot-toast'; import type { ScanResult, HistoryScan, @@ -36,10 +37,39 @@ function authHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } -// ── Core fetch wrapper ──────────────────────────────────────────────────────── +// ── Shared Error Handling Logic ────────────────────────────────────────────── + +async function handleResponse(res: Response): Promise { + if (res.ok) return res; + + // Handle 5xx errors (Server Side) + if (res.status >= 500) { + toast.error("Server error. Please try again later."); + } else { + // Handle 4xx errors + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error((err as { detail?: string }).detail || `HTTP ${res.status}`); + } + + throw new Error(`HTTP ${res.status}`); +} + +// Reusable wrapper to catch network-level drops +async function safeFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + try { + const res = await fetch(input, init); + return await handleResponse(res); + } catch (error) { + if (error instanceof TypeError) { + toast.error("Unable to connect to the server. Please check your internet connection."); + } + console.error("API Error:", error); + throw error; + } +} async function apiFetch(path: string, options: RequestInit = {}): Promise { - const res = await fetch(`${API_BASE}${path}`, { + const validRes = await safeFetch(`${API_BASE}${path}`, { ...options, headers: { 'Content-Type': 'application/json', @@ -47,98 +77,55 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise ...(options.headers as Record || {}), }, }); - - if (!res.ok) { - const err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error((err as { detail?: string }).detail || `HTTP ${res.status}`); - } - - return res.json() as Promise; + return validRes.json() as Promise; } // ── Response envelopes ──────────────────────────────────────────────────────── -export interface ScanResponse { - success: boolean; - scan: ScanResult; -} - -export interface HistoryResponse { - success: boolean; - count: number; - stats: HistoryStats; - scans: HistoryScan[]; -} - -export interface MarketsResponse { - success: boolean; - markets: Market[]; -} - -export interface GradcamResponse { - gradcam_image: string; // base64 data-URI - predicted_class: string; - class_index: number; // 0 | 1 | 2 - mode: 'real' | 'demo'; -} +export interface ScanResponse { success: boolean; scan: ScanResult; } +export interface HistoryResponse { success: boolean; count: number; stats: HistoryStats; scans: HistoryScan[]; } +export interface MarketsResponse { success: boolean; markets: Market[]; } +export interface GradcamResponse { gradcam_image: string; predicted_class: string; class_index: number; mode: 'real' | 'demo'; } // ── API surface ─────────────────────────────────────────────────────────────── export const api = { - // Auth loginUrl: (): string => `${API_BASE}/api/v1/auth/login/google`, - getMe: (): Promise => - apiFetch('/api/v1/auth/me'), + getMe: (): Promise => apiFetch('/api/v1/auth/me'), - // Scans + // Scans - Using safeFetch to ensure network errors are caught submitScan: async (blob: Blob): Promise => { const form = new FormData(); form.append('image', blob, 'scan.jpg'); - const res = await fetch(`${API_BASE}/api/v1/scan-auto`, { + const validRes = await safeFetch(`${API_BASE}/api/v1/scan-auto`, { method: 'POST', headers: authHeaders(), body: form, }); - if (!res.ok) { - const err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error((err as { detail?: string }).detail || `HTTP ${res.status}`); - } - - return res.json() as Promise; + return validRes.json() as Promise; }, - getLatestScan: (): Promise => - apiFetch('/api/v1/scans/latest'), - - getScan: (id: string): Promise => - apiFetch(`/api/v1/scans/${id}`), - - getScanHistory: (limit = 20, offset = 0): Promise => + getLatestScan: (): Promise => apiFetch('/api/v1/scans/latest'), + getScan: (id: string): Promise => apiFetch(`/api/v1/scans/${id}`), + getScanHistory: (limit = 20, offset = 0): Promise => apiFetch(`/api/v1/scans/history?limit=${limit}&offset=${offset}`), - // Grad-CAM + // Grad-CAM - Using safeFetch to ensure network errors are caught getGradcam: async (blob: Blob): Promise => { const form = new FormData(); form.append('image', blob, 'gradcam_input.jpg'); - const res = await fetch(`${API_BASE}/api/v1/gradcam`, { + const validRes = await safeFetch(`${API_BASE}/api/v1/gradcam`, { method: 'POST', headers: authHeaders(), body: form, }); - if (!res.ok) { - const err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error((err as { detail?: string }).detail || `HTTP ${res.status}`); - } - - return res.json() as Promise; + return validRes.json() as Promise; }, - // Map - getMarkets: (): Promise => - apiFetch('/api/v1/maps/markets'), -}; + getMarkets: (): Promise => apiFetch('/api/v1/maps/markets'), +}; \ No newline at end of file diff --git a/src/pages/AuthPage.tsx b/src/pages/AuthPage.tsx index e826578..616e2ec 100644 --- a/src/pages/AuthPage.tsx +++ b/src/pages/AuthPage.tsx @@ -21,33 +21,43 @@ export default function AuthPage() { const error = params.get('error'); if (error) { - Promise.resolve().then(() => { - setStatus('error'); - setErrorMsg('Authentication failed. Please try again.'); - }); + setStatus('error'); + setErrorMsg('Authentication failed. Please try again.'); window.history.replaceState({}, '', '/auth'); return; } if (accessToken) { - Promise.resolve().then(() => setStatus('processing')); + setStatus('processing'); setToken(accessToken); window.history.replaceState({}, '', '/auth'); navigate('/mode', { replace: true }); return; } - // If already authenticated, skip straight to mode select if (isAuthenticated()) { navigate('/mode', { replace: true }); } }, [navigate, posthog]); const handleGoogleLogin = () => { - window.location.href = api.loginUrl(); + try { + setStatus('processing'); + const loginUrl = api.loginUrl(); + + if (!loginUrl) { + throw new Error("Login URL configuration missing"); + } + + // Force full browser navigation for OAuth + window.location.href = loginUrl; + } catch (err) { + setStatus('error'); + setErrorMsg('Could not initiate Google Login. Please check your network connection.'); + console.error("Auth initiation failed:", err); + } }; - /** Dev-only: skip OAuth entirely, store the bypass token, go straight to /mode */ const handleDevLogin = () => { setToken(DEV_BYPASS_TOKEN); posthog?.identify('dev-user', { email: 'dev@local' }); @@ -55,7 +65,7 @@ export default function AuthPage() { }; const terminalMessages = (() => { - if (status === 'processing') return ['AUTH_SUCCESS', 'REDIRECTING...']; + if (status === 'processing') return ['AUTH_INITIATED', 'REDIRECTING_TO_OAUTH...']; if (status === 'error') return ['AUTH_ERROR', 'RETRY_REQUIRED']; return ['AUTHENTICATION', 'PROTOCOL: OAUTH-SECURE']; })(); @@ -110,7 +120,6 @@ export default function AuthPage() { {status === 'processing' ? 'AUTHENTICATING...' : 'CONTINUE_WITH_GOOGLE'} - {/* DEV ONLY — shown only when VITE_DEV_MODE=true in .env.local */} {IS_DEV_MODE && ( )} - - - - ); -} +} \ No newline at end of file diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..e171101 --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,20 @@ +// src/pages/NotFound.tsx +import { Link } from 'react-router-dom'; + +export default function NotFound() { + return ( + + 404 + Page Not Found + + Oops! The page you are looking for doesn't exist or has been moved. + + + Return Home + + + ); +} \ No newline at end of file
+ Oops! The page you are looking for doesn't exist or has been moved. +