Skip to content
Open
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
34 changes: 17 additions & 17 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,30 @@ import MarketMapPage from './pages/MarketMapPage';
import ResultsPage from './pages/ResultsPage';
import PostHogPageView from './components/PostHogPageView';
import NotFound from './pages/NotFound';
// Import the context wrapper
import { ComparisonProvider } from './context/ComparisonContext';

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 />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/mode" element={<ModeSelectPage />} />
<Route path="/scanner" element={<ScannerPage />} />
<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>
{/* Wrap everything cleanly inside the ComparisonProvider */}
<ComparisonProvider>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<LandingPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/mode" element={<ModeSelectPage />} />
<Route path="/scanner" element={<ScannerPage />} />
<Route path="/analysis" element={<AnalysisDashboard />} />
<Route path="/map" element={<MarketMapPage />} />
<Route path="/results" element={<ResultsPage />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</ComparisonProvider>
</BrowserRouter>
);
}
48 changes: 48 additions & 0 deletions src/context/ComparisonContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface ComparisonContextType {
selectedScanIds: string[];
toggleScanSelection: (scanId: string) => void;
clearSelection: () => void;
isComparisonReady: boolean;
}

const ComparisonContext = createContext<ComparisonContextType | undefined>(undefined);

export function ComparisonProvider({ children }: { children: ReactNode }) {
const [selectedScanIds, setSelectedScanIds] = useState<string[]>([]);

const toggleScanSelection = (scanId: string) => {
setSelectedScanIds((prev) => {
if (prev.includes(scanId)) {
return prev.filter((id) => id !== scanId);
}
if (prev.length >= 4) return prev; // Limit to 4 cards for grid-view constraints
return [...prev, scanId];
});
};

const clearSelection = () => setSelectedScanIds([]);
const isComparisonReady = selectedScanIds.length >= 2;

return (
<ComparisonContext.Provider
value={{
selectedScanIds,
toggleScanSelection,
clearSelection,
isComparisonReady,
}}
>
{children}
</ComparisonContext.Provider>
);
}

export function useComparison() {

Check failure on line 42 in src/context/ComparisonContext.tsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const context = useContext(ComparisonContext);
if (!context) {
throw new Error('useComparison must be used within a ComparisonProvider');
}
return context;
}
44 changes: 30 additions & 14 deletions src/pages/AnalysisDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
export default function AnalysisDashboard() {
const [params] = useSearchParams();
const [scan, setScan] = useState<ScanResult | null>(null);
const [comparisonScans, setComparisonScans] = useState<ScanResult[]>([]);

Check failure on line 26 in src/pages/AnalysisDashboard.tsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'comparisonScans' is assigned a value but never used
const [isComparisonMode, setIsComparisonMode] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');

Expand All @@ -31,15 +33,31 @@
setLoading(true);
setError('');
try {
const idParam = params.get('id');
const lastId = sessionStorage.getItem('lastScanId');
const targetId = idParam || lastId;
const compareParam = params.get('compare');
const idParam = params.get('id');

const res = targetId
? await api.getScan(targetId)
: await api.getLatestScan();
if (compareParam) {
setIsComparisonMode(true);
const ids = compareParam.split(',');

const records = await Promise.all(ids.map(id => api.getScan(id)));
const parsedScans = records.map(res => res.scan);

setComparisonScans(parsedScans);
if (parsedScans.length > 0) {
setScan(parsedScans[0]);
}
} else {
setIsComparisonMode(false);
const lastId = sessionStorage.getItem('lastScanId');
const targetId = idParam || lastId;

setScan(res.scan);
const res = targetId
? await api.getScan(targetId)
: await api.getLatestScan();

setScan(res.scan);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load scan data.');
} finally {
Expand All @@ -49,17 +67,15 @@
load();
}, [params]);

// ── Loading state ────────────────────────────────────────────────────────
if (loading) {
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
<StatusTerminal messages={['LOADING_ANALYSIS...', 'FETCHING_RESULT']} />
<StatusTerminal messages={isComparisonMode ? ['LOADING_COMPARISONS...', 'FETCHING_MULTIPLE_RESULTS'] : ['LOADING_ANALYSIS...', 'FETCHING_RESULT']} />
</div>
);
}

// ── Error state ──────────────────────────────────────────────────────────
if (error || !scan) {
if (error || (!scan && !isComparisonMode)) {
return (
<div className="min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center gap-6 px-6">
<StatusTerminal messages={['LOAD_FAILED', 'NO_DATA']} />
Expand All @@ -76,8 +92,8 @@
);
}

const { freshness_index, grade, confidence, classification, species, biomarkers, recommendations } = scan;
const displayId = scan.scan_display_id;
const { freshness_index, grade, confidence, classification, species, biomarkers, recommendations } = scan!;
const displayId = scan!.scan_display_id;
const alerts = recommendations.alert_flags;

return (
Expand Down Expand Up @@ -293,4 +309,4 @@
</div>
</div>
);
}
}
Empty file added src/pages/ComparePage.tsx
Empty file.
157 changes: 101 additions & 56 deletions src/pages/ResultsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
import GlassCard from '../components/GlassCard';
import StatusTerminal from '../components/StatusTerminal';
import { api } from '../lib/api';
import type { HistoryScan, HistoryStats } from '../lib/types';
import { useComparison } from '../context/ComparisonContext';

export default function ResultsPage() {
const navigate = useNavigate();
const { selectedScanIds, toggleScanSelection, isComparisonReady } = useComparison();

const [scans, setScans] = useState<HistoryScan[]>([]);
const [stats, setStats] = useState<HistoryStats | null>(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -58,7 +62,7 @@ export default function ResultsPage() {
const freshRate = stats?.fresh_rate_percent ?? 0;

return (
<div className="min-h-[calc(100vh-4rem)] px-6 md:px-16 lg:px-24 py-8 md:py-12">
<div className="min-h-[calc(100vh-4rem)] px-6 md:px-16 lg:px-24 py-8 md:py-12 pb-28">
<div className="max-w-4xl mx-auto">
<StatusTerminal
messages={[
Expand Down Expand Up @@ -113,68 +117,109 @@ export default function ResultsPage() {
</div>
) : (
<div className="space-y-4">
{scans.map(h => (
<Link
key={h.id}
to={`/analysis?id=${h.id}`}
className="block no-underline group"
>
<GlassCard
className={`p-5 transition-all duration-200 group-hover:bg-surface-high ${h.is_fresh ? 'freshness-bar-fresh' : 'freshness-bar-spoiled'}`}
variant="tonal"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 min-w-0">
{/* Thumbnail */}
{h.photo_url && (
<img
src={h.photo_url}
alt={h.species_detected}
className="w-12 h-12 object-cover shrink-0 opacity-80 group-hover:opacity-100 transition-opacity"
/>
)}
{scans.map(h => {
const isChecked = selectedScanIds.includes(h.id);

return (
<div key={h.id} className="flex items-center gap-4">
{/* REQUIREMENT 1 & 2: Checkbox toggle placed side-by-side with scan card */}
<div className="flex items-center justify-center shrink-0 p-1">
<input
type="checkbox"
id={`compare-${h.id}`}
checked={isChecked}
onChange={() => toggleScanSelection(h.id)}
className="w-5 h-5 accent-neon cursor-pointer bg-surface-mid border-on-surface-variant/30 rounded focus:ring-0"
/>
</div>

<div className="min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-[family-name:var(--font-display)] text-base font-bold">
{h.species_detected}
</h3>
<span className="font-[family-name:var(--font-mono)] text-[0.5rem] tracking-widest text-neon-text bg-surface-highest px-2 py-0.5">
{h.grade}
</span>
<Link
to={`/analysis?id=${h.id}`}
className="block no-underline group flex-1 min-w-0"
>
<GlassCard
className={`p-5 transition-all duration-200 group-hover:bg-surface-high ${
isChecked
? 'border-neon ring-1 ring-neon/30'
: h.is_fresh ? 'freshness-bar-fresh' : 'freshness-bar-spoiled'
}`}
variant="tonal"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 min-w-0">
{/* Thumbnail */}
{h.photo_url && (
<img
src={h.photo_url}
alt={h.species_detected}
className="w-12 h-12 object-cover shrink-0 opacity-80 group-hover:opacity-100 transition-opacity"
/>
)}

<div className="min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-[family-name:var(--font-display)] text-base font-bold">
{h.species_detected}
</h3>
<span className="font-[family-name:var(--font-mono)] text-[0.5rem] tracking-widest text-neon-text bg-surface-highest px-2 py-0.5">
{h.grade}
</span>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span className="font-[family-name:var(--font-mono)] text-[0.5625rem] tracking-widest text-on-surface-variant">
{h.scan_display_id}
</span>
<span className="font-[family-name:var(--font-mono)] text-[0.5625rem] tracking-widest text-on-surface-variant">
{h.market_name}
</span>
{h.timestamp && (
<span className="font-[family-name:var(--font-mono)] text-[0.5625rem] tracking-widest text-on-surface-variant">
{new Date(h.timestamp).toLocaleString('en-IN', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span className="font-[family-name:var(--font-mono)] text-[0.5625rem] tracking-widest text-on-surface-variant">
{h.scan_display_id}
</span>
<span className="font-[family-name:var(--font-mono)] text-[0.5625rem] tracking-widest text-on-surface-variant">
{h.market_name}

<div className="flex items-center gap-4 shrink-0">
<span className={`font-[family-name:var(--font-display)] text-2xl font-bold ${h.is_fresh ? 'text-secondary' : 'text-error'}`}>
{h.freshness_index}
</span>
{h.timestamp && (
<span className="font-[family-name:var(--font-mono)] text-[0.5625rem] tracking-widest text-on-surface-variant">
{new Date(h.timestamp).toLocaleString('en-IN', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
)}
<ArrowRight size={16} className="text-on-surface-variant group-hover:text-neon transition-colors" />
</div>
</div>
</div>
</GlassCard>
</Link>
</div>
);
})}
</div>
)}

<div className="flex items-center gap-4 shrink-0">
<span className={`font-[family-name:var(--font-display)] text-2xl font-bold ${h.is_fresh ? 'text-secondary' : 'text-error'}`}>
{h.freshness_index}
</span>
<ArrowRight size={16} className="text-on-surface-variant group-hover:text-neon transition-colors" />
</div>
</div>
</GlassCard>
</Link>
))}
{/* REQUIREMENT 3 & 4: Floating action bar to maintain selection state & conditionally enable action */}
{selectedScanIds.length > 0 && (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50 bg-surface-mid border border-on-surface-variant/20 shadow-2xl rounded-xl p-4 flex items-center justify-between gap-6 max-w-md w-11/12 animate-in fade-in slide-in-from-bottom-4 duration-200">
<span className="font-[family-name:var(--font-mono)] text-xs tracking-widest text-on-surface">
SELECTED: <span className="text-neon font-bold">{selectedScanIds.length}</span>/4_SCANS
</span>

<button
disabled={!isComparisonReady}
onClick={() => navigate(`/analysis?compare=${selectedScanIds.join(',')}`)}
className={`px-5 py-3 font-[family-name:var(--font-display)] font-bold text-xs tracking-wider transition-all duration-150 ${
isComparisonReady
? 'bg-neon text-on-primary hover:bg-neon-dim shadow-md active:scale-[0.98] cursor-pointer'
: 'bg-surface-highest text-on-surface-variant/40 cursor-not-allowed border border-on-surface-variant/10'
}`}
>
COMPARE_SELECTED
</button>
</div>
)}
</div>
</div>
);
}
}
Loading