From 817061855d377b6c1fa9a598770c47e0175b660b Mon Sep 17 00:00:00 2001 From: atmihaa_06 Date: Sat, 30 May 2026 08:06:03 +0530 Subject: [PATCH 1/2] fix(auth): implement toast notifications and resolve connection issues #12 --- package-lock.json | 28 ++++++++++- package.json | 1 + src/App.tsx | 11 +++- src/lib/api.ts | 112 ++++++++++++++++++----------------------- src/pages/AuthPage.tsx | 38 +++++++------- src/pages/NotFound.tsx | 20 ++++++++ 6 files changed, 127 insertions(+), 83 deletions(-) create mode 100644 src/pages/NotFound.tsx 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..481c67a 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,62 +37,61 @@ function authHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } -// ── Core fetch wrapper ──────────────────────────────────────────────────────── +// ── Shared Error Handling Logic ────────────────────────────────────────────── -async function apiFetch(path: string, options: RequestInit = {}): Promise { - const res = await fetch(`${API_BASE}${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...authHeaders(), - ...(options.headers as Record || {}), - }, - }); - - if (!res.ok) { +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}`); } - - return res.json() as Promise; + + throw new Error(`HTTP ${res.status}`); } -// ── Response envelopes ──────────────────────────────────────────────────────── - -export interface ScanResponse { - success: boolean; - scan: ScanResult; -} +async function apiFetch(path: string, options: RequestInit = {}): Promise { + try { + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authHeaders(), + ...(options.headers as Record || {}), + }, + }); -export interface HistoryResponse { - success: boolean; - count: number; - stats: HistoryStats; - scans: HistoryScan[]; + const validRes = await handleResponse(res); + return validRes.json() as Promise; + } catch (error) { + // Catch network-level drops (e.g., ERR_CONNECTION_REFUSED) + if (error instanceof TypeError) { + toast.error("Unable to connect to the server. Please check your internet connection."); + } + console.error("API Error:", error); + throw error; + } } -export interface MarketsResponse { - success: boolean; - markets: Market[]; -} +// ── Response envelopes ──────────────────────────────────────────────────────── -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 native fetch with shared handleResponse to accommodate FormData submitScan: async (blob: Blob): Promise => { const form = new FormData(); form.append('image', blob, 'scan.jpg'); @@ -102,24 +102,16 @@ export const api = { 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; + const validRes = await handleResponse(res); + 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 native fetch with shared handleResponse getGradcam: async (blob: Blob): Promise => { const form = new FormData(); form.append('image', blob, 'gradcam_input.jpg'); @@ -130,15 +122,9 @@ export const api = { 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; + const validRes = await handleResponse(res); + 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 && (