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
49 changes: 49 additions & 0 deletions package-lock.json

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete this file as it is not needed.

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

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@tanstack/react-query": "^5.83.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/leaflet": "^1.9.21",
"@vitest/coverage-v8": "^4.0.5",
"@vitest/ui": "^4.0.5",
"axios": "^1.12.2",
Expand All @@ -67,6 +68,7 @@
"input-otp": "^1.4.2",
"jsdom": "^27.0.1",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
Expand All @@ -76,6 +78,7 @@
"react-hook-form": "^7.61.1",
"react-i18next": "^17.0.8",
"react-is": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-resizable-panels": "^3.0.3",
"react-router-dom": "^7.7.1",
"recharts": "^2.15.4",
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const TestHedera = lazy(() => import('./pages/TestHedera'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const RiskIntelligence = lazy(() => import('./pages/RiskIntelligence'));
const BatchJourney = lazy(() => import('./pages/BatchJourney'));
const MapExplore = lazy(() => import('./pages/MapExplore'));

const queryClient = new QueryClient();

Expand Down Expand Up @@ -61,6 +62,7 @@ const App = () => (
<Route path="/demo" element={<Navigate to={DEMO_VERIFY_URL} replace />} />
<Route path="/test-hedera" element={<ProtectedRoute><TestHedera /></ProtectedRoute>} />
<Route path="/risk-intelligence" element={<ProtectedRoute><RiskIntelligence /></ProtectedRoute>} />
<Route path="/map" element={<ProtectedRoute><MapExplore /></ProtectedRoute>} />
<Route path="/journey/:batchId" element={<BatchJourney />} />
</Routes>
</Suspense>
Expand Down
4 changes: 4 additions & 0 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
FileText,
Coins,
ShieldCheck,
ShieldAlert,
MapPin,
User,
Settings,
LogOut,
Expand Down Expand Up @@ -54,6 +56,8 @@ export default function Navbar() {
{ to: "/register", label: t('nav.register'), icon: FileText },
{ to: "/tokenize", label: t('nav.tokenize'), icon: Coins },
{ to: "/verify", label: t('nav.verify'), icon: ShieldCheck },
{ to: "/risk-intelligence", label: "Risk Intelligence", icon: ShieldAlert },
{ to: "/map", label: "Explore Map", icon: MapPin },
{ to: "/about", label: t('nav.about'), icon: Info },
];

Expand Down
130 changes: 130 additions & 0 deletions src/components/SupplyChainMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns';

// Fix for default marker icon in react-leaflet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});

// A simple dictionary to map common string locations to approx coordinates (Lat, Lng)
const REGION_COORDINATES: Record<string, [number, number]> = {
'Lampung': [-4.5586, 105.4068],
'Sumatra': [-0.5897, 101.3431],
'Java': [-7.6145, 110.7122],
'Bali': [-8.4095, 115.1889],
'Kalimantan': [1.4326, 114.1511],
'Sulawesi': [-1.4300, 121.4456],
'Papua': [-4.2699, 138.0803],
'Jakarta': [-6.2088, 106.8456],
'Bandung': [-6.9175, 107.6191],
'Surabaya': [-7.2504, 112.7688],
'Default': [-0.7893, 113.9213] // Center of Indonesia
};

export interface MapBatch {
id: string;
batch_name: string;
location: string;
quantity: number;
harvest_date: string;
status: string;
farmer_id: string;
hcs_tx_id?: string;
ai_analysis?: {
riskLevel?: string;
};
}

interface SupplyChainMapProps {
batches: MapBatch[];
}

function getCoordinates(locationStr: string): [number, number] {
if (!locationStr) return REGION_COORDINATES['Default'];

const loc = locationStr.toLowerCase();
for (const [region, coords] of Object.entries(REGION_COORDINATES)) {
if (loc.includes(region.toLowerCase())) {
// Add a tiny random jitter so markers in the same region don't completely overlap
const jitterLat = (Math.random() - 0.5) * 0.5;
const jitterLng = (Math.random() - 0.5) * 0.5;
return [coords[0] + jitterLat, coords[1] + jitterLng];
}
}

// If not found, place in center of Indonesia with wider jitter
const jitterLat = (Math.random() - 0.5) * 5;
const jitterLng = (Math.random() - 0.5) * 10;
return [REGION_COORDINATES['Default'][0] + jitterLat, REGION_COORDINATES['Default'][1] + jitterLng];
}

export function SupplyChainMap({ batches }: SupplyChainMapProps) {
// Center of Indonesia
const centerPosition: [number, number] = [-0.7893, 113.9213];

return (
<Card className="w-full h-[600px] overflow-hidden rounded-xl border border-border shadow-sm">
<MapContainer
center={centerPosition}
zoom={5}
scrollWheelZoom={true}
style={{ height: '100%', width: '100%', zIndex: 0 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>

{batches.map((batch) => {
const position = getCoordinates(batch.location);
const riskLevel = batch.ai_analysis?.riskLevel || 'Unknown';

let riskColor = 'bg-gray-500';
if (riskLevel.toLowerCase() === 'low') riskColor = 'bg-emerald-500';
if (riskLevel.toLowerCase() === 'medium') riskColor = 'bg-yellow-500';
if (riskLevel.toLowerCase() === 'high') riskColor = 'bg-red-500';

return (
<Marker key={batch.id} position={position}>
<Popup className="custom-popup">
<div className="flex flex-col space-y-2 p-1 min-w-[200px]">
<h3 className="font-bold text-lg leading-tight">{batch.batch_name}</h3>
<div className="flex items-center space-x-2">
<Badge variant="outline" className={riskColor + " text-white border-none"}>
{riskLevel} Risk
</Badge>
<Badge variant="secondary">{batch.status}</Badge>
</div>
<div className="text-sm text-muted-foreground mt-2">
<p><strong>Location:</strong> {batch.location || 'Unknown'}</p>
<p><strong>Quantity:</strong> {batch.quantity} kg</p>
<p><strong>Harvest:</strong> {batch.harvest_date ? format(new Date(batch.harvest_date), 'MMM d, yyyy') : 'N/A'}</p>
<p><strong>Farmer ID:</strong> {batch.farmer_id.substring(0, 8)}...</p>
</div>
{batch.hcs_tx_id && (
<a
href={`https://hashscan.io/testnet/transaction/${batch.hcs_tx_id}`}
target="_blank"
rel="noreferrer"
className="text-xs text-blue-500 hover:underline mt-2 inline-block"
>
View on HashScan
</a>
)}
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
</Card>
);
}
17 changes: 17 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,23 @@ export const getFraudOverview = async (): Promise<{ ok: boolean; data: FraudOver
return payload;
};

/**
* Fetch all registered batches from Supabase for the Supply Chain Map.
* Returns an array of batch records with location, quantity, and risk data.
*/
export const getBatches = async () => {
const { data, error } = await supabase
.from('batches')
.select('id, batch_name, location, quantity, harvest_date, status, farmer_id, hcs_tx_id, ai_analysis')
.order('created_at', { ascending: false });

if (error) {
throw new Error(error.message || 'Failed to fetch batches');
}

return data || [];
};

export interface AuditLogEntry {
token_id: string;
serial_number: string;
Expand Down
49 changes: 49 additions & 0 deletions src/pages/MapExplore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Helmet } from 'react-helmet-async';
import { useQuery } from '@tanstack/react-query';
import { getBatches } from '@/lib/api';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import { SupplyChainMap, MapBatch } from '@/components/SupplyChainMap';
import { Loader2 } from 'lucide-react';

export default function MapExplore() {
const { data: batches, isLoading, error } = useQuery({
queryKey: ['explore-batches'],
queryFn: getBatches,
});

return (
<div className="min-h-screen bg-background text-foreground flex flex-col">
<Helmet>
<title>Supply Chain Map | AgroDex</title>
</Helmet>
<Navbar />

<main className="flex-grow container mx-auto px-4 py-8 max-w-7xl">
<div className="mb-6">
<h1 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
Supply Chain Map
</h1>
<p className="text-muted-foreground mt-2">
Explore registered agricultural batches across Indonesia. Click on any pin to view provenance, fraud risk, and Hedera verification status.
</p>
</div>

{isLoading ? (
<div className="flex items-center justify-center h-[600px] border rounded-xl bg-gray-50/50 dark:bg-gray-900/50">
<Loader2 className="w-10 h-10 animate-spin text-emerald-500" />
<span className="ml-3 text-lg text-gray-500">Loading supply chain data...</span>
</div>
) : error ? (
<div className="flex items-center justify-center h-[600px] border border-red-200 rounded-xl bg-red-50 text-red-600">
Failed to load map data. Please try again later.
</div>
) : (
<SupplyChainMap batches={batches as MapBatch[] || []} />
)}
</main>

<Footer />
</div>
);
}
Loading