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 ? (
-
-
{/* 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 && (
)}
-
-
-
-
);
-}
+}
\ 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