# Plan 03: React Frontend Architecture **Status**: Pending Implementation **Date**: 2026-02-27 --- ## Objective Build a React-based frontend that enables users to: 1. Authenticate via JWT 2. Select Area of Interest (AOI) on an interactive map 3. Configure job parameters (year, model) 4. Submit inference jobs to the API 5. View real-time job status 6. Display results as tiled map layers 7. Download result GeoTIFFs --- ## 1. Architecture Overview ```mermaid graph TD A[React Frontend] -->|HTTPS| B[Ingress/Nginx] B -->|Proxy| C[FastAPI Backend] B -->|Proxy| D[TiTiler Tiles] C -->|JWT| E[Auth Handler] C -->|RQ| F[Redis Queue] F --> G[Worker] G -->|S3| H[MinIO] D -->|Read COG| H C -->|Presigned URL| A ``` ## 2. Page Structure ### 2.1 Routes | Path | Page | Description | |------|------|-------------| | `/` | Landing | Login form, demo info | | `/dashboard` | Main App | Map + job submission | | `/jobs` | Job List | User's job history | | `/jobs/[id]` | Job Detail | Result view + download | | `/admin` | Admin | Dataset upload, retraining | ### 2.2 Dashboard Layout ```tsx // app/dashboard/page.tsx export default function DashboardPage() { return (
{/* Sidebar */} {/* Map Area */}
); } ``` --- ## 2. Tech Stack | Layer | Technology | |-------|------------| | Framework | Next.js 14 (App Router) | | UI Library | Tailwind CSS + shadcn/ui | | Maps | Leaflet + react-leaflet | | State | Zustand | | API Client | TanStack Query (React Query) | | Forms | React Hook Form + Zod | --- ## 3. Project Structure ``` apps/web/ ├── app/ │ ├── layout.tsx # Root layout with auth provider │ ├── page.tsx # Landing/Login page │ ├── dashboard/ │ │ └── page.tsx # Main app page │ ├── jobs/ │ │ ├── page.tsx # Job list │ │ └── [id]/page.tsx # Job detail/result │ └── admin/ │ └── page.tsx # Admin panel ├── components/ │ ├── ui/ # shadcn components │ ├── map/ │ │ ├── MapView.tsx # Main map component │ │ ├── AoiSelector.tsx # Circle/polygon selection │ │ ├── LayerSwitcher.tsx │ │ └── Legend.tsx │ ├── job/ │ │ ├── JobForm.tsx # Job submission form │ │ ├── JobStatus.tsx # Status polling │ │ └── JobResults.tsx # Results display │ └── auth/ │ ├── LoginForm.tsx │ └── ProtectedRoute.tsx ├── lib/ │ ├── api.ts # API client │ ├── auth.ts # Auth utilities │ ├── map-utils.ts # Map helpers │ └── constants.ts # App constants ├── stores/ │ └── useAppStore.ts # Zustand store ├── types/ │ └── index.ts # TypeScript types └── public/ └── zimbabwe.geojson # Zimbabwe boundary ``` --- ## 4. Key Components ### 4.1 Authentication Flow ```mermaid sequenceDiagram participant User participant Frontend participant API participant Redis User->>Frontend: Enter email/password Frontend->>API: POST /auth/login API->>Redis: Verify credentials Redis-->>API: User data API-->>Frontend: JWT token Frontend->>Frontend: Store JWT in localStorage Frontend->>User: Redirect to dashboard ``` ### 4.2 Job Submission Flow ```mermaid sequenceDiagram participant User participant Frontend participant API participant Worker participant MinIO User->>Frontend: Submit AOI + params Frontend->>API: POST /jobs API->>Redis: Enqueue job API-->>Frontend: job_id Frontend->>Frontend: Start polling Worker->>Worker: Process (5-15 min) Worker->>MinIO: Upload COG Worker->>Redis: Update status Frontend->>API: GET /jobs/{id} API-->>Frontend: Status + download URL Frontend->>User: Show result ``` ### 4.3 Data Flow 1. User logs in → stores JWT 2. User selects AOI + year + model → POST /jobs 3. UI polls GET /jobs/{id} 4. When done: receives layer URLs (tiles) and download signed URL --- ## 5. Component Details ### 5.1 MapView Component ```tsx // components/map/MapView.tsx 'use client'; import { MapContainer, TileLayer, useMap } from 'react-leaflet'; import { useEffect } from 'react'; import L from 'leaflet'; interface MapViewProps { center: [number, number]; // [lat, lon] - Zimbabwe default zoom: number; children?: React.ReactNode; } export function MapView({ center, zoom, children }: MapViewProps) { return ( {/* Base layer - OpenStreetMap */} {/* Result layers from TiTiler - added dynamically */} {children} ); } ``` ### 5.2 AOI Selector ```tsx // components/map/AoiSelector.tsx 'use client'; import { useMapEvents, Circle, CircleMarker } from 'react-leaflet'; import { useState, useCallback } from 'react'; import L from 'leaflet'; interface AoiSelectorProps { onChange: (center: [number, number], radius: number) => void; maxRadiusKm: number; } export function AoiSelector({ onChange, maxRadiusKm }: AoiSelectorProps) { const [center, setCenter] = useState<[number, number] | null>(null); const [radius, setRadius] = useState(1000); // meters const map = useMapEvents({ click: (e) => { const { lat, lng } = e.latlng; setCenter([lat, lng]); onChange([lat, lng], radius); } }); return ( <> {center && ( )} ); } ``` ### 5.3 Job Status Polling ```tsx // components/job/JobStatus.tsx 'use client'; import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; interface JobStatusProps { jobId: string; onComplete: (result: any) => void; } export function JobStatus({ jobId, onComplete }: JobStatusProps) { const [status, setStatus] = useState('queued'); // Poll for status updates const { data, isLoading } = useQuery({ queryKey: ['job', jobId], queryFn: () => fetchJobStatus(jobId), refetchInterval: (query) => { const status = query.state.data?.status; if (status === 'finished' || status === 'failed') { return false; // Stop polling } return 5000; // Poll every 5 seconds }, }); useEffect(() => { if (data?.status === 'finished') { onComplete(data.result); } }, [data]); const steps = [ { id: 'queued', label: 'Queued', icon: '⏳' }, { id: 'processing', label: 'Processing', icon: '⚙️' }, { id: 'finished', label: 'Complete', icon: '✅' }, ]; // ... render progress steps } ``` ### 5.4 Layer Switcher ```tsx // components/map/LayerSwitcher.tsx 'use client'; import { useState } from 'react'; import { TileLayer } from 'react-leaflet'; interface Layer { id: string; name: string; urlTemplate: string; visible: boolean; } interface LayerSwitcherProps { layers: Layer[]; onToggle: (id: string) => void; } export function LayerSwitcher({ layers, onToggle }: LayerSwitcherProps) { const [activeLayer, setActiveLayer] = useState('refined'); return (

Layers

{layers.map(layer => ( ))}
); } ``` --- ## 6. State Management ### 6.1 Zustand Store ```typescript // stores/useAppStore.ts import { create } from 'zustand'; interface AppState { // Auth user: User | null; token: string | null; isAuthenticated: boolean; setAuth: (user: User, token: string) => void; logout: () => void; // Job currentJob: Job | null; setCurrentJob: (job: Job | null) => void; // Map aoiCenter: [number, number] | null; aoiRadius: number; setAoi: (center: [number, number], radius: number) => void; selectedYear: number; setYear: (year: number) => void; selectedModel: string; setModel: (model: string) => void; } export const useAppStore = create((set) => ({ // Auth user: null, token: null, isAuthenticated: false, setAuth: (user, token) => set({ user, token, isAuthenticated: true }), logout: () => set({ user: null, token: null, isAuthenticated: false }), // Job currentJob: null, setCurrentJob: (job) => set({ currentJob: job }), // Map aoiCenter: null, aoiRadius: 1000, setAoi: (center, radius) => set({ aoiCenter: center, aoiRadius: radius }), selectedYear: new Date().getFullYear(), setYear: (year) => set({ selectedYear: year }), selectedModel: 'lightgbm', setModel: (model) => set({ selectedModel: model }), })); ``` --- ## 7. API Client ### 7.1 API Service ```typescript // lib/api.ts const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.portfolio.techarvest.co.zw'; class ApiClient { private token: string | null = null; setToken(token: string) { this.token = token; } private async request(endpoint: string, options: RequestInit = {}): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json', ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...options.headers, }; const response = await fetch(`${API_BASE}${endpoint}`, { ...options, headers, }); if (!response.ok) { throw new Error(`API error: ${response.statusText}`); } return response.json(); } // Auth async login(email: string, password: string) { const formData = new URLSearchParams(); formData.append('username', email); formData.append('password', password); const response = await fetch(`${API_BASE}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formData, }); return response.json(); } // Jobs async createJob(jobData: JobRequest) { return this.request('/jobs', { method: 'POST', body: JSON.stringify(jobData), }); } async getJobStatus(jobId: string) { return this.request(`/jobs/${jobId}`); } async getJobResult(jobId: string) { return this.request(`/jobs/${jobId}/result`); } // Models async getModels() { return this.request('/models'); } } export const api = new ApiClient(); ``` --- ## 8. Pages & Routes ### 8.1 Route Structure | Path | Page | Description | |------|------|-------------| | `/` | Landing | Login form, demo info | | `/dashboard` | Main App | Map + job submission | | `/jobs` | Job List | User's job history | | `/jobs/[id]` | Job Detail | Result view + download | | `/admin` | Admin | Dataset upload, retraining | ### 8.2 Dashboard Page Layout ```tsx // app/dashboard/page.tsx export default function DashboardPage() { return (
{/* Sidebar */} {/* Map Area */}
); } ``` --- ## 9. Environment Variables ```bash # .env.local NEXT_PUBLIC_API_URL=https://api.portfolio.techarvest.co.zw NEXT_PUBLIC_TILES_URL=https://tiles.portfolio.techarvest.co.zw NEXT_PUBLIC_MAP_CENTER=-19.0,29.0 NEXT_PUBLIC_MAP_ZOOM=8 # JWT Secret (for token validation) JWT_SECRET=your-secret-here ``` --- ## 10. Implementation Checklist - [ ] Set up Next.js project with TypeScript - [ ] Install dependencies (leaflet, react-leaflet, tailwind, zustand, react-query) - [ ] Configure Tailwind CSS - [ ] Create auth components (LoginForm, ProtectedRoute) - [ ] Create API client - [ ] Implement Zustand store - [ ] Build MapView component - [ ] Build AoiSelector component - [ ] Build JobForm component - [ ] Build JobStatus component with polling - [ ] Build LayerSwitcher component - [ ] Build Legend component - [ ] Create dashboard page layout - [ ] Create job detail page - [ ] Add Zimbabwe boundary GeoJSON - [ ] Test end-to-end flow ### 11.1 UX Constraints - Zimbabwe-only - Max radius 5km - Summer season fixed (Sep–May) --- ## 11. Key Constraints ### 11.1 AOI Validation - Max radius: 5km (per API) - Must be within Zimbabwe bounds - Lon: 25.2 to 33.1, Lat: -22.5 to -15.6 ### 11.2 Year Range - Available: 2015 to present - Must match available DW baselines ### 11.3 Models - Default: `lightgbm` - Available: `randomforest`, `xgboost`, `catboost` ### 11.4 Rate Limits - 5 jobs per 24 hours per user - Global: 2 concurrent jobs --- ## 12. Next Steps After implementation approval: 1. Initialize Next.js project 2. Install and configure dependencies 3. Build authentication flow 4. Create map components 5. Build job submission and status UI 6. Add layer switching and legend 7. Test with mock data 8. Deploy to cluster