diff --git a/package-lock.json b/package-lock.json index 4b8563c..fba0c7e 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" @@ -68,6 +69,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -277,27 +279,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -592,6 +573,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -1491,6 +1473,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1557,6 +1540,7 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -1839,6 +1823,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1957,6 +1942,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2170,8 +2156,8 @@ "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" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -2272,6 +2258,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2632,6 +2619,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", @@ -2832,7 +2828,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/levn": { "version": "0.4.1", @@ -3315,6 +3312,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3436,6 +3434,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3445,6 +3444,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3452,6 +3452,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", @@ -3777,6 +3794,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3861,6 +3879,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4048,6 +4067,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } 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..14f81c6 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'; 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/components/Layout.tsx b/src/components/Layout.tsx index d281802..11d9c71 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -2,6 +2,7 @@ import { Outlet } from 'react-router-dom'; import Navbar from './Navbar'; import BottomNav from './BottomNav'; import Footer from './Footer'; +import { toggleTheme } from '../lib/theme'; export default function Layout() { return ( @@ -31,4 +32,4 @@ export default function Layout() { ); -} +} \ No newline at end of file diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 059c323..5b30fb5 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from 'react'; import { usePostHog } from 'posthog-js/react'; import { api, clearToken, isAuthenticated } from '../lib/api'; import type { UserProfile } from '../lib/types'; +import { toggleTheme } from '../lib/theme'; // Import the toggle function export default function Navbar() { const location = useLocation(); @@ -45,7 +46,6 @@ export default function Navbar() { const handleLogout = () => { setIsDropdownOpen(false); clearToken(); - // Reset PostHog session so next user on this device isn't tracked as this user posthog?.reset(); navigate('/'); setShowToast(true); @@ -90,51 +90,61 @@ export default function Navbar() { ))} - {/* Auth Button & Modal */} - {loggedIn ? ( -
- + {loggedIn ? ( +
+ + + {isDropdownOpen && ( +
+ setIsDropdownOpen(false)} + className="px-4 py-3 text-sm font-[family-name:var(--font-display)] font-bold text-on-surface-variant hover:text-neon hover:bg-surface-high no-underline transition-colors duration-200 block" + > + RESULTS + +
)} - - {profile?.full_name ? profile.full_name.split(' ')[0] : 'SESSION'} - - - - {isDropdownOpen && ( -
- setIsDropdownOpen(false)} - className="px-4 py-3 text-sm font-[family-name:var(--font-display)] font-bold text-on-surface-variant hover:text-neon hover:bg-surface-high no-underline transition-colors duration-200 block" - > - RESULTS - - -
- )} -
- ) : ( - - SIGN_IN / SIGN_UP - - )} +
+ ) : ( + + SIGN_IN / SIGN_UP + + )} + {/* Logout Toast Notification */} @@ -146,4 +156,4 @@ export default function Navbar() { ); -} +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 2902d04..167b9ad 100644 --- a/src/index.css +++ b/src/index.css @@ -6,9 +6,13 @@ Brutalist. Zero-radius. High-contrast neon. ========================================================= */ -/* ── THEME TOKENS (from Stitch: NEON NEURAL) ── */ +/* ── THEME TOKENS ── */ @theme { - --color-bg: #131313; + /* Mapping tokens to CSS variables */ + --color-bg: var(--bg); + --color-on-surface: var(--on-surface); + --color-heading: var(--heading-color); + --color-bg-void: #000000; --color-surface: #131313; --color-surface-lowest: #0e0e0e; @@ -26,7 +30,6 @@ --color-secondary: #b5d25e; --color-secondary-container: #5d7602; - --color-on-surface: #e2e2e2; --color-on-surface-variant: #c4c9ac; --color-on-primary: #283500; @@ -43,6 +46,19 @@ --font-mono: 'Space Mono', 'Space Grotesk', ui-monospace, monospace; } +/* ── DYNAMIC THEME VARIABLES ── */ +:root { + --bg: #131313; + --on-surface: #e2e2e2; + --heading-color: #ffffff; +} + +:root.light { + --bg: #ffffff; + --on-surface: #121212; + --heading-color: #121212; +} + /* ── GLOBAL RESET: ZERO RADIUS ── */ *, *::before, @@ -55,6 +71,7 @@ html { font-family: var(--font-body); background-color: var(--color-bg); color: var(--color-on-surface); + transition: background-color 0.3s, color 0.3s; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; @@ -80,7 +97,7 @@ h1, h2, h3, h4, h5, h6 { font-family: var(--font-display); font-weight: 700; letter-spacing: -0.03em; - color: var(--color-tertiary); + color: var(--color-heading); margin: 0; } @@ -160,68 +177,36 @@ h1, h2, h3, h4, h5, h6 { background: linear-gradient(135deg, #ffffff 0%, #c3f400 100%); } -/* ── SCAN LINE ANIMATION ── */ +/* ── ANIMATIONS ── */ @keyframes scan-line { 0% { transform: translateY(-100%); } 100% { transform: translateY(100%); } } +.scan-line { animation: scan-line 2.5s ease-in-out infinite; } -.scan-line { - animation: scan-line 2.5s ease-in-out infinite; -} - -/* ── PULSE GLOW ── */ @keyframes pulse-glow { - 0%, 100% { - box-shadow: 0 0 12px rgba(195, 244, 0, 0.1); - } - 50% { - box-shadow: 0 0 28px rgba(195, 244, 0, 0.25); - } + 0%, 100% { box-shadow: 0 0 12px rgba(195, 244, 0, 0.1); } + 50% { box-shadow: 0 0 28px rgba(195, 244, 0, 0.25); } } +.pulse-glow { animation: pulse-glow 2.5s ease-in-out infinite; } -.pulse-glow { - animation: pulse-glow 2.5s ease-in-out infinite; -} - -/* ── CONCENTRIC PULSE (Map Markers) ── */ @keyframes concentric-pulse { - 0% { - transform: scale(1); - opacity: 0.6; - } - 100% { - transform: scale(2.5); - opacity: 0; - } + 0% { transform: scale(1); opacity: 0.6; } + 100% { transform: scale(2.5); opacity: 0; } } -/* ── DATA STREAM ── */ @keyframes data-stream { 0% { opacity: 0.3; } 50% { opacity: 1; } 100% { opacity: 0.3; } } +.data-stream { animation: data-stream 1.5s ease-in-out infinite; } -.data-stream { - animation: data-stream 1.5s ease-in-out infinite; -} - -/* ── FADE IN UP ── */ @keyframes fade-in-up { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.animate-in { - animation: fade-in-up 0.6s ease-out forwards; + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } } +.animate-in { animation: fade-in-up 0.6s ease-out forwards; } /* ── VIEWFINDER CORNER BRACKETS ── */ .viewfinder-corner { @@ -230,36 +215,16 @@ h1, h2, h3, h4, h5, h6 { height: 28px; border-color: var(--color-secondary); } -.viewfinder-corner.top-left { - top: 0; left: 0; - border-top: 2px solid; - border-left: 2px solid; -} -.viewfinder-corner.top-right { - top: 0; right: 0; - border-top: 2px solid; - border-right: 2px solid; -} -.viewfinder-corner.bottom-left { - bottom: 0; left: 0; - border-bottom: 2px solid; - border-left: 2px solid; -} -.viewfinder-corner.bottom-right { - bottom: 0; right: 0; - border-bottom: 2px solid; - border-right: 2px solid; -} +.viewfinder-corner.top-left { top: 0; left: 0; border-top: 2px solid; border-left: 2px solid; } +.viewfinder-corner.top-right { top: 0; right: 0; border-top: 2px solid; border-right: 2px solid; } +.viewfinder-corner.bottom-left { bottom: 0; left: 0; border-bottom: 2px solid; border-left: 2px solid; } +.viewfinder-corner.bottom-right { bottom: 0; right: 0; border-bottom: 2px solid; border-right: 2px solid; } /* ── FRESHNESS BAR ── */ -.freshness-bar-fresh { - border-left: 4px solid var(--color-secondary); -} -.freshness-bar-spoiled { - border-left: 4px solid var(--color-error); -} +.freshness-bar-fresh { border-left: 4px solid var(--color-secondary); } +.freshness-bar-spoiled { border-left: 4px solid var(--color-error); } -/* ── INPUT FIELDS (bottom-border only) ── */ +/* ── INPUT FIELDS ── */ input[type="text"], input[type="email"], input[type="password"], @@ -293,7 +258,6 @@ input::placeholder { letter-spacing: 0.05em; } -/* ── UTILITY: No Scrollbar ── */ .no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } @@ -316,10 +280,22 @@ input::placeholder { border-top: none !important; border-left: none !important; } -.brutalist-popup .leaflet-popup-content { - margin: 0 !important; -} -.custom-leaflet-icon { - background: transparent !important; - border: none !important; +.brutalist-popup .leaflet-popup-content { margin: 0 !important; } +.custom-leaflet-icon { background: transparent !important; border: none !important; } + +:root.light { + --bg: #ffffff; + --on-surface: #121212; + --heading-color: #121212; + /* Surface and border tokens for light mode readability */ + --color-surface: #f7f7f7; + --color-surface-lowest: #ffffff; + --color-surface-low: #f2f2f2; + --color-surface-mid: #e8e8e8; + --color-surface-high: #dedede; + --color-surface-highest: #d4d4d4; + --color-on-surface-variant: #3d3d3d; + --color-outline: #6b6b6b; + --color-outline-variant: #c9c9c9; + --color-tertiary: #121212; } \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 3ce7b8e..84ae3d0 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,41 @@ function authHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } -// ── Core fetch wrapper ──────────────────────────────────────────────────────── +// ── Shared Error Handling Logic ────────────────────────────────────────────── + +// ── Shared Error Handling Logic ────────────────────────────────────────────── + +async function handleResponse(res: Response): Promise { + if (res.ok) return res; + + // Handle 5xx errors (Server Side) + if (res.status >= 500) { + const msg = "Server error. Please try again later."; + toast.error(msg); + throw new Error(msg); + } + + // Handle 4xx errors + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error((err as { detail?: string }).detail || `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 +79,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/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..b130440 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,34 @@ +export const toggleTheme = () => { + const isLight = document.documentElement.classList.toggle('light'); + try { + localStorage.setItem('theme', isLight ? 'light' : 'dark'); + } catch (e) { + console.warn("Unable to save theme preference:", e); + } +}; + +export const initTheme = () => { + try { + const savedTheme = localStorage.getItem('theme'); + const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + + if (savedTheme === 'light') { + document.documentElement.classList.add('light'); + } else if (savedTheme === 'dark') { + document.documentElement.classList.remove('light'); + } else if (systemPrefersDark) { + document.documentElement.classList.remove('light'); + } else { + // Default to light if that's the desired site baseline, + // or add 'light' class if the site defaults to dark + document.documentElement.classList.add('light'); + } + } catch (e) { + // If localStorage is blocked, fall back to system preference + const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + if (!systemPrefersDark) { + document.documentElement.classList.add('light'); + } + console.warn("Unable to read theme preference:", e); + } +}; \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 792d000..fac91d0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,10 @@ import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' import './index.css' import App from './App.tsx' +import { initTheme } from './lib/theme'; + +// Initialize theme before rendering the app to prevent flicker +initTheme(); // PostHog is only initialized when the key is present. // Contributors running locally without the key will have it silently disabled. @@ -28,4 +32,4 @@ createRoot(document.getElementById('root')!).render( , -) +) \ 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 && (