Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<BrowserRouter>
{/* Toast provider for global error notifications */}
<Toaster position="bottom-right" />

{/* Fires a $pageview event to PostHog on every SPA route change */}
<PostHogPageView />

<Routes>
<Route element={<Layout />}>
<Route path="/" element={<LandingPage />} />
Expand All @@ -23,8 +29,11 @@ export default function App() {
<Route path="/analysis" element={<AnalysisDashboard />} />
<Route path="/map" element={<MarketMapPage />} />
<Route path="/results" element={<ResultsPage />} />

{/* Catch-all route for broken links/404s */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
}
111 changes: 49 additions & 62 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import toast from 'react-hot-toast';
import type {
ScanResult,
HistoryScan,
Expand Down Expand Up @@ -36,109 +37,95 @@ function authHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}

// ── Core fetch wrapper ────────────────────────────────────────────────────────
// ── Shared Error Handling Logic ──────────────────────────────────────────────

async function handleResponse(res: Response): Promise<Response> {
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<Response> {
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<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
const validRes = await safeFetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(options.headers as Record<string, string> || {}),
},
});

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<T>;
return validRes.json() as Promise<T>;
}

// ── 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<UserProfile> =>
apiFetch<UserProfile>('/api/v1/auth/me'),
getMe: (): Promise<UserProfile> => apiFetch<UserProfile>('/api/v1/auth/me'),

// Scans
// Scans - Using safeFetch to ensure network errors are caught
submitScan: async (blob: Blob): Promise<ScanResponse> => {
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<ScanResponse>;
return validRes.json() as Promise<ScanResponse>;
},

getLatestScan: (): Promise<ScanResponse> =>
apiFetch<ScanResponse>('/api/v1/scans/latest'),

getScan: (id: string): Promise<ScanResponse> =>
apiFetch<ScanResponse>(`/api/v1/scans/${id}`),

getScanHistory: (limit = 20, offset = 0): Promise<HistoryResponse> =>
getLatestScan: (): Promise<ScanResponse> => apiFetch<ScanResponse>('/api/v1/scans/latest'),
getScan: (id: string): Promise<ScanResponse> => apiFetch<ScanResponse>(`/api/v1/scans/${id}`),
getScanHistory: (limit = 20, offset = 0): Promise<HistoryResponse> =>
apiFetch<HistoryResponse>(`/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<GradcamResponse> => {
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<GradcamResponse>;
return validRes.json() as Promise<GradcamResponse>;
},

// Map
getMarkets: (): Promise<MarketsResponse> =>
apiFetch<MarketsResponse>('/api/v1/maps/markets'),
};
getMarkets: (): Promise<MarketsResponse> => apiFetch<MarketsResponse>('/api/v1/maps/markets'),
};
38 changes: 20 additions & 18 deletions src/pages/AuthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,51 @@ 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' });
navigate('/mode', { replace: true });
};

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'];
})();
Expand Down Expand Up @@ -110,7 +120,6 @@ export default function AuthPage() {
{status === 'processing' ? 'AUTHENTICATING...' : 'CONTINUE_WITH_GOOGLE'}
</button>

{/* DEV ONLY — shown only when VITE_DEV_MODE=true in .env.local */}
{IS_DEV_MODE && (
<button
type="button"
Expand All @@ -123,14 +132,7 @@ export default function AuthPage() {
</button>
)}
</div>

<div className="mt-12 pt-6 border-t border-outline-variant/15">
<StatusTerminal
messages={['SYS_STAT: ONLINE', 'UPTIME: 99.97%']}
className="justify-center mt-4"
/>
</div>
</div>
</div>
);
}
}
20 changes: 20 additions & 0 deletions src/pages/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// src/pages/NotFound.tsx
import { Link } from 'react-router-dom';

export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<h1 className="text-6xl font-bold text-gray-800 mb-4">404</h1>
<h2 className="text-2xl font-semibold text-gray-600 mb-6">Page Not Found</h2>
<p className="text-gray-500 mb-8">
Oops! The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Return Home
</Link>
</div>
);
}
Loading